Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,6 @@ export const expectedFailures = new Set([
'scoped-slots/mixed-with-light-dom-slots-outside/index.js',
'slot-forwarding/slots/mixed/index.js',
'slot-forwarding/slots/dangling/index.js',
'slot-not-at-top-level/with-adjacent-text-nodes/lwcIf-as-sibling/light/index.js',
'slot-not-at-top-level/with-adjacent-text-nodes/lwcIf/light/index.js',
'slot-not-at-top-level/with-adjacent-text-nodes/if/light/index.js',
'slot-not-at-top-level/with-adjacent-text-nodes/if-as-sibling/light/index.js',
'wire/errors/throws-on-computed-key/index.js',
'wire/errors/throws-when-colliding-prop-then-method/index.js',
]);
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,26 @@
* SPDX-License-Identifier: MIT
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT
*/
import { esTemplateWithYield } from '../estemplate';
import type { IfStatement as EsIfStatement } from 'estree';
import { builders as b } from 'estree-toolkit/dist/builders';
import { is } from 'estree-toolkit';
import { esTemplate, esTemplateWithYield } from '../estemplate';
import { isLiteral } from './shared';
import { expressionIrToEs } from './expression';
import type {
CallExpression as EsCallExpression,
Expression as EsExpression,
ExpressionStatement as EsExpressionStatement,
} from 'estree';
import type { TransformerContext } from './types';
import type { Node as IrNode } from '@lwc/template-compiler';
import type { Node as IrNode, Text as IrText } from '@lwc/template-compiler';

const bMassageTextContent = esTemplate`
massageTextContent(${/* string value */ is.expression});
`<EsCallExpression>;

const bYieldTextContent = esTemplateWithYield`
yield renderTextContent(${/* text concatenation, possibly as binary expression */ is.expression});
`<EsExpressionStatement>;

/**
* True if this is one of a series of text content nodes and/or comment node that are adjacent to one another as
Expand All @@ -34,15 +50,37 @@ export const isLastConcatenatedNode = (cxt: TransformerContext) => {
return !isConcatenatedNode(nextSibling, cxt);
};

export const bYieldTextContent = esTemplateWithYield`
if (didBufferTextContent) {
// We are at the end of a series of text nodes - flush to a concatenated string
// We only render the ZWJ if there were actually any dynamic text nodes rendered
// The ZWJ is just so hydration can compare the SSR'd dynamic text content against
// the CSR'd text content.
yield textContentBuffer === '' ? '\u200D' : htmlEscape(textContentBuffer);
// Reset
textContentBuffer = '';
didBufferTextContent = false;
export function generateConcatenatedTextNodesExpressions(
cxt: TransformerContext,
lastValue?: EsExpression
) {
const values = [...cxt.bufferedTextNodeValues];
if (lastValue) {
values.push(lastValue);
}
`<EsIfStatement>;

if (!values.length) {
// Render nothing. This can occur if we hit a comment in non-preserveComments mode with no adjacent text nodes
return [];
}

cxt.import(['massageTextContent', 'renderTextContent']);

// Generate a binary expression to concatenate the text together. E.g.:
// renderTextContent(
// massageTextContent(a) +
// massageTextContent(b) +
// massageTextContent(c)
// )
const concatenatedExpression = values
.map((expression) => bMassageTextContent(expression) as EsExpression)
.reduce((accumulator, expression) => b.binaryExpression('+', accumulator, expression));

cxt.bufferedTextNodeValues.length = 0; // reset

return [bYieldTextContent(concatenatedExpression)];
}

export function generateExpressionFromTextNode(node: IrText, cxt: TransformerContext) {
return isLiteral(node.value) ? b.literal(node.value.value) : expressionIrToEs(node.value, cxt);
}
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export function createNewContext(templateOptions: TemplateOpts): {
isLocalVar,
templateOptions,
import: importManager.add.bind(importManager),
bufferedTextNodeValues: [],
},
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,25 @@

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

import { bYieldTextContent, isLastConcatenatedNode } from '../adjacent-text-nodes';
import {
generateConcatenatedTextNodesExpressions,
isLastConcatenatedNode,
} from '../adjacent-text-nodes';
import type { Comment as IrComment } from '@lwc/template-compiler';
import type { Transformer } from '../types';

export const Comment: Transformer<IrComment> = function Comment(node, cxt) {
if (cxt.templateOptions.preserveComments) {
return [b.expressionStatement(b.yieldExpression(b.literal(`<!--${node.value}-->`)))];
} else {
cxt.import('htmlEscape');

const isLastInSeries = isLastConcatenatedNode(cxt);

// If preserve comments is off, we check if we should flush text content
// for adjacent text nodes. (If preserve comments is on, then the previous
// text node already flushed.)
return [...(isLastInSeries ? [bYieldTextContent()] : [])];
if (isLastInSeries) {
return generateConcatenatedTextNodesExpressions(cxt);
}
return [];
}
};
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { irChildrenToEs, irToEs } from '../../ir-to-es';
import { isLiteral } from '../../shared';
import { expressionIrToEs } from '../../expression';
import { isNullableOf } from '../../../estree/validators';
import { generateExpressionFromTextNode, isLastConcatenatedNode } from '../../adjacent-text-nodes';
import type { CallExpression as EsCallExpression, Expression as EsExpression } from 'estree';

import type {
Expand Down Expand Up @@ -156,6 +157,8 @@ function getLightSlottedContent(rootNodes: IrChildNode[], cxt: TransformerContex

const traverse = (nodes: IrChildNode[], ancestorIndices: number[]) => {
for (let i = 0; i < nodes.length; i++) {
cxt.prevSibling = nodes[i - 1];
cxt.nextSibling = nodes[i + 1];
const node = nodes[i];
switch (node.type) {
// SlottableAncestorIrType
Expand All @@ -175,11 +178,22 @@ function getLightSlottedContent(rootNodes: IrChildNode[], cxt: TransformerContex
// '' is the default slot name. Text nodes are always slotted into the default slot
const slotName =
node.type === 'Text' ? b.literal('') : bAttributeValue(node, 'slot');

// Handle concatenated adjacent text nodes, which must be specially handled since we're
// doing our own traversal.
if (node.type === 'Text' && !isLastConcatenatedNode(cxt)) {
cxt.bufferedTextNodeValues.push(generateExpressionFromTextNode(node, cxt));
continue;
}

addLightDomSlotContent(slotName, [...ancestorIndices, i]);
break;
}
}
}
// reset the context
cxt.prevSibling = undefined;
cxt.nextSibling = undefined;
};

traverse(rootNodes, []);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,32 +5,22 @@
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT
*/

