Skip to content

Commit 8274541

Browse files
nolanlawsonjhefferman-sfdcwjhsf
authored
fix(ssr): fix adjacent text node concatenation (#5079)
Co-authored-by: jhefferman-sfdc <[email protected]> Co-authored-by: Will Harney <[email protected]>
1 parent d2f169b commit 8274541

File tree

10 files changed

+133
-57
lines changed

10 files changed

+133
-57
lines changed

packages/@lwc/ssr-compiler/src/__tests__/utils/expected-failures.ts

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,6 @@ export const expectedFailures = new Set([
2121
'scoped-slots/mixed-with-light-dom-slots-outside/index.js',
2222
'slot-forwarding/slots/mixed/index.js',
2323
'slot-forwarding/slots/dangling/index.js',
24-
'slot-not-at-top-level/with-adjacent-text-nodes/lwcIf-as-sibling/light/index.js',
25-
'slot-not-at-top-level/with-adjacent-text-nodes/lwcIf/light/index.js',
26-
'slot-not-at-top-level/with-adjacent-text-nodes/if/light/index.js',
27-
'slot-not-at-top-level/with-adjacent-text-nodes/if-as-sibling/light/index.js',
2824
'wire/errors/throws-on-computed-key/index.js',
2925
'wire/errors/throws-when-colliding-prop-then-method/index.js',
3026
]);

packages/@lwc/ssr-compiler/src/compile-template/adjacent-text-nodes.ts

Lines changed: 71 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,33 @@
44
* SPDX-License-Identifier: MIT
55
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT
66
*/
7-
import { esTemplateWithYield } from '../estemplate';
8-
import type { IfStatement as EsIfStatement } from 'estree';
7+
import { builders as b } from 'estree-toolkit/dist/builders';
8+
import { is } from 'estree-toolkit';
9+
import { esTemplate, esTemplateWithYield } from '../estemplate';
10+
import { isLiteral } from './shared';
11+
import { expressionIrToEs } from './expression';
12+
import type {
13+
CallExpression as EsCallExpression,
14+
Expression as EsExpression,
15+
ExpressionStatement as EsExpressionStatement,
16+
} from 'estree';
917
import type { TransformerContext } from './types';
10-
import type { Node as IrNode } from '@lwc/template-compiler';
18+
import type { Node as IrNode, Text as IrText, Comment as IrComment } from '@lwc/template-compiler';
19+
20+
const bNormalizeTextContent = esTemplate`
21+
normalizeTextContent(${/* string value */ is.expression});
22+
`<EsCallExpression>;
23+
24+
const bYieldTextContent = esTemplateWithYield`
25+
yield renderTextContent(${/* text concatenation, possibly as binary expression */ is.expression});
26+
`<EsExpressionStatement>;
1127

1228
/**
1329
* True if this is one of a series of text content nodes and/or comment node that are adjacent to one another as
1430
* siblings. (Comment nodes are ignored when preserve-comments is turned off.) This allows for adjacent text
1531
* node concatenation.
1632
*/
17-
const isConcatenatedNode = (node: IrNode, cxt: TransformerContext) => {
33+
const isConcatenatedNode = (node: IrNode, cxt: TransformerContext): node is IrText | IrComment => {
1834
switch (node.type) {
1935
case 'Text':
2036
return true;
@@ -26,23 +42,62 @@ const isConcatenatedNode = (node: IrNode, cxt: TransformerContext) => {
2642
};
2743

2844
export const isLastConcatenatedNode = (cxt: TransformerContext) => {
29-
const { nextSibling } = cxt;
45+
const siblings = cxt.siblings!;
46+
const currentNodeIndex = cxt.currentNodeIndex!;
47+
48+
const nextSibling = siblings[currentNodeIndex + 1];
3049
if (!nextSibling) {
3150
// we are the last sibling
3251
return true;
3352
}
3453
return !isConcatenatedNode(nextSibling, cxt);
3554
};
3655

37-
export const bYieldTextContent = esTemplateWithYield`
38-
if (didBufferTextContent) {
39-
// We are at the end of a series of text nodes - flush to a concatenated string
40-
// We only render the ZWJ if there were actually any dynamic text nodes rendered
41-
// The ZWJ is just so hydration can compare the SSR'd dynamic text content against
42-
// the CSR'd text content.
43-
yield textContentBuffer === '' ? '\u200D' : htmlEscape(textContentBuffer);
44-
// Reset
45-
textContentBuffer = '';
46-
didBufferTextContent = false;
56+
function generateExpressionFromTextNode(node: IrText, cxt: TransformerContext) {
57+
return isLiteral(node.value) ? b.literal(node.value.value) : expressionIrToEs(node.value, cxt);
58+
}
59+
60+
export function generateConcatenatedTextNodesExpressions(cxt: TransformerContext) {
61+
const siblings = cxt.siblings!;
62+
const currentNodeIndex = cxt.currentNodeIndex!;
63+
64+
const textNodes = [];
65+
66+
for (let i = currentNodeIndex; i >= 0; i--) {
67+
const sibling = siblings[i];
68+
if (isConcatenatedNode(sibling, cxt)) {
69+
if (sibling.type === 'Text') {
70+
textNodes.unshift(sibling);
71+
}
72+
} else {
73+
// If we reach a non-Text/Comment node, we are done. These should not be concatenated
74+
// with sibling Text nodes separated by e.g. an Element:
75+
// {a}{b}<div></div>{c}{d}
76+
// In the above, {a} and {b} are concatenated, and {c} and {d} are concatenated,
77+
// but the `<div>` separates the two groups.
78+
break;
79+
}
4780
}
48-
`<EsIfStatement>;
81+
82+
if (!textNodes.length) {
83+
// Render nothing. This can occur if we hit a comment in non-preserveComments mode with no adjacent text nodes
84+
return [];
85+
}
86+
87+
cxt.import(['normalizeTextContent', 'renderTextContent']);
88+
89+
// Generate a binary expression to concatenate the text together. E.g.:
90+
// renderTextContent(
91+
// normalizeTextContent(a) +
92+
// normalizeTextContent(b) +
93+
// normalizeTextContent(c)
94+
// )
95+
const concatenatedExpression = textNodes
96+
.map(
97+
(node) =>
98+
bNormalizeTextContent(generateExpressionFromTextNode(node, cxt)) as EsExpression
99+
)
100+
.reduce((accumulator, expression) => b.binaryExpression('+', accumulator, expression));
101+
102+
return [bYieldTextContent(concatenatedExpression)];
103+
}

packages/@lwc/ssr-compiler/src/compile-template/context.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ export function createNewContext(templateOptions: TemplateOpts): {
4242
isLocalVar,
4343
templateOptions,
4444
import: importManager.add.bind(importManager),
45+
siblings: undefined,
46+
currentNodeIndex: undefined,
4547
},
4648
};
4749
}

packages/@lwc/ssr-compiler/src/compile-template/ir-to-es.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -75,15 +75,16 @@ export function irChildrenToEs(
7575
const result: EsStatement[] = [];
7676

7777
for (let i = 0; i < children.length; i++) {
78-
cxt.prevSibling = children[i - 1];
79-
cxt.nextSibling = children[i + 1];
78+
// must set the siblings inside the for loop due to nested children
79+
cxt.siblings = children;
80+
cxt.currentNodeIndex = i;
8081
const cleanUp = cb?.(children[i]);
8182
result.push(...irToEs(children[i], cxt));
8283
cleanUp?.();
8384
}
84-
85-
cxt.prevSibling = undefined;
86-
cxt.nextSibling = undefined;
85+
// reset the context
86+
cxt.siblings = undefined;
87+
cxt.currentNodeIndex = undefined;
8788

8889
return result;
8990
}

packages/@lwc/ssr-compiler/src/compile-template/transformers/comment.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,21 +7,25 @@
77

88
import { builders as b } from 'estree-toolkit';
99

10-
import { bYieldTextContent, isLastConcatenatedNode } from '../adjacent-text-nodes';
10+
import {
11+
generateConcatenatedTextNodesExpressions,
12+
isLastConcatenatedNode,
13+
} from '../adjacent-text-nodes';
1114
import type { Comment as IrComment } from '@lwc/template-compiler';
1215
import type { Transformer } from '../types';
1316

1417
export const Comment: Transformer<IrComment> = function Comment(node, cxt) {
1518
if (cxt.templateOptions.preserveComments) {
1619
return [b.expressionStatement(b.yieldExpression(b.literal(`<!--${node.value}-->`)))];
1720
} else {
18-
cxt.import('htmlEscape');
19-
2021
const isLastInSeries = isLastConcatenatedNode(cxt);
2122

2223
// If preserve comments is off, we check if we should flush text content
2324
// for adjacent text nodes. (If preserve comments is on, then the previous
2425
// text node already flushed.)
25-
return [...(isLastInSeries ? [bYieldTextContent()] : [])];
26+
if (isLastInSeries) {
27+
return generateConcatenatedTextNodesExpressions(cxt);
28+
}
29+
return [];
2630
}
2731
};

packages/@lwc/ssr-compiler/src/compile-template/transformers/component/slotted-content.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { irChildrenToEs, irToEs } from '../../ir-to-es';
1313
import { isLiteral } from '../../shared';
1414
import { expressionIrToEs } from '../../expression';
1515
import { isNullableOf } from '../../../estree/validators';
16+
import { isLastConcatenatedNode } from '../../adjacent-text-nodes';
1617
import type { CallExpression as EsCallExpression, Expression as EsExpression } from 'estree';
1718

1819
import type {
@@ -156,6 +157,9 @@ function getLightSlottedContent(rootNodes: IrChildNode[], cxt: TransformerContex
156157

157158
const traverse = (nodes: IrChildNode[], ancestorIndices: number[]) => {
158159
for (let i = 0; i < nodes.length; i++) {
160+
// must set the siblings inside the for loop due to nested children
161+
cxt.siblings = nodes;
162+
cxt.currentNodeIndex = i;
159163
const node = nodes[i];
160164
switch (node.type) {
161165
// SlottableAncestorIrType
@@ -175,11 +179,21 @@ function getLightSlottedContent(rootNodes: IrChildNode[], cxt: TransformerContex
175179
// '' is the default slot name. Text nodes are always slotted into the default slot
176180
const slotName =
177181
node.type === 'Text' ? b.literal('') : bAttributeValue(node, 'slot');
182+
183+
// For concatenated adjacent text nodes, for any but the final text node, we
184+
// should skip them and let the final text node take care of rendering its siblings
185+
if (node.type === 'Text' && !isLastConcatenatedNode(cxt)) {
186+
continue;
187+
}
188+
178189
addLightDomSlotContent(slotName, [...ancestorIndices, i]);
179190
break;
180191
}
181192
}
182193
}
194+
// reset the context
195+
cxt.siblings = undefined;
196+
cxt.currentNodeIndex = undefined;
183197
};
184198

185199
traverse(rootNodes, []);

packages/@lwc/ssr-compiler/src/compile-template/transformers/text.ts

Lines changed: 11 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -5,32 +5,20 @@
55
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT
66
*/
77

8-
import { builders as b, is } from 'estree-toolkit';
9-
import { esTemplateWithYield } from '../../estemplate';
10-
import { expressionIrToEs } from '../expression';
11-
import { isLiteral } from '../shared';
12-
13-
import { bYieldTextContent, isLastConcatenatedNode } from '../adjacent-text-nodes';
14-
import type {
15-
Statement as EsStatement,
16-
ExpressionStatement as EsExpressionStatement,
17-
} from 'estree';
8+
import {
9+
generateConcatenatedTextNodesExpressions,
10+
isLastConcatenatedNode,
11+
} from '../adjacent-text-nodes';
12+
import type { Statement as EsStatement } from 'estree';
1813
import type { Text as IrText } from '@lwc/template-compiler';
1914
import type { Transformer } from '../types';
2015

21-
const bBufferTextContent = esTemplateWithYield`
22-
didBufferTextContent = true;
23-
textContentBuffer += massageTextContent(${/* string value */ is.expression});
24-
`<EsExpressionStatement[]>;
25-
2616
export const Text: Transformer<IrText> = function Text(node, cxt): EsStatement[] {
27-
cxt.import(['htmlEscape', 'massageTextContent']);
28-
29-
const isLastInSeries = isLastConcatenatedNode(cxt);
30-
31-
const valueToYield = isLiteral(node.value)
32-
? b.literal(node.value.value)
33-
: expressionIrToEs(node.value, cxt);
17+
if (isLastConcatenatedNode(cxt)) {
18+
// render all concatenated content up to us
19+
return generateConcatenatedTextNodesExpressions(cxt);
20+
}
3421

35-
return [...bBufferTextContent(valueToYield), ...(isLastInSeries ? [bYieldTextContent()] : [])];
22+
// our last sibling is responsible for rendering our content, not us
23+
return [];
3624
};

packages/@lwc/ssr-compiler/src/compile-template/types.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@ export interface TransformerContext {
1818
popLocalVars: () => void;
1919
isLocalVar: (varName: string | null | undefined) => boolean;
2020
templateOptions: TemplateOpts;
21-
prevSibling?: IrNode;
22-
nextSibling?: IrNode;
21+
siblings: IrNode[] | undefined;
22+
currentNodeIndex: number | undefined;
2323
isSlotted?: boolean;
2424
import: (
2525
imports: string | string[] | Record<string, string | undefined>,

packages/@lwc/ssr-runtime/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ export {
3030
// renderComponent is an alias for serverSideRenderComponent
3131
serverSideRenderComponent as renderComponent,
3232
} from './render';
33-
export { massageTextContent } from './render-text-content';
33+
export { normalizeTextContent, renderTextContent } from './render-text-content';
3434
export { hasScopedStaticStylesheets, renderStylesheets } from './styles';
3535
export { toIteratorDirective } from './to-iterator-directive';
3636
export { validateStyleTextContents } from './validate-style-text-contents';

packages/@lwc/ssr-runtime/src/render-text-content.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,28 @@
55
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT
66
*/
77

8+
import { htmlEscape } from '@lwc/shared';
9+
810
/**
9-
* Given an object, render it for use as a text content node.
11+
* Given an object, render it for use as a text content node. Not that this applies to individual text nodes,
12+
* not the concatenated result of multiple adjacent text nodes.
1013
* @param value
1114
*/
12-
export function massageTextContent(value: unknown): string {
15+
export function normalizeTextContent(value: unknown): string {
1316
// Using non strict equality to align with original implementation (ex. undefined == null)
1417
// See: https://github.com/salesforce/lwc/blob/348130f/packages/%40lwc/engine-core/src/framework/api.ts#L548
1518
return value == null ? '' : String(value);
1619
}
20+
21+
/**
22+
* Given a string, render it for use as text content in HTML. Notably this escapes HTML and renders as
23+
* a ZWJ is empty. Intended to be used on the result of concatenating multiple adjacent text nodes together.
24+
* @param value
25+
*/
26+
export function renderTextContent(value: string): string {
27+
// We are at the end of a series of text nodes - flush to a concatenated string
28+
// We only render the ZWJ if there were actually any dynamic text nodes rendered
29+
// The ZWJ is just so hydration can compare the SSR'd dynamic text content against
30+
// the CSR'd text content.
31+
return value === '' ? '\u200D' : htmlEscape(value);
32+
}

0 commit comments

Comments
 (0)