From 1f4dd3d4769862ea8a6d928118fca4f8462ecc1d Mon Sep 17 00:00:00 2001 From: Dale Bustad Date: Thu, 24 Oct 2024 16:36:59 -0700 Subject: [PATCH] feat(ssr): add SSR compilation mode (#4685) * feat(ssr): add SSR compilation mode * chore: avoid inadvertently transforming component-author code * chore: add comment Co-authored-by: Nolan Lawson * chore: add comment Co-authored-by: Nolan Lawson * chore: remove commented code * chore: use more specific regex to detect generateMarkup fns Co-authored-by: Nolan Lawson * chore: remove unnecessary scope traveral option * chore: split targetSSR and ssrMode config options --------- Co-authored-by: Nolan Lawson --- packages/@lwc/compiler/src/options.ts | 7 +- .../compiler/src/transformers/transformer.ts | 19 +- packages/@lwc/rollup-plugin/src/index.ts | 3 + .../src/__tests__/fixtures.spec.ts | 7 +- .../@lwc/ssr-compiler/src/compile-js/index.ts | 10 +- .../src/compile-template/index.ts | 11 +- packages/@lwc/ssr-compiler/src/index.ts | 14 +- packages/@lwc/ssr-compiler/src/shared.ts | 3 + .../@lwc/ssr-compiler/src/transmogrify.ts | 175 ++++++++++++++++++ packages/@lwc/ssr-runtime/src/index.ts | 2 + packages/@lwc/ssr-runtime/src/render.ts | 89 ++++++++- 11 files changed, 313 insertions(+), 27 deletions(-) create mode 100644 packages/@lwc/ssr-compiler/src/transmogrify.ts diff --git a/packages/@lwc/compiler/src/options.ts b/packages/@lwc/compiler/src/options.ts index 9beed506cf..1526559806 100755 --- a/packages/@lwc/compiler/src/options.ts +++ b/packages/@lwc/compiler/src/options.ts @@ -6,6 +6,7 @@ */ import { InstrumentationObject, CompilerValidationErrors, invariant } from '@lwc/errors'; import { isUndefined, isBoolean, getAPIVersionFromNumber } from '@lwc/shared'; +import { CompilationMode } from '@lwc/ssr-compiler'; import type { CustomRendererConfig } from '@lwc/template-compiler'; /** @@ -31,7 +32,9 @@ const DEFAULT_OPTIONS = { experimentalComplexExpressions: false, disableSyntheticShadowSupport: false, enableLightningWebSecurityTransforms: false, -}; + targetSSR: false, + ssrMode: 'sync', +} as const; const DEFAULT_DYNAMIC_IMPORT_CONFIG: Required = { loader: '', @@ -129,6 +132,7 @@ export interface TransformOptions { /** API version to associate with the compiled module. Values correspond to Salesforce platform releases. */ apiVersion?: number; targetSSR?: boolean; + ssrMode?: CompilationMode; } type OptionalTransformKeys = @@ -237,6 +241,5 @@ function normalizeOptions(options: TransformOptions): NormalizedTransformOptions outputConfig, experimentalDynamicComponent, apiVersion, - targetSSR: !!options.targetSSR, }; } diff --git a/packages/@lwc/compiler/src/transformers/transformer.ts b/packages/@lwc/compiler/src/transformers/transformer.ts index fff31fdd7b..86992c2f4a 100755 --- a/packages/@lwc/compiler/src/transformers/transformer.ts +++ b/packages/@lwc/compiler/src/transformers/transformer.ts @@ -92,16 +92,15 @@ function transformFile( filename: string, options: NormalizedTransformOptions ): TransformResult { - let transformer; - switch (path.extname(filename)) { case '.html': - transformer = options.targetSSR ? compileTemplateForSSR : templateTransformer; - break; + if (options.targetSSR) { + return compileTemplateForSSR(src, filename, options, options.ssrMode); + } + return templateTransformer(src, filename, options); case '.css': - transformer = styleTransform; - break; + return styleTransform(src, filename, options); case '.tsx': case '.jsx': @@ -109,8 +108,10 @@ function transformFile( case '.js': case '.mts': case '.mjs': - transformer = options.targetSSR ? compileComponentForSSR : scriptTransformer; - break; + if (options.targetSSR) { + return compileComponentForSSR(src, filename, options, options.ssrMode); + } + return scriptTransformer(src, filename, options); default: throw generateCompilerError(TransformerErrors.NO_AVAILABLE_TRANSFORMER, { @@ -118,6 +119,4 @@ function transformFile( origin: { filename }, }); } - - return transformer(src, filename, options); } diff --git a/packages/@lwc/rollup-plugin/src/index.ts b/packages/@lwc/rollup-plugin/src/index.ts index c0f8bfc284..b504085af1 100644 --- a/packages/@lwc/rollup-plugin/src/index.ts +++ b/packages/@lwc/rollup-plugin/src/index.ts @@ -14,10 +14,13 @@ import { transformSync, StylesheetConfig, DynamicImportConfig } from '@lwc/compi import { resolveModule, ModuleRecord, RegistryType } from '@lwc/module-resolver'; import { APIVersion, getAPIVersionFromNumber } from '@lwc/shared'; import type { CompilerDiagnostic } from '@lwc/errors'; +import type { CompilationMode } from '@lwc/ssr-compiler'; export interface RollupLwcOptions { /** A boolean indicating whether to compile for SSR runtime target. */ targetSSR?: boolean; + /** The variety of SSR code that should be generated, one of 'sync', 'async', or 'asyncYield' */ + ssrMode?: CompilationMode; /** A [minimatch pattern](https://github.com/isaacs/minimatch), or array of patterns, which specifies the files in the build the plugin should transform on. By default all files are targeted. */ include?: FilterPattern; /** A [minimatch pattern](https://github.com/isaacs/minimatch), or array of patterns, which specifies the files in the build the plugin should not transform. By default no files are ignored. */ diff --git a/packages/@lwc/ssr-compiler/src/__tests__/fixtures.spec.ts b/packages/@lwc/ssr-compiler/src/__tests__/fixtures.spec.ts index 3530c9ca49..a714d9d1a9 100644 --- a/packages/@lwc/ssr-compiler/src/__tests__/fixtures.spec.ts +++ b/packages/@lwc/ssr-compiler/src/__tests__/fixtures.spec.ts @@ -12,6 +12,7 @@ import lwcRollupPlugin from '@lwc/rollup-plugin'; import { FeatureFlagName } from '@lwc/features/dist/types'; import { testFixtureDir, formatHTML } from '@lwc/test-utils-lwc-internals'; import { serverSideRenderComponent } from '@lwc/ssr-runtime'; +import type { CompilationMode } from '../index'; interface FixtureModule { tagName: string; @@ -23,6 +24,8 @@ interface FixtureModule { vi.setConfig({ testTimeout: 10_000 /* 10 seconds */ }); +const SSR_MODE: CompilationMode = 'sync'; + async function compileFixture({ input, dirname }: { input: string; dirname: string }) { const modulesDir = path.resolve(dirname, './modules'); const outputFile = path.resolve(dirname, './dist/compiled-experimental-ssr.js'); @@ -35,6 +38,7 @@ async function compileFixture({ input, dirname }: { input: string; dirname: stri plugins: [ lwcRollupPlugin({ targetSSR: true, + ssrMode: SSR_MODE, enableDynamicComponents: true, // TODO [#3331]: remove usage of lwc:dynamic in 246 experimentalDynamicDirective: true, @@ -85,7 +89,8 @@ function testFixtures() { result = await serverSideRenderComponent( module!.tagName, module!.generateMarkup, - config?.props ?? {} + config?.props ?? {}, + SSR_MODE ); } catch (err: any) { return { diff --git a/packages/@lwc/ssr-compiler/src/compile-js/index.ts b/packages/@lwc/ssr-compiler/src/compile-js/index.ts index 758b200b31..24c5ffe32e 100644 --- a/packages/@lwc/ssr-compiler/src/compile-js/index.ts +++ b/packages/@lwc/ssr-compiler/src/compile-js/index.ts @@ -10,6 +10,7 @@ import { traverse, builders as b, is } from 'estree-toolkit'; import { parseModule } from 'meriyah'; import { AriaPropNameToAttrNameMap } from '@lwc/shared'; +import { transmogrify } from '../transmogrify'; import { replaceLwcImport } from './lwc-import'; import { catalogTmplImport } from './catalog-tmpls'; import { catalogStaticStylesheets, catalogStyleImport } from './stylesheets'; @@ -17,6 +18,7 @@ import { addGenerateMarkupExport } from './generate-markup'; import type { Identifier as EsIdentifier, Program as EsProgram } from 'estree'; import type { Visitors, ComponentMetaState } from './types'; +import type { CompilationMode } from '../shared'; const visitors: Visitors = { $: { scope: true }, @@ -112,8 +114,8 @@ const visitors: Visitors = { }, }; -export default function compileJS(src: string, filename: string) { - const ast = parseModule(src, { +export default function compileJS(src: string, filename: string, compilationMode: CompilationMode) { + let ast = parseModule(src, { module: true, next: true, }) as EsProgram; @@ -155,6 +157,10 @@ export default function compileJS(src: string, filename: string) { addGenerateMarkupExport(ast, state, filename); + if (compilationMode === 'async' || compilationMode === 'sync') { + ast = transmogrify(ast, compilationMode); + } + return { code: generate(ast, {}), }; diff --git a/packages/@lwc/ssr-compiler/src/compile-template/index.ts b/packages/@lwc/ssr-compiler/src/compile-template/index.ts index 7397fb4a10..1a03190e72 100644 --- a/packages/@lwc/ssr-compiler/src/compile-template/index.ts +++ b/packages/@lwc/ssr-compiler/src/compile-template/index.ts @@ -12,12 +12,14 @@ import { DiagnosticLevel } from '@lwc/errors'; import { esTemplate } from '../estemplate'; import { getStylesheetImports } from '../compile-js/stylesheets'; import { addScopeTokenDeclarations } from '../compile-js/stylesheet-scope-token'; +import { transmogrify } from '../transmogrify'; import { optimizeAdjacentYieldStmts } from './shared'; import { templateIrToEsTree } from './ir-to-es'; import type { ExportDefaultDeclaration as EsExportDefaultDeclaration, ImportDeclaration as EsImportDeclaration, } from 'estree'; +import type { CompilationMode } from '../shared'; const bStyleValidationImport = esTemplate` import { validateStyleTextContents } from '@lwc/ssr-runtime'; @@ -62,7 +64,8 @@ const bExportTemplate = esTemplate` export default function compileTemplate( src: string, filename: string, - options: TemplateCompilerConfig + options: TemplateCompilerConfig, + compilationMode: CompilationMode ) { const { root, warnings } = parse(src, { // `options` is from @lwc/compiler, and may have flags that @lwc/template-compiler doesn't @@ -110,13 +113,17 @@ export default function compileTemplate( bStyleValidationImport(), bExportTemplate(optimizeAdjacentYieldStmts(statements)), ]; - const program = b.program(moduleBody, 'module'); + let program = b.program(moduleBody, 'module'); addScopeTokenDeclarations(program, filename, options.namespace, options.name); const stylesheetImports = getStylesheetImports(filename); program.body.unshift(...stylesheetImports); + if (compilationMode === 'async' || compilationMode === 'sync') { + program = transmogrify(program, compilationMode); + } + return { code: generate(program, {}), }; diff --git a/packages/@lwc/ssr-compiler/src/index.ts b/packages/@lwc/ssr-compiler/src/index.ts index f50928cf72..6487fce81e 100644 --- a/packages/@lwc/ssr-compiler/src/index.ts +++ b/packages/@lwc/ssr-compiler/src/index.ts @@ -7,27 +7,31 @@ import compileJS from './compile-js'; import compileTemplate from './compile-template'; -import { TransformOptions } from './shared'; +import type { CompilationMode, TransformOptions } from './shared'; export interface CompilationResult { code: string; map: unknown; } +export type { CompilationMode }; + export function compileComponentForSSR( src: string, filename: string, - _options: TransformOptions + _options: TransformOptions, + mode: CompilationMode = 'asyncYield' ): CompilationResult { - const { code } = compileJS(src, filename); + const { code } = compileJS(src, filename, mode); return { code, map: undefined }; } export function compileTemplateForSSR( src: string, filename: string, - options: TransformOptions + options: TransformOptions, + mode: CompilationMode = 'asyncYield' ): CompilationResult { - const { code } = compileTemplate(src, filename, options); + const { code } = compileTemplate(src, filename, options, mode); return { code, map: undefined }; } diff --git a/packages/@lwc/ssr-compiler/src/shared.ts b/packages/@lwc/ssr-compiler/src/shared.ts index 14030ee6ed..ed4f99a0cf 100644 --- a/packages/@lwc/ssr-compiler/src/shared.ts +++ b/packages/@lwc/ssr-compiler/src/shared.ts @@ -59,3 +59,6 @@ export interface IHoistInstantiation { } export type TransformOptions = Pick; + +/* SSR compilation mode. `async` refers to async functions, `sync` to sync functions, and `asyncYield` to async generator functions. */ +export type CompilationMode = 'asyncYield' | 'async' | 'sync'; diff --git a/packages/@lwc/ssr-compiler/src/transmogrify.ts b/packages/@lwc/ssr-compiler/src/transmogrify.ts new file mode 100644 index 0000000000..7bbb6767a7 --- /dev/null +++ b/packages/@lwc/ssr-compiler/src/transmogrify.ts @@ -0,0 +1,175 @@ +/* + * Copyright (c) 2024, Salesforce, Inc. + * All rights reserved. + * SPDX-License-Identifier: MIT + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT + */ +import { traverse, builders as b, type NodePath } from 'estree-toolkit'; +import { produce } from 'immer'; +import type { Node } from 'estree'; +import type { Program as EsProgram } from 'estree'; + +export type TransmogrificationMode = 'sync' | 'async'; + +interface TransmogrificationState { + mode: TransmogrificationMode; +} + +export type Visitors = Parameters>[1]; + +const EMIT_IDENT = b.identifier('$$emit'); +// Rollup may rename variables to prevent shadowing. When it does, it uses the format `foo$0`, `foo$1`, etc. +const TMPL_FN_PATTERN = /tmpl($\d+)?/; +const GEN_MARKUP_PATTERN = /generateMarkup($\d+)?/; + +const isWithinFn = (pattern: RegExp, nodePath: NodePath): boolean => { + const { node } = nodePath; + if (!node) { + return false; + } + if (node.type === 'FunctionDeclaration' && pattern.test(node.id.name)) { + return true; + } + if (nodePath.parentPath) { + return isWithinFn(pattern, nodePath.parentPath); + } + return false; +}; + +const visitors: Visitors = { + FunctionDeclaration(path, state) { + const { node } = path; + if (!node?.async || !node?.generator) { + return; + } + + // Component authors might conceivably use async generator functions in their own code. Therefore, + // when traversing & transforming written+generated code, we need to disambiguate generated async + // generator functions from those that were written by the component author. + if (!isWithinFn(GEN_MARKUP_PATTERN, path) && !isWithinFn(TMPL_FN_PATTERN, path)) { + return; + } + node.generator = false; + node.async = state.mode === 'async'; + node.params.unshift(EMIT_IDENT); + }, + YieldExpression(path, state) { + const { node } = path; + if (!node) { + return; + } + + // Component authors might conceivably use generator functions within their own code. Therefore, + // when traversing & transforming written+generated code, we need to disambiguate generated yield + // expressions from those that were written by the component author. + if (!isWithinFn(TMPL_FN_PATTERN, path) && !isWithinFn(GEN_MARKUP_PATTERN, path)) { + return; + } + + if (node.delegate) { + // transform `yield* foo(arg)` into `foo($$emit, arg)` or `await foo($$emit, arg)` + if (node.argument?.type !== 'CallExpression') { + throw new Error( + 'Implementation error: cannot transmogrify complex yield-from expressions' + ); + } + + const callExpr = node.argument; + callExpr.arguments.unshift(EMIT_IDENT); + + path.replaceWith(state.mode === 'sync' ? callExpr : b.awaitExpression(callExpr)); + } else { + // transform `yield foo` into `$$emit(foo)` + const emittedExpression = node.argument; + if (!emittedExpression) { + throw new Error( + 'Implementation error: cannot transform a yield expression that yields nothing' + ); + } + + path.replaceWith(b.callExpression(EMIT_IDENT, [emittedExpression])); + } + }, + ImportSpecifier(path, _state) { + // @lwc/ssr-runtime has a couple of helper functions that need to conform to either the generator or + // no-generator compilation mode/paradigm. Since these are simple helper functions, we can maintain + // two implementations of each helper method: + // + // - renderAttrs vs renderAttrsNoYield + // - fallbackTmpl vs fallbackTmplNoYield + // + // If this becomes too burdensome to maintain, we can officially deprecate the generator-based approach + // and switch the @lwc/ssr-runtime implementation wholesale over to the no-generator paradigm. + + const { node } = path; + if (!node || node.imported.type !== 'Identifier') { + throw new Error( + 'Implementation error: unexpected missing identifier in import specifier' + ); + } + + if ( + path.parent?.type !== 'ImportDeclaration' || + path.parent.source.value !== '@lwc/ssr-runtime' + ) { + return; + } + + if (node.imported.name === 'fallbackTmpl') { + node.imported.name = 'fallbackTmplNoYield'; + } else if (node.imported.name === 'renderAttrs') { + node.imported.name = 'renderAttrsNoYield'; + } + }, +}; + +/** + * Transforms async-generator code into either the async or synchronous alternatives that are + * ~semantically equivalent. For example, this template: + * + * + * + * Is compiled into the following JavaScript, intended for execution during SSR & stripped down + * for the purposes of this example: + * + * async function* tmpl(props, attrs, slottedContent, Cmp, instance) { + * yield '
foobar
'; + * const childProps = {}; + * const childAttrs = {}; + * yield* generateChildMarkup("x-child", childProps, childAttrs, childSlottedContentGenerator); + * } + * + * When transmogrified in async-mode, the above generated template function becomes the following: + * + * async function tmpl($$emit, props, attrs, slottedContent, Cmp, instance) { + * $$emit('
foobar
'); + * const childProps = {}; + * const childAttrs = {}; + * await generateChildMarkup($$emit, "x-child", childProps, childAttrs, childSlottedContentGenerator); + * } + * + * When transmogrified in sync-mode, the template function becomes the following: + * + * function tmpl($$emit, props, attrs, slottedContent, Cmp, instance) { + * $$emit('
foobar
'); + * const childProps = {}; + * const childAttrs = {}; + * generateChildMarkup($$emit, "x-child", childProps, childAttrs, childSlottedContentGenerator); + * } + * + * There are tradeoffs for each of these modes. Notably, the async-yield variety is the easiest to transform + * into either of the other varieties and, for that reason, is the variety that is "authored" by the SSR compiler. + */ +export function transmogrify( + compiledComponentAst: EsProgram, + mode: TransmogrificationMode = 'sync' +): EsProgram { + const state: TransmogrificationState = { + mode, + }; + + return produce(compiledComponentAst, (astDraft) => traverse(astDraft, visitors, state)); +} diff --git a/packages/@lwc/ssr-runtime/src/index.ts b/packages/@lwc/ssr-runtime/src/index.ts index 647ec34f0a..20e9cfedcf 100644 --- a/packages/@lwc/ssr-runtime/src/index.ts +++ b/packages/@lwc/ssr-runtime/src/index.ts @@ -15,8 +15,10 @@ export { mutationTracker } from './mutation-tracker'; // renderComponent is an alias for serverSideRenderComponent export { fallbackTmpl, + fallbackTmplNoYield, GenerateMarkupFn, renderAttrs, + renderAttrsNoYield, serverSideRenderComponent, serverSideRenderComponent as renderComponent, } from './render'; diff --git a/packages/@lwc/ssr-runtime/src/render.ts b/packages/@lwc/ssr-runtime/src/render.ts index f09c5fca39..e083c48162 100644 --- a/packages/@lwc/ssr-runtime/src/render.ts +++ b/packages/@lwc/ssr-runtime/src/render.ts @@ -5,8 +5,8 @@ * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT */ -import { LightningElement, type LightningElementConstructor } from './lightning-element'; import { mutationTracker } from './mutation-tracker'; +import type { LightningElement, LightningElementConstructor } from './lightning-element'; import type { Attributes } from './types'; const escapeAttrVal = (attrVal: string) => @@ -27,6 +27,25 @@ export function* renderAttrs(instance: LightningElement, attrs: Attributes) { yield mutationTracker.renderMutatedAttrs(instance); } +export function renderAttrsNoYield( + emit: (segment: string) => void, + instance: LightningElement, + attrs: Attributes +) { + if (!attrs) { + return; + } + for (const attrName of Object.getOwnPropertyNames(attrs)) { + const attrVal = attrs[attrName]; + if (typeof attrVal === 'string') { + emit(attrVal === '' ? ` ${attrName}` : ` ${attrName}="${escapeAttrVal(attrVal)}"`); + } else if (attrVal === null) { + emit(''); + } + } + emit(mutationTracker.renderMutatedAttrs(instance)); +} + export function* fallbackTmpl( _props: unknown, _attrs: unknown, @@ -39,6 +58,19 @@ export function* fallbackTmpl( } } +export function fallbackTmplNoYield( + emit: (segment: string) => void, + _props: unknown, + _attrs: unknown, + _slotted: unknown, + Cmp: LightningElementConstructor, + _instance: unknown +) { + if (Cmp.renderMode !== 'light') { + emit(''); + } +} + export type GenerateMarkupFn = ( tagName: string, props: Record | null, @@ -46,15 +78,62 @@ export type GenerateMarkupFn = ( slotted: Record> | null ) => AsyncGenerator; +export type GenerateMarkupFnAsyncNoGen = ( + emit: (segment: string) => void, + tagName: string, + props: Record | null, + attrs: Attributes | null, + slotted: Record> | null +) => Promise; + +export type GenerateMarkupFnSyncNoGen = ( + emit: (segment: string) => void, + tagName: string, + props: Record | null, + attrs: Attributes | null, + slotted: Record> | null +) => void; + +type GenerateMarkupFnVariants = + | GenerateMarkupFn + | GenerateMarkupFnAsyncNoGen + | GenerateMarkupFnSyncNoGen; + export async function serverSideRenderComponent( tagName: string, - compiledGenerateMarkup: GenerateMarkupFn, - props: Record + compiledGenerateMarkup: GenerateMarkupFnVariants, + props: Record, + mode: 'asyncYield' | 'async' | 'sync' = 'asyncYield' ): Promise { let markup = ''; - for await (const segment of compiledGenerateMarkup(tagName, props, null, null)) { - markup += segment; + if (mode === 'asyncYield') { + for await (const segment of (compiledGenerateMarkup as GenerateMarkupFn)( + tagName, + props, + null, + null + )) { + markup += segment; + } + } else if (mode === 'async') { + const emit = (segment: string) => { + markup += segment; + }; + await (compiledGenerateMarkup as GenerateMarkupFnAsyncNoGen)( + emit, + tagName, + props, + null, + null + ); + } else if (mode === 'sync') { + const emit = (segment: string) => { + markup += segment; + }; + (compiledGenerateMarkup as GenerateMarkupFnSyncNoGen)(emit, tagName, props, null, null); + } else { + throw new Error(`Invalid mode: ${mode}`); } return markup;