Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
65 changes: 64 additions & 1 deletion packages/@lwc/ssr-compiler/src/compile-template/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
*/

import { ImportManager } from '../imports';
import type { ImportDeclaration as EsImportDeclaration } from 'estree';
import type { ImportDeclaration as EsImportDeclaration, Statement as EsStatement } from 'estree';
import type { TemplateOpts, TransformerContext } from './types';

export function createNewContext(templateOptions: TemplateOpts): {
Expand All @@ -33,14 +33,77 @@ export function createNewContext(templateOptions: TemplateOpts): {
}
return false;
};
const getLocalVars = () => localVarStack.flatMap((varsSet) => Array.from(varsSet));

const hoistedStatements = {
module: [] as EsStatement[],
templateFn: [] as EsStatement[],
};
const hoistedModuleDedupe = new Set<unknown>();
const hoistedTemplateDedupe = new Set<unknown>();

const hoist = {
// Anything added here will be inserted at the top of the compiled template's
// JS module.
module(stmt: EsStatement, optionalDedupeKey?: unknown) {
if (optionalDedupeKey) {
if (hoistedModuleDedupe.has(optionalDedupeKey)) {
return;
}
hoistedModuleDedupe.add(optionalDedupeKey);
}
hoistedStatements.module.push(stmt);
},
// Anything added here will be inserted at the top of the JavaScript function
// corresponding to the template (typically named `__lwcTmpl`).
templateFn(stmt: EsStatement, optionalDedupeKey?: unknown) {
if (optionalDedupeKey) {
if (hoistedTemplateDedupe.has(optionalDedupeKey)) {
return;
}
hoistedTemplateDedupe.add(optionalDedupeKey);
}
hoistedStatements.templateFn.push(stmt);
},
};

const shadowSlotToFnName = new Map<string, string>();
let fnNameUniqueId = 0;

// At present, we only track shadow-slotted content. This is because the functions
// corresponding to shadow-slotted content are deduped and hoisted to the top of
// the template function, whereas light-dom-slotted content is inlined. It may be
// desirable to also track light-dom-slotted content at some future point in time.
const slots = {
shadow: {
isDuplicate(uniqueNodeId: string) {
return shadowSlotToFnName.has(uniqueNodeId);
},
register(uniqueNodeId: string, kebabCmpName: string) {
if (slots.shadow.isDuplicate(uniqueNodeId)) {
return shadowSlotToFnName.get(uniqueNodeId)!;
}
const shadowSlotContentFnName = `__lwcGenerateShadowSlottedContent_${kebabCmpName}_${fnNameUniqueId++}`;
shadowSlotToFnName.set(uniqueNodeId, shadowSlotContentFnName);
return shadowSlotContentFnName;
},
getFnName(uniqueNodeId: string) {
return shadowSlotToFnName.get(uniqueNodeId) ?? null;
},
},
};