import { builders as b, is } from 'estree-toolkit';
import { esTemplateWithYield } from '../../estemplate';
import { expressionIrToEs } from '../expression';
import { isLiteral } from '../shared';

import { bYieldTextContent, isLastConcatenatedNode } from '../adjacent-text-nodes';
import type {
Statement as EsStatement,
ExpressionStatement as EsExpressionStatement,
} from 'estree';
import {
generateConcatenatedTextNodesExpressions,
generateExpressionFromTextNode,
isLastConcatenatedNode,
} from '../adjacent-text-nodes';
import type { Statement as EsStatement } from 'estree';
import type { Text as IrText } from '@lwc/template-compiler';
import type { Transformer } from '../types';

const bBufferTextContent = esTemplateWithYield`
didBufferTextContent = true;
textContentBuffer += massageTextContent(${/* string value */ is.expression});
`<EsExpressionStatement[]>;

export const Text: Transformer<IrText> = function Text(node, cxt): EsStatement[] {
cxt.import(['htmlEscape', 'massageTextContent']);

const isLastInSeries = isLastConcatenatedNode(cxt);
const valueToYield = generateExpressionFromTextNode(node, cxt);

const valueToYield = isLiteral(node.value)
? b.literal(node.value.value)
: expressionIrToEs(node.value, cxt);
if (!isLastConcatenatedNode(cxt)) {
cxt.bufferedTextNodeValues.push(valueToYield);
return [];
}

return [...bBufferTextContent(valueToYield), ...(isLastInSeries ? [bYieldTextContent()] : [])];
return generateConcatenatedTextNodesExpressions(cxt, valueToYield);
};
3 changes: 2 additions & 1 deletion packages/@lwc/ssr-compiler/src/compile-template/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
*/

import type { Node as IrNode } from '@lwc/template-compiler';
import type { Statement as EsStatement } from 'estree';
import type { Statement as EsStatement, Expression as EsExpression } from 'estree';

export type Transformer<T extends IrNode = IrNode> = (
node: T,
Expand All @@ -20,6 +20,7 @@ export interface TransformerContext {
templateOptions: TemplateOpts;
prevSibling?: IrNode;
nextSibling?: IrNode;
bufferedTextNodeValues: EsExpression[];
isSlotted?: boolean;
import: (
imports: string | string[] | Record<string, string | undefined>,
Expand Down
2 changes: 1 addition & 1 deletion packages/@lwc/ssr-runtime/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export {
// renderComponent is an alias for serverSideRenderComponent
serverSideRenderComponent as renderComponent,
} from './render';
export { massageTextContent } from './render-text-content';
export { massageTextContent, renderTextContent } from './render-text-content';
export { hasScopedStaticStylesheets, renderStylesheets } from './styles';
export { toIteratorDirective } from './to-iterator-directive';
export { validateStyleTextContents } from './validate-style-text-contents';
Expand Down
18 changes: 17 additions & 1 deletion packages/@lwc/ssr-runtime/src/render-text-content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,28 @@
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT
*/

import { htmlEscape } from '@lwc/shared';

/**
* Given an object, render it for use as a text content node.
* Given an object, render it for use as a text content node. Not that this applies to individual text nodes,
* not the concatenated result of multiple adjacent text nodes.
* @param value
*/
export function massageTextContent(value: unknown): string {
// Using non strict equality to align with original implementation (ex. undefined == null)
// See: https://github.com/salesforce/lwc/blob/348130f/packages/%40lwc/engine-core/src/framework/api.ts#L548
return value == null ? '' : String(value);
}

/**
* Given a string, render it for use as text content in HTML. Notably this escapes HTML and renders as
* a ZWJ is empty. Intended to be used on the result of concatenating multiple adjacent text nodes together.
* @param value
*/
export function renderTextContent(value: string): string {
// We are at the end of a series of text nodes - flush to a concatenated string
// We only render the ZWJ if there were actually any dynamic text nodes rendered
// The ZWJ is just so hydration can compare the SSR'd dynamic text content against
// the CSR'd text content.
return value === '' ? '\u200D' : htmlEscape(value);
}