fix(compiler): preserve &ngsp; between sibling control flow blocks

`findConnectedBlocks` scans siblings after an `@if` to collect connected
`@else`/`@else if` blocks. Whitespace-only text nodes encountered during
the scan were eagerly added to `processedNodes`, marking them as "do not
emit", before confirming whether a connected block actually followed.

By the time `findConnectedBlocks` runs, `WhitespaceVisitor` has already
converted `&ngsp;` (and ` `) into a plain space character, making
them indistinguishable from insignificant whitespace via `.trim().length`.
When the next sibling was a second, unrelated `@if` instead of `@else`,
the scan stopped but the text node was already silently dropped.

Fix by deferring the `processedNodes` insertion into a pending buffer and
only committing those nodes once a connected block is confirmed to follow.

Fixes #55791
This commit is contained in:
arturovt
2026-06-30 15:50:37 +03:00
committed by Jessica Janiuk
parent c2b14b7ab4
commit 53fc371142
2 changed files with 67 additions and 7 deletions
@@ -217,9 +217,7 @@ class HtmlAstToIvyAst implements html.Visitor {
attrs.bound,
boundEvents,
directives,
[
/* no template attributes */
],
[/* no template attributes */],
children,
references,
variables,
@@ -560,6 +558,11 @@ class HtmlAstToIvyAst implements html.Visitor {
predicate: (blockName: string) => boolean,
): html.Block[] {
const relatedBlocks: html.Block[] = [];
// Whitespace-only text nodes to mark as processed only if a connected block follows them.
// We defer this so that significant whitespace (e.g. &ngsp; or  , both of which look
// like insignificant whitespace by the time we get here) between two unrelated @if blocks
// is not silently eaten.
const pendingTextNodes: html.Text[] = [];
for (let i = primaryBlockIndex + 1; i < siblings.length; i++) {
const node = siblings[i];
@@ -569,11 +572,10 @@ class HtmlAstToIvyAst implements html.Visitor {
continue;
}
// Ignore empty text nodes between blocks.
if (node instanceof html.Text && node.value.trim().length === 0) {
// Add the text node to the processed nodes since we don't want
// it to be generated between the connected nodes.
this.processedNodes.add(node);
// Collect whitespace-only text nodes; only mark them as processed once we confirm
// they precede a connected block (e.g. @else / @else if).
pendingTextNodes.push(node);
continue;
}
@@ -582,6 +584,12 @@ class HtmlAstToIvyAst implements html.Visitor {
break;
}
// A connected block was found — commit the pending whitespace nodes as processed.
for (const pending of pendingTextNodes) {
this.processedNodes.add(pending);
}
pendingTextNodes.length = 0;
relatedBlocks.push(node);
this.processedNodes.add(node);
}
@@ -2636,6 +2636,58 @@ describe('R3 template transform', () => {
]);
});
it('should preserve &ngsp; between two sibling @if blocks', () => {
expectFromHtml(
`@if (true) {<span>Hello</span>}&ngsp;@if (true) {<span>World</span>}`,
).toEqual([
['IfBlock'],
['IfBlockBranch', 'true'],
['Element', 'span'],
['Text', 'Hello'],
['Text', ' '],
['IfBlock'],
['IfBlockBranch', 'true'],
['Element', 'span'],
['Text', 'World'],
]);
});
it('should preserve plain whitespace between two sibling @if blocks when preserveWhitespaces is enabled', () => {
expectFromR3Nodes(
parse(`@if (true) {<span>Hello</span>}\n@if (true) {<span>World</span>}`, {
preserveWhitespaces: true,
}).nodes,
).toEqual([
['IfBlock'],
['IfBlockBranch', 'true'],
['Element', 'span'],
['Text', 'Hello'],
['Text', '\n'],
['IfBlock'],
['IfBlockBranch', 'true'],
['Element', 'span'],
['Text', 'World'],
]);
});
it('should preserve &nbsp; between two sibling @if blocks when preserveWhitespaces is enabled', () => {
expectFromR3Nodes(
parse(`@if (true) {<span>Hello</span>}&nbsp;@if (true) {<span>World</span>}`, {
preserveWhitespaces: true,
}).nodes,
).toEqual([
['IfBlock'],
['IfBlockBranch', 'true'],
['Element', 'span'],
['Text', 'Hello'],
['Text', '\u00A0'],
['IfBlock'],
['IfBlockBranch', 'true'],
['Element', 'span'],
['Text', 'World'],
]);
});
describe('validations', () => {
it('should report an if block without a condition', () => {
expect(() =>