return {
getImports: () => importManager.getImportDeclarations(),
cxt: {
pushLocalVars,
popLocalVars,
isLocalVar,
getLocalVars,
templateOptions,
hoist,
hoistedStatements,
slots,
import: importManager.add.bind(importManager),
siblings: undefined,
currentNodeIndex: undefined,
Expand Down
26 changes: 23 additions & 3 deletions packages/@lwc/ssr-compiler/src/compile-template/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ export default function compileTemplate(
)?.value?.value;
const experimentalComplexExpressions = Boolean(options.experimentalComplexExpressions);

const { addImport, getImports, statements } = templateIrToEsTree(root, {
const { addImport, getImports, statements, cxt } = templateIrToEsTree(root, {
preserveComments,
experimentalComplexExpressions,
});
Expand All @@ -126,7 +126,16 @@ export default function compileTemplate(
addImport(imports, source);
}

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

let program = b.program([...getImports(), tmplDecl], 'module');
let program = b.program(
[
// All import declarations come first...
...getImports(),
// ... followed by any statements or declarations that need to be hoisted
// to the top of the module scope...
...cxt.hoistedStatements.module,
// ... followed by the template function declaration itself.
tmplDecl,
],
'module'
);

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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,5 +110,6 @@ export function templateIrToEsTree(node: IrNode, contextOpts: TemplateOpts) {
addImport: cxt.import,
getImports,
statements,
cxt,
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,21 @@

import { produce } from 'immer';
import { builders as b, is } from 'estree-toolkit';
import { kebabCaseToCamelCase } from '@lwc/shared';
import { bAttributeValue, optimizeAdjacentYieldStmts } from '../../shared';
import { esTemplate, esTemplateWithYield } from '../../../estemplate';
import { irChildrenToEs, irToEs } from '../../ir-to-es';
import { isLiteral } from '../../shared';
import { expressionIrToEs } from '../../expression';
import { isNullableOf } from '../../../estree/validators';
import { isLastConcatenatedNode } from '../../adjacent-text-nodes';
import type { CallExpression as EsCallExpression, Expression as EsExpression } from 'estree';

import type {
CallExpression as EsCallExpression,
Expression as EsExpression,
Statement as EsStatement,
ExpressionStatement as EsExpressionStatement,
VariableDeclaration as EsVariableDeclaration,
} from 'estree';
import type {
ChildNode as IrChildNode,
Expand All @@ -36,51 +39,58 @@ import type {
} from '@lwc/template-compiler';
import type { TransformerContext } from '../../types';

const bGenerateSlottedContent = esTemplateWithYield`
const shadowSlottedContent = ${/* hasShadowSlottedContent */ is.literal}
? async function* __lwcGenerateSlottedContent(contextfulParent) {
// The 'contextfulParent' variable is shadowed here so that a contextful relationship
// is established between components rendered in slotted content & the "parent"
// component that contains the <slot>.

${/* shadow slot content */ is.statement}
}
// Avoid creating the object unnecessarily
: null;

const lightSlottedContentMap = ${/* hasLightSlottedContent */ is.literal}
? Object.create(null)
// Avoid creating the object unnecessarily
: null;

// The containing slot treats scoped slotted content differently.
const scopedSlottedContentMap = ${/* hasScopedSlottedContent */ is.literal}
? Object.create(null)
// Avoid creating the object unnecessarily
: null;

function addSlottedContent(name, fn, contentMap) {
let contentList = contentMap[name];
if (contentList) {
contentList.push(fn);
} else {
contentMap[name] = [fn];
}
}
// This function will be defined once and hoisted to the top of the template function. It'll be
// referenced deeper in the call stack where the function is called or passed as a parameter.
// It is a higher-order function that curries local variables that may be referenced by the
// shadow slot content.
const bGenerateShadowSlottedContent = esTemplateWithYield`
const ${/* function name */ is.identifier} = (${/* local vars */ is.identifier}) => async function* ${/* function name */ 0}(contextfulParent) {
// The 'contextfulParent' variable is shadowed here so that a contextful relationship
// is established between components rendered in slotted content & the "parent"
// component that contains the <slot>.
${/* shadow slot content */ is.statement}
};
`<EsVariableDeclaration>;
// By passing in the set of local variables (which correspond 1:1 to the variables expected by
// the referenced function), `shadowSlottedContent` will be curried function that can generate
// shadow-slotted content.
const bGenerateShadowSlottedContentRef = esTemplateWithYield`
const shadowSlottedContent = ${/* reference to hoisted fn */ is.identifier}(${/* local vars */ is.identifier});
`<EsVariableDeclaration>;
const bNullishGenerateShadowSlottedContent = esTemplateWithYield`
const shadowSlottedContent = null;
`<EsVariableDeclaration>;

${/* light DOM addLightContent statements */ is.expressionStatement}
${/* scoped slot addLightContent statements */ is.expressionStatement}
const blightSlottedContentMap = esTemplateWithYield`
const ${/* name of the content map */ is.identifier} = Object.create(null);
`<EsVariableDeclaration>;
const bNullishLightSlottedContentMap = esTemplateWithYield`
const ${/* name of the content map */ is.identifier} = null;
`<EsVariableDeclaration>;

const bGenerateSlottedContent = esTemplateWithYield`
${/* const shadowSlottedContent = ... */ is.variableDeclaration}
${/* const lightSlottedContentMap */ is.variableDeclaration}
${/* const scopedSlottedContentMap */ is.variableDeclaration}
${/* light DOM addLightContent statements */ is.expressionStatement}
${/* scoped slot addLightContent statements */ is.expressionStatement}
`<EsStatement[]>;

// Note that this function name (`__lwcGenerateSlottedContent`) does not need to be scoped even though
// it may be repeated multiple times in the same scope, because it's a function _expression_ rather
// than a function _declaration_, so it isn't available to be referenced anywhere.
const bAddSlottedContent = esTemplate`
addSlottedContent(${/* slot name */ is.expression} ?? "", async function* __lwcGenerateSlottedContent(contextfulParent, ${
/* scoped slot data variable */ isNullableOf(is.identifier)
}, slotAttributeValue) {
${/* slot content */ is.statement}
}, ${/* content map */ is.identifier});
addSlottedContent(
${/* slot name */ is.expression} ?? "",
async function* __lwcGenerateSlottedContent(
contextfulParent,
${/* scoped slot data variable */ isNullableOf(is.identifier)},
slotAttributeValue)
{
${/* slot content */ is.statement}
},
${/* content map */ is.identifier}
);
`<EsCallExpression>;

function getShadowSlottedContent(slottableChildren: IrChildNode[], cxt: TransformerContext) {
Expand Down Expand Up @@ -155,7 +165,7 @@ function getLightSlottedContent(rootNodes: IrChildNode[], cxt: TransformerContex
});
const { isSlotted: originalIsSlotted } = cxt;
cxt.isSlotted = ancestorIndices.length > 1 || clone.type === 'Slot';
const slotContent = irToEs(clone, cxt);
const slotContent = optimizeAdjacentYieldStmts(irToEs(clone, cxt));
cxt.isSlotted = originalIsSlotted;
results.push(
b.expressionStatement(
Expand Down Expand Up @@ -246,24 +256,65 @@ export function getSlottedContent(
bAddSlottedContent(
slotName,
boundVariable,
irChildrenToEs(child.children, cxt),
optimizeAdjacentYieldStmts(irChildrenToEs(child.children, cxt)),
b.identifier('scopedSlottedContentMap')
)
);
cxt.popLocalVars();
return addLightContentExpr;
});

const hasShadowSlottedContent = b.literal(shadowSlotContent.length > 0);
const hasLightSlottedContent = b.literal(lightSlotContent.length > 0);
const hasScopedSlottedContent = b.literal(scopedSlotContent.length > 0);
const hasShadowSlottedContent = shadowSlotContent.length > 0;
const hasLightSlottedContent = lightSlotContent.length > 0;
const hasScopedSlottedContent = scopedSlotContent.length > 0;
cxt.isSlotted = isSlotted;

if (hasShadowSlottedContent || hasLightSlottedContent || hasScopedSlottedContent) {
cxt.import('addSlottedContent');
}

// Elsewhere, nodes and their subtrees are cloned. This design decision means that
// the node objects themselves cannot be used as unique identifiers (e.g. as keys
// in a map). However, for a given template, a node's location information does
// uniquely identify that node.
const uniqueNodeId = `${node.name}:${node.location.start}:${node.location.end}`;

const localVars = cxt.getLocalVars();
const localVarIds = localVars.map(b.identifier);

if (hasShadowSlottedContent && !cxt.slots.shadow.isDuplicate(uniqueNodeId)) {
// Colon characters in <lwc:component> element name will result in an invalid
// JavaScript identifier if not otherwise accounted for.
const kebabCmpName = kebabCaseToCamelCase(node.name).replace(':', '_');
const shadowSlotContentFnName = cxt.slots.shadow.register(uniqueNodeId, kebabCmpName);
const shadowSlottedContentFn = bGenerateShadowSlottedContent(
b.identifier(shadowSlotContentFnName),
// If the slot-fn were defined here instead of hoisted to the top of the module,
// the local variables (e.g. from for:each) would be closed-over. When hoisted,
// however, we need to curry these variables.
localVarIds,
shadowSlotContent
);
cxt.hoist.templateFn(shadowSlottedContentFn, node);
}

const shadowSlottedContentFn = hasShadowSlottedContent
? bGenerateShadowSlottedContentRef(
b.identifier(cxt.slots.shadow.getFnName(uniqueNodeId)!),
localVarIds
)
: bNullishGenerateShadowSlottedContent();
const lightSlottedContentMap = hasLightSlottedContent
? blightSlottedContentMap(b.identifier('lightSlottedContentMap'))
: bNullishLightSlottedContentMap(b.identifier('lightSlottedContentMap'));
const scopedSlottedContentMap = hasScopedSlottedContent
? blightSlottedContentMap(b.identifier('scopedSlottedContentMap'))
: bNullishLightSlottedContentMap(b.identifier('scopedSlottedContentMap'));

return bGenerateSlottedContent(
hasShadowSlottedContent,
shadowSlotContent,
hasLightSlottedContent,
hasScopedSlottedContent,
shadowSlottedContentFn,
lightSlottedContentMap,
scopedSlottedContentMap,
lightSlotContent,
scopedSlotContent
);
Expand Down
18 changes: 18 additions & 0 deletions packages/@lwc/ssr-compiler/src/compile-template/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,32 @@ export type Transformer<T extends IrNode = IrNode> = (
cxt: TransformerContext
) => EsStatement[];

export interface SlotMetadataContext {
shadow: {
isDuplicate: (uniqueNodeId: string) => boolean;
register: (uniqueNodeId: string, kebabCmpName: string) => string;
getFnName: (uniqueNodeId: string) => string | null;
};
}

export interface TransformerContext {
pushLocalVars: (vars: string[]) => void;
popLocalVars: () => void;
isLocalVar: (varName: string | null | undefined) => boolean;
getLocalVars: () => string[];
templateOptions: TemplateOpts;
siblings: IrNode[] | undefined;
currentNodeIndex: number | undefined;
isSlotted?: boolean;
hoistedStatements: {
module: EsStatement[];
templateFn: EsStatement[];
};
hoist: {
module: (stmt: EsStatement, optionalDedupeKey?: unknown) => void;
templateFn: (stmt: EsStatement, optionalDedupeKey?: unknown) => void;
};
slots: SlotMetadataContext;
import: (
imports: string | string[] | Record<string, string | undefined>,
source?: string
Expand Down
Loading