Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weโ€™ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add dynamic components for @lwc/ssr-compiler #4847

Merged
merged 19 commits into from
Nov 15, 2024
Merged
Show file tree
Hide file tree
Changes from 9 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
@@ -0,0 +1 @@
Invalid constructor frankenstein is not a LightningElement constructor.
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export const tagName = 'x-dynamic-component-invalid-ctor';
export { default } from 'x/dynamic-invalid-ctor';
export * from 'x/dynamic-invalid-ctor';
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<template>
<lwc:component lwc:is={customCtor}></lwc:component>
</template>
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { LightningElement } from 'lwc';

export default class extends LightningElement {
customCtor = 'frankenstein';
}
10 changes: 10 additions & 0 deletions packages/@lwc/ssr-compiler/src/compile-js/generate-markup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { is, builders as b } from 'estree-toolkit';
import { esTemplate } from '../estemplate';
import { isIdentOrRenderCall } from '../estree/validators';
import { bImportDeclaration, bImportDefaultDeclaration } from '../estree/builders';
import { TransformOptions } from '../shared';
import { bWireAdaptersPlumbing } from './wire';

import type {
Expand All @@ -30,6 +31,7 @@ type RenderCallExpression = SimpleCallExpression & {

const bGenerateMarkup = esTemplate`
export async function* generateMarkup(tagName, props, attrs, slotted, parent, scopeToken) {
tagName = tagName ?? ${/*component tag name*/ is.literal}
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sample output will look like this:

async function* generateMarkup$1(tagName, props, attrs, slotted, parent, scopeToken) {
  tagName = tagName ?? "x-test";

We need this to get the tag name that's passed to the transformer, otherwise, the constructor doesn't have a tag name to use.

nolanlawson marked this conversation as resolved.
Show resolved Hide resolved
attrs = attrs ?? Object.create(null);
props = props ?? Object.create(null);
props = __filterProperties(
Expand Down Expand Up @@ -87,10 +89,17 @@ const bAssignGenerateMarkupToComponentClass = esTemplate`
export function addGenerateMarkupExport(
program: Program,
state: ComponentMetaState,
options: TransformOptions,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure what @nolanlawson thinks about this, but it might be worth scoping this down to { name: string, namespace: string }.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

filename: string
) {
const { hasRenderMethod, privateFields, publicFields, tmplExplicitImports } = state;
const { namespace, name } = options;

// The default tag name represents the component name that's passed to the transformer.
// This is needed to generate markup for dynamic components which are invoked through
// the generateMarkup function on the constructor.
// At the time of generation, the invoker does not have reference to its tag name to pass as an argument.
const defaultTagName = b.literal(`${namespace}-${name}`);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just double checking: is the name value here kebab-cased or camel-cased?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just checked. Yes it is camel-cased. So this needs to be transformed.

// Extract module name and namespace from file path.
// Specifier will only exist for modules with alias paths.
// Otherwise, use the file directory structure to resolve namespace and name.
const [namespace, name] =
specifier?.split('/') ?? path.dirname(filename).split(path.sep).slice(-2);

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a helper function!

const classIdentifier = b.identifier(state.lwcClassName!);
const renderCall = hasRenderMethod
? (b.callExpression(
Expand Down Expand Up @@ -126,6 +135,7 @@ export function addGenerateMarkupExport(
program.body.unshift(bImportDeclaration(['hasScopedStaticStylesheets']));
program.body.push(
bGenerateMarkup(
defaultTagName,
b.arrayExpression(publicFields.map(b.literal)),
b.arrayExpression(privateFields.map(b.literal)),
classIdentifier,
Expand Down
12 changes: 9 additions & 3 deletions packages/@lwc/ssr-compiler/src/compile-js/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { catalogWireAdapters } from './wire';

import type { Identifier as EsIdentifier, Program as EsProgram, Expression } from 'estree';
import type { Visitors, ComponentMetaState } from './types';
import type { CompilationMode } from '../shared';
import type { CompilationMode, TransformOptions } from '../shared';
import type {
PropertyDefinition as DecoratatedPropertyDefinition,
MethodDefinition as DecoratatedMethodDefinition,
Expand Down Expand Up @@ -148,7 +148,12 @@ const visitors: Visitors = {
},
};

export default function compileJS(src: string, filename: string, compilationMode: CompilationMode) {
export default function compileJS(
src: string,
filename: string,
options: TransformOptions,
compilationMode: CompilationMode
) {
let ast = parseModule(src, {
module: true,
next: true,
Expand Down Expand Up @@ -183,7 +188,8 @@ export default function compileJS(src: string, filename: string, compilationMode
};
}

addGenerateMarkupExport(ast, state, filename);
// Add the tag name here, either export tag name as additional export or just attach it to the class
addGenerateMarkupExport(ast, state, options, filename);
assignGenerateMarkupToComponent(ast, state);

if (compilationMode === 'async' || compilationMode === 'sync') {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { If, IfBlock } from './transformers/if';
import { Slot } from './transformers/slot';
import { Text } from './transformers/text';
import { createNewContext } from './context';
import { LwcComponent } from './transformers/lwc-component';

import type {
ChildNode as IrChildNode,
Expand Down Expand Up @@ -58,7 +59,7 @@ const transformers: Transformers = {
ElseBlock: defaultTransformer,
ScopedSlotFragment: defaultTransformer,
Slot,
Lwc: defaultTransformer,
Lwc: LwcComponent,
};

export function irChildrenToEs(children: IrChildNode[], cxt: TransformerContext): EsStatement[] {
Expand Down
47 changes: 45 additions & 2 deletions packages/@lwc/ssr-compiler/src/compile-template/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,24 @@
*/

import { builders as b, is } from 'estree-toolkit';
import { StringReplace, StringTrim } from '@lwc/shared';
import { Node as IrNode } from '@lwc/template-compiler';
import {
Node as IrNode,
Attribute as IrAttribute,
Property as IrProperty,
} from '@lwc/template-compiler';
import { normalizeStyleAttributeValue } from '@lwc/shared';

import { bImportDeclaration } from '../estree/builders';
import { isValidIdentifier } from '../shared';
import { TransformerContext } from './types';
import { expressionIrToEs } from './expression';
import type {
Statement as EsStatement,
Expression as EsExpression,
MemberExpression as EsMemberExpression,
Identifier as EsIdentifier,
ObjectExpression as EsObjectExpression,
Property as EsProperty,
} from 'estree';

export const bImportHtmlEscape = () => bImportDeclaration(['htmlEscape']);
Expand Down Expand Up @@ -95,3 +103,38 @@ export function normalizeClassAttributeValue(value: string) {
// @ts-expect-error weird indirection results in wrong overload being picked up
return StringReplace.call(StringTrim.call(value), /\s+/g, ' ');
}

export function getChildAttrsOrProps(
Copy link
Member Author

@jmsjtu jmsjtu Nov 13, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I moved this function to the shared file because I'm reusing it in lwc-component.ts.

Open to keeping it in the Component.ts if it's got things specific to Components only.

attrs: (IrAttribute | IrProperty)[],
cxt: TransformerContext
): EsObjectExpression {
const objectAttrsOrProps = attrs
.map(({ name, value, type }) => {
const key = isValidIdentifier(name) ? b.identifier(name) : b.literal(name);
if (value.type === 'Literal' && typeof value.value === 'string') {
let literalValue = value.value;
if (name === 'style') {
literalValue = normalizeStyleAttributeValue(literalValue);
} else if (name === 'class') {
literalValue = normalizeClassAttributeValue(literalValue);
if (literalValue === '') {
return; // do not render empty `class=""`
}
}
return b.property('init', key, b.literal(literalValue));
} else if (value.type === 'Literal' && typeof value.value === 'boolean') {
if (name === 'class') {
return; // do not render empty `class=""`
}

return b.property('init', key, b.literal(type === 'Attribute' ? '' : value.value));
} else if (value.type === 'Identifier' || value.type === 'MemberExpression') {
const propValue = expressionIrToEs(value, cxt);
return b.property('init', key, propValue);
}
throw new Error(`Unimplemented child attr IR node type: ${value.type}`);
})
.filter(Boolean) as EsProperty[];

return b.objectExpression(objectAttrsOrProps);
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,34 +8,15 @@
import { produce } from 'immer';
import { builders as b, is } from 'estree-toolkit';
import { kebabcaseToCamelcase, ScopedSlotFragment, toPropertyName } from '@lwc/template-compiler';
import { normalizeStyleAttributeValue } from '@lwc/shared';
import { bAttributeValue, getChildAttrsOrProps, optimizeAdjacentYieldStmts } from '../shared';
import { esTemplate, esTemplateWithYield } from '../../estemplate';
import { isValidIdentifier } from '../../shared';
import {
bAttributeValue,
normalizeClassAttributeValue,
optimizeAdjacentYieldStmts,
} from '../shared';
import { TransformerContext } from '../types';
import { expressionIrToEs } from '../expression';
import { irChildrenToEs, irToEs } from '../ir-to-es';
import { isNullableOf } from '../../estree/validators';
import { bImportDeclaration } from '../../estree/builders';
import type {
CallExpression as EsCallExpression,
Expression as EsExpression,
Property as EsProperty,
} from 'estree';

import type {
BlockStatement as EsBlockStatement,
ObjectExpression as EsObjectExpression,
} from 'estree';
import type {
Attribute as IrAttribute,
Component as IrComponent,
Property as IrProperty,
} from '@lwc/template-compiler';
import type { CallExpression as EsCallExpression, Expression as EsExpression } from 'estree';

import type { BlockStatement as EsBlockStatement } from 'estree';
import type { Component as IrComponent } from '@lwc/template-compiler';
import type { Transformer } from '../types';

const bYieldFromChildGenerator = esTemplateWithYield`
Expand Down Expand Up @@ -93,41 +74,6 @@ const bImportGenerateMarkup = (localName: string, importPath: string) =>
b.literal(importPath)
);

function getChildAttrsOrProps(
attrs: (IrAttribute | IrProperty)[],
cxt: TransformerContext
): EsObjectExpression {
const objectAttrsOrProps = attrs
.map(({ name, value, type }) => {
const key = isValidIdentifier(name) ? b.identifier(name) : b.literal(name);
if (value.type === 'Literal' && typeof value.value === 'string') {
let literalValue = value.value;
if (name === 'style') {
literalValue = normalizeStyleAttributeValue(literalValue);
} else if (name === 'class') {
literalValue = normalizeClassAttributeValue(literalValue);
if (literalValue === '') {
return; // do not render empty `class=""`
}
}
return b.property('init', key, b.literal(literalValue));
} else if (value.type === 'Literal' && typeof value.value === 'boolean') {
if (name === 'class') {
return; // do not render empty `class=""`
}

return b.property('init', key, b.literal(type === 'Attribute' ? '' : value.value));
} else if (value.type === 'Identifier' || value.type === 'MemberExpression') {
const propValue = expressionIrToEs(value, cxt);
return b.property('init', key, propValue);
}
throw new Error(`Unimplemented child attr IR node type: ${value.type}`);
})
.filter(Boolean) as EsProperty[];

return b.objectExpression(objectAttrsOrProps);
}

export const Component: Transformer<IrComponent> = function Component(node, cxt) {
// Import the custom component's generateMarkup export.
const childGeneratorLocalName = `generateMarkup_${toPropertyName(node.name)}`;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
/*
* Copyright (c) 2024, salesforce.com, 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 { builders as b, is } from 'estree-toolkit';
import { isUndefined } from '@lwc/shared';
import { Transformer } from '../types';
import { expressionIrToEs } from '../expression';
import { esTemplate, esTemplateWithYield } from '../../estemplate';
import { bImportDeclaration } from '../../estree/builders';
import { getChildAttrsOrProps } from '../shared';
import type {
LwcComponent as IrLwcComponent,
Expression as IrExpression,
} from '@lwc/template-compiler';
import type { BlockStatement as EsBlockStatement, Expression, Statement } from 'estree';

const bYieldFromDynamicComponentConstructorGenerator = esTemplateWithYield`
{
const childProps = __cloneAndDeepFreeze(${/* child props */ is.objectExpression});
const childAttrs = ${/* child attrs */ is.objectExpression};
yield* ${/*component ctor*/ is.expression}[SYMBOL__GENERATE_MARKUP](null, childProps, childAttrs);
}
`<EsBlockStatement>;

const bThrowErrorForInvalidConstructor = esTemplate`
{
throw new Error(\`Invalid constructor \${String(${/*component ctor*/ is.expression})} is not a LightningElement constructor.\`)
jmsjtu marked this conversation as resolved.
Show resolved Hide resolved
}
`<EsBlockStatement>;

function bIfLwcIsExpressionDefined(lwcIsExpression: Expression, consequent: Statement) {
// instance.lwcIsValue !== undefined && instance.lwcIsValue !== null
const lwcIsExpressionDefined = b.logicalExpression(
'&&',
b.binaryExpression('!==', lwcIsExpression, b.identifier('undefined')),
b.binaryExpression('!==', lwcIsExpression, b.identifier('null'))
);

return b.ifStatement(lwcIsExpressionDefined, b.blockStatement([consequent]));
}

function bifLwcIsExpressionTypeCorrect(
lwcIsExpression: Expression,
consequent: Statement,
alternate: Statement
) {
// typeof instance.lwcIsValue === 'function'
const typeComparison = b.binaryExpression(
'===',
b.unaryExpression('typeof', lwcIsExpression),
b.literal('function')
);

// instance.lwcIsValue.prototype instanceof LightningElement
const protoComparison = b.binaryExpression(
'instanceof',
b.memberExpression(lwcIsExpression, b.identifier('prototype')),
b.identifier('LightningElement')
);

const comparison = b.logicalExpression('&&', typeComparison, protoComparison);

return b.ifStatement(comparison, consequent, alternate);
}

export const LwcComponent: Transformer<IrLwcComponent> = function LwcComponent(node, cxt) {
const { directives } = node;

const lwcIs = directives.find((directive) => directive.name === 'Is');
if (!isUndefined(lwcIs)) {
cxt.hoist(bImportDeclaration(['LightningElement']), 'import:LightningElement');
cxt.hoist(
bImportDeclaration(['SYMBOL__GENERATE_MARKUP']),
'import:SYMBOL__GENERATE_MARKUP'
);
cxt.hoist(
bImportDeclaration([{ cloneAndDeepFreeze: '__cloneAndDeepFreeze' }]),
'import:cloneAndDeepFreeze'
);

// The template compiler has validation to prevent lwcIs.value from being a literal
const lwcIsExpression = expressionIrToEs(lwcIs.value as IrExpression, cxt);
return [
bIfLwcIsExpressionDefined(
lwcIsExpression,
bifLwcIsExpressionTypeCorrect(
lwcIsExpression,
bYieldFromDynamicComponentConstructorGenerator(
getChildAttrsOrProps(node.properties, cxt),
getChildAttrsOrProps(node.attributes, cxt),
lwcIsExpression
),
bThrowErrorForInvalidConstructor(lwcIsExpression)
)
),
];
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here's a sample output:

if (instance.customCtor !== undefined && instance.customCtor !== null) {
    if (typeof instance.customCtor === "function" && instance.customCtor.prototype instanceof LightningElement) {
      const childProps = cloneAndDeepFreeze({});
      const childAttrs = {};
      yield* instance.customCtor[SYMBOL__GENERATE_MARKUP](null, childProps, childAttrs);
    } else {
      throw new Error(`Invalid constructor ${String(instance.customCtor)} is not a LightningElement constructor.`);
    }
  }

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: It's not immediately apparent to me why a bunch little builders were used instead of a larger esTemplate. This seems to work fine though - just not quite as readable.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wasn't sure what parts we wanted to keep in an esTemplate vs using a builder but this helps clear things up!

I'll redo this to use an esTemplate, I think it'll be easier to read as well!

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

} else {
return [];
}
};
4 changes: 2 additions & 2 deletions packages/@lwc/ssr-compiler/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,10 @@ export type { CompilationMode };
export function compileComponentForSSR(
src: string,
filename: string,
_options: TransformOptions,
options: TransformOptions,
mode: CompilationMode = 'asyncYield'
): CompilationResult {
const { code } = compileJS(src, filename, mode);
const { code } = compileJS(src, filename, options, mode);
return { code, map: undefined };
}

Expand Down
Loading