diff --git a/packages/@lwc/ssr-compiler/src/compile-template/context.ts b/packages/@lwc/ssr-compiler/src/compile-template/context.ts index 6de3a6b23c..a2208ba6e2 100644 --- a/packages/@lwc/ssr-compiler/src/compile-template/context.ts +++ b/packages/@lwc/ssr-compiler/src/compile-template/context.ts @@ -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): { @@ -33,6 +33,65 @@ 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(); + const hoistedTemplateDedupe = new Set(); + + 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(); + 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(), @@ -40,7 +99,11 @@ export function createNewContext(templateOptions: TemplateOpts): { pushLocalVars, popLocalVars, isLocalVar, + getLocalVars, templateOptions, + hoist, + hoistedStatements, + slots, import: importManager.add.bind(importManager), siblings: undefined, currentNodeIndex: undefined, diff --git a/packages/@lwc/ssr-compiler/src/compile-template/index.ts b/packages/@lwc/ssr-compiler/src/compile-template/index.ts index 47b827528d..c07acc51b1 100644 --- a/packages/@lwc/ssr-compiler/src/compile-template/index.ts +++ b/packages/@lwc/ssr-compiler/src/compile-template/index.ts @@ -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, }); @@ -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) => { @@ -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); diff --git a/packages/@lwc/ssr-compiler/src/compile-template/ir-to-es.ts b/packages/@lwc/ssr-compiler/src/compile-template/ir-to-es.ts index 8d5fc027f9..76dd5f42a6 100644 --- a/packages/@lwc/ssr-compiler/src/compile-template/ir-to-es.ts +++ b/packages/@lwc/ssr-compiler/src/compile-template/ir-to-es.ts @@ -110,5 +110,6 @@ export function templateIrToEsTree(node: IrNode, contextOpts: TemplateOpts) { addImport: cxt.import, getImports, statements, + cxt, }; } diff --git a/packages/@lwc/ssr-compiler/src/compile-template/transformers/component/slotted-content.ts b/packages/@lwc/ssr-compiler/src/compile-template/transformers/component/slotted-content.ts index 50f2c890de..99421e387b 100644 --- a/packages/@lwc/ssr-compiler/src/compile-template/transformers/component/slotted-content.ts +++ b/packages/@lwc/ssr-compiler/src/compile-template/transformers/component/slotted-content.ts @@ -7,6 +7,7 @@ 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'; @@ -14,11 +15,13 @@ 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, @@ -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 . - - ${/* 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 . + ${/* shadow slot content */ is.statement} + }; +`; +// 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}); +`; +const bNullishGenerateShadowSlottedContent = esTemplateWithYield` + const shadowSlottedContent = null; +`; - ${/* 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); +`; +const bNullishLightSlottedContentMap = esTemplateWithYield` + const ${/* name of the content map */ is.identifier} = null; +`; + +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} `; // 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} + ); `; function getShadowSlottedContent(slottableChildren: IrChildNode[], cxt: TransformerContext) { @@ -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( @@ -246,7 +256,7 @@ export function getSlottedContent( bAddSlottedContent( slotName, boundVariable, - irChildrenToEs(child.children, cxt), + optimizeAdjacentYieldStmts(irChildrenToEs(child.children, cxt)), b.identifier('scopedSlottedContentMap') ) ); @@ -254,16 +264,57 @@ export function getSlottedContent( 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 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 ); diff --git a/packages/@lwc/ssr-compiler/src/compile-template/types.ts b/packages/@lwc/ssr-compiler/src/compile-template/types.ts index f9cd8adcea..73f244a87d 100644 --- a/packages/@lwc/ssr-compiler/src/compile-template/types.ts +++ b/packages/@lwc/ssr-compiler/src/compile-template/types.ts @@ -13,14 +13,32 @@ export type Transformer = ( 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, source?: string diff --git a/packages/@lwc/ssr-compiler/src/shared.ts b/packages/@lwc/ssr-compiler/src/shared.ts index cd6a390a2a..f5c437fd77 100644 --- a/packages/@lwc/ssr-compiler/src/shared.ts +++ b/packages/@lwc/ssr-compiler/src/shared.ts @@ -9,55 +9,6 @@ import type { Config as TemplateCompilerConfig } from '@lwc/template-compiler'; export type Expression = string; -export type Instruction = - | IEmitTagName - | IEmitStaticString - | IEmitExpression - | IStartConditional - | IEndConditional - | IInvokeConnectedCallback - | IRenderChild - | IHoistImport - | IHoistInstantiation; - -export interface IEmitTagName { - kind: 'emitTagName'; -} - -export interface IEmitStaticString { - kind: 'emitStaticString'; -} - -export interface IEmitExpression { - kind: 'emitExpression'; - expression: Expression; -} - -export interface IStartConditional { - kind: 'startConditional'; -} - -export interface IEndConditional { - kind: 'endConditional'; -} - -export interface IInvokeConnectedCallback { - kind: 'invokeConnectedCallback'; -} - -export interface IRenderChild { - kind: 'renderChild'; - dynamic: Expression | null; -} - -export interface IHoistImport { - kind: 'hoistImport'; -} - -export interface IHoistInstantiation { - kind: 'hoistInstantiation'; -} - export type TemplateTransformOptions = Pick; export type ComponentTransformOptions = Partial< Pick diff --git a/packages/@lwc/ssr-compiler/src/transmogrify.ts b/packages/@lwc/ssr-compiler/src/transmogrify.ts index 0ffb61b26c..10a19acc66 100644 --- a/packages/@lwc/ssr-compiler/src/transmogrify.ts +++ b/packages/@lwc/ssr-compiler/src/transmogrify.ts @@ -21,7 +21,7 @@ export type Visitors = Parameters { const { node } = nodePath; diff --git a/packages/@lwc/ssr-runtime/src/index.ts b/packages/@lwc/ssr-runtime/src/index.ts index d2537bd90f..c65c028725 100644 --- a/packages/@lwc/ssr-runtime/src/index.ts +++ b/packages/@lwc/ssr-runtime/src/index.ts @@ -31,6 +31,7 @@ export { GenerateMarkupAsyncYield, renderAttrs, renderAttrsNoYield, + addSlottedContent, serverSideRenderComponent, // renderComponent is an alias for serverSideRenderComponent serverSideRenderComponent as renderComponent, diff --git a/packages/@lwc/ssr-runtime/src/render.ts b/packages/@lwc/ssr-runtime/src/render.ts index 8b631a52f8..f108286db7 100644 --- a/packages/@lwc/ssr-runtime/src/render.ts +++ b/packages/@lwc/ssr-runtime/src/render.ts @@ -187,6 +187,19 @@ export function fallbackTmplNoYield( } } +export function addSlottedContent( + name: string, + fn: unknown, + contentMap: Record +) { + const contentList = contentMap[name]; + if (contentList) { + contentList.push(fn); + } else { + contentMap[name] = [fn]; + } +} + interface ComponentWithGenerateMarkup extends LightningElementConstructor { [SYMBOL__GENERATE_MARKUP]?: GenerateMarkupVariants; }