Skip to content

Commit 32770e5

Browse files
divmainwjhsf
andauthored
fix(ssr): massive compiled outputs when nested slots are present (#5379)
* chore(ssr): move some calculations to compile-time for sleeker output * chore: remove unused types * chore: add mechanism to hoist statements * wip: hoist shadow slot fns * wip: address initial issues with hoisted shadow slot fn * wip: complete non-working solution * chore: comment * wip: new approach with hoisting to top of tmpl fn * fix: invalid slot-fn identifier when lwc:component is used * feat: curry local variables into the shadow slot content fn * chore: comments and some light cleanup * chore: add files that will be immediately deleted * chore: delete files immediately * chore: readability improvement Co-authored-by: Will Harney <62956339+wjhsf@users.noreply.github.com> * fix: excessive verbosity Co-authored-by: Will Harney <62956339+wjhsf@users.noreply.github.com> --------- Co-authored-by: Will Harney <62956339+wjhsf@users.noreply.github.com>
1 parent 1a97ed2 commit 32770e5

File tree

9 files changed

+220
-102
lines changed

9 files changed

+220
-102
lines changed

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

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
*/
77

88
import { ImportManager } from '../imports';
9-
import type { ImportDeclaration as EsImportDeclaration } from 'estree';
9+
import type { ImportDeclaration as EsImportDeclaration, Statement as EsStatement } from 'estree';
1010
import type { TemplateOpts, TransformerContext } from './types';
1111

1212
export function createNewContext(templateOptions: TemplateOpts): {
@@ -33,14 +33,77 @@ export function createNewContext(templateOptions: TemplateOpts): {
3333
}
3434
return false;
3535
};
36+
const getLocalVars = () => localVarStack.flatMap((varsSet) => Array.from(varsSet));
37+
38+
const hoistedStatements = {
39+
module: [] as EsStatement[],
40+
templateFn: [] as EsStatement[],
41+
};
42+
const hoistedModuleDedupe = new Set<unknown>();
43+
const hoistedTemplateDedupe = new Set<unknown>();
44+
45+
const hoist = {
46+
// Anything added here will be inserted at the top of the compiled template's
47+
// JS module.
48+
module(stmt: EsStatement, optionalDedupeKey?: unknown) {
49+
if (optionalDedupeKey) {
50+
if (hoistedModuleDedupe.has(optionalDedupeKey)) {
51+
return;
52+
}
53+
hoistedModuleDedupe.add(optionalDedupeKey);
54+
}
55+
hoistedStatements.module.push(stmt);
56+
},
57+
// Anything added here will be inserted at the top of the JavaScript function
58+
// corresponding to the template (typically named `__lwcTmpl`).
59+
templateFn(stmt: EsStatement, optionalDedupeKey?: unknown) {
60+
if (optionalDedupeKey) {
61+
if (hoistedTemplateDedupe.has(optionalDedupeKey)) {
62+
return;
63+
}
64+
hoistedTemplateDedupe.add(optionalDedupeKey);
65+
}
66+
hoistedStatements.templateFn.push(stmt);
67+
},
68+
};
69+
70+
const shadowSlotToFnName = new Map<string, string>();
71+
let fnNameUniqueId = 0;
72+
73+
// At present, we only track shadow-slotted content. This is because the functions
74+
// corresponding to shadow-slotted content are deduped and hoisted to the top of
75+
// the template function, whereas light-dom-slotted content is inlined. It may be
76+
// desirable to also track light-dom-slotted content at some future point in time.
77+
const slots = {
78+
shadow: {
79+
isDuplicate(uniqueNodeId: string) {
80+
return shadowSlotToFnName.has(uniqueNodeId);
81+
},
82+
register(uniqueNodeId: string, kebabCmpName: string) {
83+
if (slots.shadow.isDuplicate(uniqueNodeId)) {
84+
return shadowSlotToFnName.get(uniqueNodeId)!;
85+
}
86+
const shadowSlotContentFnName = `__lwcGenerateShadowSlottedContent_${kebabCmpName}_${fnNameUniqueId++}`;
87+
shadowSlotToFnName.set(uniqueNodeId, shadowSlotContentFnName);
88+
return shadowSlotContentFnName;
89+
},
90+
getFnName(uniqueNodeId: string) {
91+
return shadowSlotToFnName.get(uniqueNodeId) ?? null;
92+
},
93+
},
94+
};
3695

3796
return {
3897
getImports: () => importManager.getImportDeclarations(),
3998
cxt: {
4099
pushLocalVars,
41100
popLocalVars,
42101
isLocalVar,
102+
getLocalVars,
43103
templateOptions,
104+
hoist,
105+
hoistedStatements,
106+
slots,
44107
import: importManager.add.bind(importManager),
45108
siblings: undefined,
46109
currentNodeIndex: undefined,

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

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ export default function compileTemplate(
117117
)?.value?.value;
118118
const experimentalComplexExpressions = Boolean(options.experimentalComplexExpressions);
119119

120-
const { addImport, getImports, statements } = templateIrToEsTree(root, {
120+
const { addImport, getImports, statements, cxt } = templateIrToEsTree(root, {
121121
preserveComments,
122122
experimentalComplexExpressions,
123123
});
@@ -126,7 +126,16 @@ export default function compileTemplate(
126126
addImport(imports, source);
127127
}
128128

129-
let tmplDecl = bExportTemplate(optimizeAdjacentYieldStmts(statements));
129+
let tmplDecl = bExportTemplate(
130+
optimizeAdjacentYieldStmts([
131+
// Deep in the compiler, we may choose to hoist statements and declarations
132+
// to the top of the template function. After `templateIrToEsTree`, these
133+
// hoisted statements/declarations are prepended to the template function's
134+
// body.
135+
...cxt.hoistedStatements.templateFn,
136+
...statements,
137+
])
138+
);
130139
// Ideally, we'd just do ${LWC_VERSION_COMMENT} in the code template,
131140
// but placeholders have a special meaning for `esTemplate`.
132141
tmplDecl = produce(tmplDecl, (draft) => {
@@ -138,7 +147,18 @@ export default function compileTemplate(
138147
];
139148
});
140149

141-
let program = b.program([...getImports(), tmplDecl], 'module');
150+
let program = b.program(
151+
[
152+
// All import declarations come first...
153+
...getImports(),
154+
// ... followed by any statements or declarations that need to be hoisted
155+
// to the top of the module scope...
156+
...cxt.hoistedStatements.module,
157+
// ... followed by the template function declaration itself.
158+
tmplDecl,
159+
],
160+
'module'
161+
);
142162

143163
addScopeTokenDeclarations(program, filename, options.namespace, options.name);
144164

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,5 +110,6 @@ export function templateIrToEsTree(node: IrNode, contextOpts: TemplateOpts) {
110110
addImport: cxt.import,
111111
getImports,
112112
statements,
113+
cxt,
113114
};
114115
}

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

Lines changed: 99 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -7,18 +7,21 @@
77

88
import { produce } from 'immer';
99
import { builders as b, is } from 'estree-toolkit';
10+
import { kebabCaseToCamelCase } from '@lwc/shared';
1011
import { bAttributeValue, optimizeAdjacentYieldStmts } from '../../shared';
1112
import { esTemplate, esTemplateWithYield } from '../../../estemplate';
1213
import { irChildrenToEs, irToEs } from '../../ir-to-es';
1314
import { isLiteral } from '../../shared';
1415
import { expressionIrToEs } from '../../expression';
1516
import { isNullableOf } from '../../../estree/validators';
1617
import { isLastConcatenatedNode } from '../../adjacent-text-nodes';
17-
import type { CallExpression as EsCallExpression, Expression as EsExpression } from 'estree';
1818

1919
import type {
20+
CallExpression as EsCallExpression,
21+
Expression as EsExpression,
2022
Statement as EsStatement,
2123
ExpressionStatement as EsExpressionStatement,
24+
VariableDeclaration as EsVariableDeclaration,
2225
} from 'estree';
2326
import type {
2427
ChildNode as IrChildNode,
@@ -36,51 +39,58 @@ import type {
3639
} from '@lwc/template-compiler';
3740
import type { TransformerContext } from '../../types';
3841

39-
const bGenerateSlottedContent = esTemplateWithYield`
40-
const shadowSlottedContent = ${/* hasShadowSlottedContent */ is.literal}
41-
? async function* __lwcGenerateSlottedContent(contextfulParent) {
42-
// The 'contextfulParent' variable is shadowed here so that a contextful relationship
43-
// is established between components rendered in slotted content & the "parent"
44-
// component that contains the <slot>.
45-
46-
${/* shadow slot content */ is.statement}
47-
}
48-
// Avoid creating the object unnecessarily
49-
: null;
50-
51-
const lightSlottedContentMap = ${/* hasLightSlottedContent */ is.literal}
52-
? Object.create(null)
53-
// Avoid creating the object unnecessarily
54-
: null;
55-
56-
// The containing slot treats scoped slotted content differently.
57-
const scopedSlottedContentMap = ${/* hasScopedSlottedContent */ is.literal}
58-
? Object.create(null)
59-
// Avoid creating the object unnecessarily
60-
: null;
61-
62-
function addSlottedContent(name, fn, contentMap) {
63-
let contentList = contentMap[name];
64-
if (contentList) {
65-
contentList.push(fn);
66-
} else {
67-
contentMap[name] = [fn];
68-
}
69-
}
42+
// This function will be defined once and hoisted to the top of the template function. It'll be
43+
// referenced deeper in the call stack where the function is called or passed as a parameter.
44+
// It is a higher-order function that curries local variables that may be referenced by the
45+
// shadow slot content.
46+
const bGenerateShadowSlottedContent = esTemplateWithYield`
47+
const ${/* function name */ is.identifier} = (${/* local vars */ is.identifier}) => async function* ${/* function name */ 0}(contextfulParent) {
48+
// The 'contextfulParent' variable is shadowed here so that a contextful relationship
49+
// is established between components rendered in slotted content & the "parent"
50+
// component that contains the <slot>.
51+
${/* shadow slot content */ is.statement}
52+
};
53+
`<EsVariableDeclaration>;
54+
// By passing in the set of local variables (which correspond 1:1 to the variables expected by
55+
// the referenced function), `shadowSlottedContent` will be curried function that can generate
56+
// shadow-slotted content.
57+
const bGenerateShadowSlottedContentRef = esTemplateWithYield`
58+
const shadowSlottedContent = ${/* reference to hoisted fn */ is.identifier}(${/* local vars */ is.identifier});
59+
`<EsVariableDeclaration>;
60+
const bNullishGenerateShadowSlottedContent = esTemplateWithYield`
61+
const shadowSlottedContent = null;
62+
`<EsVariableDeclaration>;
7063

71-
${/* light DOM addLightContent statements */ is.expressionStatement}
72-
${/* scoped slot addLightContent statements */ is.expressionStatement}
64+
const blightSlottedContentMap = esTemplateWithYield`
65+
const ${/* name of the content map */ is.identifier} = Object.create(null);
66+
`<EsVariableDeclaration>;
67+
const bNullishLightSlottedContentMap = esTemplateWithYield`
68+
const ${/* name of the content map */ is.identifier} = null;
69+
`<EsVariableDeclaration>;
70+
71+
const bGenerateSlottedContent = esTemplateWithYield`
72+
${/* const shadowSlottedContent = ... */ is.variableDeclaration}
73+
${/* const lightSlottedContentMap */ is.variableDeclaration}
74+
${/* const scopedSlottedContentMap */ is.variableDeclaration}
75+
${/* light DOM addLightContent statements */ is.expressionStatement}
76+
${/* scoped slot addLightContent statements */ is.expressionStatement}
7377
`<EsStatement[]>;
7478

7579
// Note that this function name (`__lwcGenerateSlottedContent`) does not need to be scoped even though
7680
// it may be repeated multiple times in the same scope, because it's a function _expression_ rather
7781
// than a function _declaration_, so it isn't available to be referenced anywhere.
7882
const bAddSlottedContent = esTemplate`
79-
addSlottedContent(${/* slot name */ is.expression} ?? "", async function* __lwcGenerateSlottedContent(contextfulParent, ${
80-
/* scoped slot data variable */ isNullableOf(is.identifier)
81-
}, slotAttributeValue) {
82-
${/* slot content */ is.statement}
83-
}, ${/* content map */ is.identifier});
83+
addSlottedContent(
84+
${/* slot name */ is.expression} ?? "",
85+
async function* __lwcGenerateSlottedContent(
86+
contextfulParent,
87+
${/* scoped slot data variable */ isNullableOf(is.identifier)},
88+
slotAttributeValue)
89+
{
90+
${/* slot content */ is.statement}
91+
},
92+
${/* content map */ is.identifier}
93+
);
8494
`<EsCallExpression>;
8595

8696
function getShadowSlottedContent(slottableChildren: IrChildNode[], cxt: TransformerContext) {
@@ -155,7 +165,7 @@ function getLightSlottedContent(rootNodes: IrChildNode[], cxt: TransformerContex
155165
});
156166
const { isSlotted: originalIsSlotted } = cxt;
157167
cxt.isSlotted = ancestorIndices.length > 1 || clone.type === 'Slot';
158-
const slotContent = irToEs(clone, cxt);
168+
const slotContent = optimizeAdjacentYieldStmts(irToEs(clone, cxt));
159169
cxt.isSlotted = originalIsSlotted;
160170
results.push(
161171
b.expressionStatement(
@@ -246,24 +256,65 @@ export function getSlottedContent(
246256
bAddSlottedContent(
247257
slotName,
248258
boundVariable,
249-
irChildrenToEs(child.children, cxt),
259+
optimizeAdjacentYieldStmts(irChildrenToEs(child.children, cxt)),
250260
b.identifier('scopedSlottedContentMap')
251261
)
252262
);
253263
cxt.popLocalVars();
254264
return addLightContentExpr;
255265
});
256266

257-
const hasShadowSlottedContent = b.literal(shadowSlotContent.length > 0);
258-
const hasLightSlottedContent = b.literal(lightSlotContent.length > 0);
259-
const hasScopedSlottedContent = b.literal(scopedSlotContent.length > 0);
267+
const hasShadowSlottedContent = shadowSlotContent.length > 0;
268+
const hasLightSlottedContent = lightSlotContent.length > 0;
269+
const hasScopedSlottedContent = scopedSlotContent.length > 0;
260270
cxt.isSlotted = isSlotted;
261271

272+
if (hasShadowSlottedContent || hasLightSlottedContent || hasScopedSlottedContent) {
273+
cxt.import('addSlottedContent');
274+
}
275+
276+
// Elsewhere, nodes and their subtrees are cloned. This design decision means that
277+
// the node objects themselves cannot be used as unique identifiers (e.g. as keys
278+
// in a map). However, for a given template, a node's location information does
279+
// uniquely identify that node.
280+
const uniqueNodeId = `${node.name}:${node.location.start}:${node.location.end}`;
281+
282+
const localVars = cxt.getLocalVars();
283+
const localVarIds = localVars.map(b.identifier);
284+
285+
if (hasShadowSlottedContent && !cxt.slots.shadow.isDuplicate(uniqueNodeId)) {
286+
// Colon characters in <lwc:component> element name will result in an invalid
287+
// JavaScript identifier if not otherwise accounted for.
288+
const kebabCmpName = kebabCaseToCamelCase(node.name).replace(':', '_');
289+
const shadowSlotContentFnName = cxt.slots.shadow.register(uniqueNodeId, kebabCmpName);
290+
const shadowSlottedContentFn = bGenerateShadowSlottedContent(
291+
b.identifier(shadowSlotContentFnName),
292+
// If the slot-fn were defined here instead of hoisted to the top of the module,
293+
// the local variables (e.g. from for:each) would be closed-over. When hoisted,
294+
// however, we need to curry these variables.
295+
localVarIds,
296+
shadowSlotContent
297+
);
298+
cxt.hoist.templateFn(shadowSlottedContentFn, node);
299+
}
300+
301+
const shadowSlottedContentFn = hasShadowSlottedContent
302+
? bGenerateShadowSlottedContentRef(
303+
b.identifier(cxt.slots.shadow.getFnName(uniqueNodeId)!),
304+
localVarIds
305+
)
306+
: bNullishGenerateShadowSlottedContent();
307+
const lightSlottedContentMap = hasLightSlottedContent
308+
? blightSlottedContentMap(b.identifier('lightSlottedContentMap'))
309+
: bNullishLightSlottedContentMap(b.identifier('lightSlottedContentMap'));
310+
const scopedSlottedContentMap = hasScopedSlottedContent
311+
? blightSlottedContentMap(b.identifier('scopedSlottedContentMap'))
312+
: bNullishLightSlottedContentMap(b.identifier('scopedSlottedContentMap'));
313+
262314
return bGenerateSlottedContent(
263-
hasShadowSlottedContent,
264-
shadowSlotContent,
265-
hasLightSlottedContent,
266-
hasScopedSlottedContent,
315+
shadowSlottedContentFn,
316+
lightSlottedContentMap,
317+
scopedSlottedContentMap,
267318
lightSlotContent,
268319
scopedSlotContent
269320
);

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,32 @@ export type Transformer<T extends IrNode = IrNode> = (
1313
cxt: TransformerContext
1414
) => EsStatement[];
1515

16+
export interface SlotMetadataContext {
17+
shadow: {
18+
isDuplicate: (uniqueNodeId: string) => boolean;
19+
register: (uniqueNodeId: string, kebabCmpName: string) => string;
20+
getFnName: (uniqueNodeId: string) => string | null;
21+
};
22+
}
23+
1624
export interface TransformerContext {
1725
pushLocalVars: (vars: string[]) => void;
1826
popLocalVars: () => void;
1927
isLocalVar: (varName: string | null | undefined) => boolean;
28+
getLocalVars: () => string[];
2029
templateOptions: TemplateOpts;
2130
siblings: IrNode[] | undefined;
2231
currentNodeIndex: number | undefined;
2332
isSlotted?: boolean;
33+
hoistedStatements: {
34+
module: EsStatement[];
35+
templateFn: EsStatement[];
36+
};
37+
hoist: {
38+
module: (stmt: EsStatement, optionalDedupeKey?: unknown) => void;
39+
templateFn: (stmt: EsStatement, optionalDedupeKey?: unknown) => void;
40+
};
41+
slots: SlotMetadataContext;
2442
import: (
2543
imports: string | string[] | Record<string, string | undefined>,
2644
source?: string

0 commit comments

Comments
 (0)