Skip to content

Commit

Permalink
feat: add dynamic components for @lwc/ssr-compiler (#4847)
Browse files Browse the repository at this point in the history
  • Loading branch information
jmsjtu authored Nov 15, 2024
1 parent 7d7dea4 commit b777da2
Show file tree
Hide file tree
Showing 17 changed files with 179 additions and 78 deletions.
5 changes: 2 additions & 3 deletions packages/@lwc/babel-plugin-component/src/component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import * as types from '@babel/types';
import { addDefault, addNamed } from '@babel/helper-module-imports';
import { NodePath } from '@babel/traverse';
import { Visitor } from '@babel/core';
import { getAPIVersionFromNumber } from '@lwc/shared';
import { generateCustomElementTagName, getAPIVersionFromNumber } from '@lwc/shared';
import {
COMPONENT_NAME_KEY,
LWC_PACKAGE_ALIAS,
Expand Down Expand Up @@ -48,8 +48,7 @@ function needsComponentRegistration(path: DeclarationPath) {

function getComponentRegisteredName(t: BabelTypes, state: LwcBabelPluginPass) {
const { namespace, name } = state.opts;
const kebabCasedName = name?.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase();
const componentName = namespace && kebabCasedName ? `${namespace}-${kebabCasedName}` : '';
const componentName = generateCustomElementTagName(namespace, name);
return t.stringLiteral(componentName);
}

Expand Down
2 changes: 1 addition & 1 deletion packages/@lwc/engine-core/src/framework/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -667,7 +667,7 @@ function dc(

if (!isComponentConstructor(Ctor)) {
throw new Error(
`Invalid constructor ${toString(Ctor)} is not a LightningElement constructor.`
`Invalid constructor: "${toString(Ctor)}" is not a LightningElement constructor.`
);
}

Expand Down
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';
}
20 changes: 20 additions & 0 deletions packages/@lwc/shared/src/custom-element.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/*
* 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
*/

/**
* Generates a custom element tag name given a namespace and component name.
* Based on the LWC file system requirements, component names come from the file system name which is
* camel cased. The component's name will be converted to kebab case when the tag name is produced.
*
* @param namespace component namespace
* @param name component name
* @returns component tag name
*/
export function generateCustomElementTagName(namespace: string = '', name: string = '') {
const kebabCasedName = name.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase();
return `${namespace}-${kebabCasedName}`;
}
1 change: 1 addition & 0 deletions packages/@lwc/shared/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,6 @@ export * from './overridable-hooks';
export * from './static-part-tokens';
export * from './style';
export * from './signals';
export * from './custom-element';

export { assert };
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,6 @@ export const expectedFailures = new Set([
'attribute-style/basic/index.js',
'attribute-style/dynamic/index.js',
'comments-text-preserve-off/index.js',
'dynamic-component-no-ctor/index.js',
'dynamic-components/index.js',
'dynamic-slots/index.js',
'empty-text-with-comments-non-static-optimized/index.js',
'if-conditional-slot-content/index.js',
Expand Down
12 changes: 10 additions & 2 deletions packages/@lwc/ssr-compiler/src/compile-js/generate-markup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ type RenderCallExpression = SimpleCallExpression & {

const bGenerateMarkup = esTemplate`
export async function* generateMarkup(tagName, props, attrs, slotted, parent, scopeToken) {
tagName = tagName ?? ${/*component tag name*/ is.literal};
attrs = attrs ?? Object.create(null);
props = props ?? Object.create(null);
props = __filterProperties(
Expand All @@ -56,12 +57,12 @@ const bGenerateMarkup = esTemplate`
const hostHasScopedStylesheets =
tmplFn.hasScopedStylesheets ||
hasScopedStaticStylesheets(${/*component class*/ 2});
hasScopedStaticStylesheets(${/*component class*/ 3});
const hostScopeToken = hostHasScopedStylesheets ? tmplFn.stylesheetScopeToken + "-host" : undefined;
yield* __renderAttrs(instance, attrs, hostScopeToken, scopeToken);
yield '>';
yield* tmplFn(props, attrs, slotted, ${/*component class*/ 2}, instance);
yield* tmplFn(props, attrs, slotted, ${/*component class*/ 3}, instance);
yield \`</\${tagName}>\`;
}
`<ExportNamedDeclaration>;
Expand All @@ -87,10 +88,16 @@ const bAssignGenerateMarkupToComponentClass = esTemplate`
export function addGenerateMarkupExport(
program: Program,
state: ComponentMetaState,
tagName: string,
filename: string
) {
const { hasRenderMethod, privateFields, publicFields, tmplExplicitImports } = state;

// 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(tagName);
const classIdentifier = b.identifier(state.lwcClassName!);
const renderCall = hasRenderMethod
? (b.callExpression(
Expand Down Expand Up @@ -126,6 +133,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
9 changes: 7 additions & 2 deletions packages/@lwc/ssr-compiler/src/compile-js/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,12 @@ const visitors: Visitors = {
},
};

export default function compileJS(src: string, filename: string, compilationMode: CompilationMode) {
export default function compileJS(
src: string,
filename: string,
tagName: string,
compilationMode: CompilationMode
) {
let ast = parseModule(src, {
module: true,
next: true,
Expand Down Expand Up @@ -185,7 +190,7 @@ export default function compileJS(src: string, filename: string, compilationMode
};
}

addGenerateMarkupExport(ast, state, filename);
addGenerateMarkupExport(ast, state, tagName, filename);
assignGenerateMarkupToComponent(ast, state);

if (compilationMode === 'async' || compilationMode === 'sync') {
Expand Down
3 changes: 2 additions & 1 deletion packages/@lwc/ssr-compiler/src/compile-template/ir-to-es.ts
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
51 changes: 49 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, StringReplace, StringTrim } 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,42 @@ 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(
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: string | boolean = value.value;
if (name === 'style') {
literalValue = normalizeStyleAttributeValue(literalValue);
} else if (name === 'class') {
literalValue = normalizeClassAttributeValue(literalValue);
if (literalValue === '') {
return; // do not render empty `class=""`
}
} else if (name === 'spellcheck') {
// `spellcheck` string values are specially handled to massage them into booleans:
// https://github.com/salesforce/lwc/blob/574ffbd/packages/%40lwc/template-compiler/src/codegen/index.ts#L445-L448
literalValue = literalValue.toLowerCase() !== 'false';
}
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,45 +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: string | boolean = value.value;
if (name === 'style') {
literalValue = normalizeStyleAttributeValue(literalValue);
} else if (name === 'class') {
literalValue = normalizeClassAttributeValue(literalValue);
if (literalValue === '') {
return; // do not render empty `class=""`
}
} else if (name === 'spellcheck') {
// `spellcheck` string values are specially handled to massage them into booleans:
// https://github.com/salesforce/lwc/blob/574ffbd/packages/%40lwc/template-compiler/src/codegen/index.ts#L445-L448
literalValue = literalValue.toLowerCase() !== 'false';
}
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,66 @@
/*
* 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 { 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 {
IfStatement as EsIfStatement,
VariableDeclaration as EsVariableDeclaration,
} from 'estree';

const bDynamicComponentConstructorDeclaration = esTemplate`
const Ctor = '${/*lwcIs attribute value*/ is.expression}';
`<EsVariableDeclaration>;

const bYieldFromDynamicComponentConstructorGenerator = esTemplateWithYield`
if (Ctor) {
if (typeof Ctor !== 'function' || !(Ctor.prototype instanceof LightningElement)) {
throw new Error(\`Invalid constructor: "\${String(Ctor)}" is not a LightningElement constructor.\`)
}
const childProps = __getReadOnlyProxy(${/* child props */ is.objectExpression});
const childAttrs = ${/* child attrs */ is.objectExpression};
yield* Ctor[SYMBOL__GENERATE_MARKUP](null, childProps, childAttrs);
}
`<EsIfStatement>;

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([{ getReadOnlyProxy: '__getReadOnlyProxy' }]),
'import:getReadOnlyProxy'
);

return [
bDynamicComponentConstructorDeclaration(
// The template compiler has validation to prevent lwcIs.value from being a literal
expressionIrToEs(lwcIs.value as IrExpression, cxt)
),
bYieldFromDynamicComponentConstructorGenerator(
getChildAttrsOrProps(node.properties, cxt),
getChildAttrsOrProps(node.attributes, cxt)
),
];
} else {
return [];
}
};
Loading

0 comments on commit b777da2

Please sign in to comment.