diff --git a/packages/@lwc/babel-plugin-component/src/component.ts b/packages/@lwc/babel-plugin-component/src/component.ts index 7debbf4bd2..7e6c20e056 100644 --- a/packages/@lwc/babel-plugin-component/src/component.ts +++ b/packages/@lwc/babel-plugin-component/src/component.ts @@ -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, @@ -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); } diff --git a/packages/@lwc/engine-core/src/framework/api.ts b/packages/@lwc/engine-core/src/framework/api.ts index 401e826c95..46bc01a477 100644 --- a/packages/@lwc/engine-core/src/framework/api.ts +++ b/packages/@lwc/engine-core/src/framework/api.ts @@ -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.` ); } diff --git a/packages/@lwc/engine-server/src/__tests__/fixtures/dynamic-component-invalid-ctor/error.txt b/packages/@lwc/engine-server/src/__tests__/fixtures/dynamic-component-invalid-ctor/error.txt new file mode 100644 index 0000000000..957987955f --- /dev/null +++ b/packages/@lwc/engine-server/src/__tests__/fixtures/dynamic-component-invalid-ctor/error.txt @@ -0,0 +1 @@ +Invalid constructor: "frankenstein" is not a LightningElement constructor. \ No newline at end of file diff --git a/packages/@lwc/engine-server/src/__tests__/fixtures/dynamic-component-invalid-ctor/expected.html b/packages/@lwc/engine-server/src/__tests__/fixtures/dynamic-component-invalid-ctor/expected.html new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/@lwc/engine-server/src/__tests__/fixtures/dynamic-component-invalid-ctor/index.js b/packages/@lwc/engine-server/src/__tests__/fixtures/dynamic-component-invalid-ctor/index.js new file mode 100644 index 0000000000..30885ee9a6 --- /dev/null +++ b/packages/@lwc/engine-server/src/__tests__/fixtures/dynamic-component-invalid-ctor/index.js @@ -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'; \ No newline at end of file diff --git a/packages/@lwc/engine-server/src/__tests__/fixtures/dynamic-component-invalid-ctor/modules/x/dynamic-invalid-ctor/dynamic-invalid-ctor.html b/packages/@lwc/engine-server/src/__tests__/fixtures/dynamic-component-invalid-ctor/modules/x/dynamic-invalid-ctor/dynamic-invalid-ctor.html new file mode 100644 index 0000000000..e48fa62ca9 --- /dev/null +++ b/packages/@lwc/engine-server/src/__tests__/fixtures/dynamic-component-invalid-ctor/modules/x/dynamic-invalid-ctor/dynamic-invalid-ctor.html @@ -0,0 +1,3 @@ + \ No newline at end of file diff --git a/packages/@lwc/engine-server/src/__tests__/fixtures/dynamic-component-invalid-ctor/modules/x/dynamic-invalid-ctor/dynamic-invalid-ctor.js b/packages/@lwc/engine-server/src/__tests__/fixtures/dynamic-component-invalid-ctor/modules/x/dynamic-invalid-ctor/dynamic-invalid-ctor.js new file mode 100644 index 0000000000..111557c1c8 --- /dev/null +++ b/packages/@lwc/engine-server/src/__tests__/fixtures/dynamic-component-invalid-ctor/modules/x/dynamic-invalid-ctor/dynamic-invalid-ctor.js @@ -0,0 +1,5 @@ +import { LightningElement } from 'lwc'; + +export default class extends LightningElement { + customCtor = 'frankenstein'; +} \ No newline at end of file diff --git a/packages/@lwc/shared/src/custom-element.ts b/packages/@lwc/shared/src/custom-element.ts new file mode 100644 index 0000000000..8d74ffb8cc --- /dev/null +++ b/packages/@lwc/shared/src/custom-element.ts @@ -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}`; +} diff --git a/packages/@lwc/shared/src/index.ts b/packages/@lwc/shared/src/index.ts index d752c05f91..5447d335d9 100644 --- a/packages/@lwc/shared/src/index.ts +++ b/packages/@lwc/shared/src/index.ts @@ -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 }; diff --git a/packages/@lwc/ssr-compiler/src/__tests__/utils/expected-failures.ts b/packages/@lwc/ssr-compiler/src/__tests__/utils/expected-failures.ts index b559ecd35f..6638489ddc 100644 --- a/packages/@lwc/ssr-compiler/src/__tests__/utils/expected-failures.ts +++ b/packages/@lwc/ssr-compiler/src/__tests__/utils/expected-failures.ts @@ -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', diff --git a/packages/@lwc/ssr-compiler/src/compile-js/generate-markup.ts b/packages/@lwc/ssr-compiler/src/compile-js/generate-markup.ts index d830e6e813..b67d59cd3f 100644 --- a/packages/@lwc/ssr-compiler/src/compile-js/generate-markup.ts +++ b/packages/@lwc/ssr-compiler/src/compile-js/generate-markup.ts @@ -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( @@ -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 \`\`; } `; @@ -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( @@ -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, diff --git a/packages/@lwc/ssr-compiler/src/compile-js/index.ts b/packages/@lwc/ssr-compiler/src/compile-js/index.ts index c05c8f3014..f2ef0e4d15 100644 --- a/packages/@lwc/ssr-compiler/src/compile-js/index.ts +++ b/packages/@lwc/ssr-compiler/src/compile-js/index.ts @@ -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, @@ -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') { 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 0e437a94b1..b0ddb069b6 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 @@ -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, @@ -58,7 +59,7 @@ const transformers: Transformers = { ElseBlock: defaultTransformer, ScopedSlotFragment: defaultTransformer, Slot, - Lwc: defaultTransformer, + Lwc: LwcComponent, }; export function irChildrenToEs(children: IrChildNode[], cxt: TransformerContext): EsStatement[] { diff --git a/packages/@lwc/ssr-compiler/src/compile-template/shared.ts b/packages/@lwc/ssr-compiler/src/compile-template/shared.ts index 32eb14c0bf..112972748a 100644 --- a/packages/@lwc/ssr-compiler/src/compile-template/shared.ts +++ b/packages/@lwc/ssr-compiler/src/compile-template/shared.ts @@ -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']); @@ -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); +} diff --git a/packages/@lwc/ssr-compiler/src/compile-template/transformers/component.ts b/packages/@lwc/ssr-compiler/src/compile-template/transformers/component.ts index d61dc1d96c..85dc3cfb20 100644 --- a/packages/@lwc/ssr-compiler/src/compile-template/transformers/component.ts +++ b/packages/@lwc/ssr-compiler/src/compile-template/transformers/component.ts @@ -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` @@ -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 = function Component(node, cxt) { // Import the custom component's generateMarkup export. const childGeneratorLocalName = `generateMarkup_${toPropertyName(node.name)}`; diff --git a/packages/@lwc/ssr-compiler/src/compile-template/transformers/lwc-component.ts b/packages/@lwc/ssr-compiler/src/compile-template/transformers/lwc-component.ts new file mode 100644 index 0000000000..e503827bae --- /dev/null +++ b/packages/@lwc/ssr-compiler/src/compile-template/transformers/lwc-component.ts @@ -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}'; +`; + +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); + } +`; + +export const LwcComponent: Transformer = 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 []; + } +}; diff --git a/packages/@lwc/ssr-compiler/src/index.ts b/packages/@lwc/ssr-compiler/src/index.ts index 6487fce81e..2a6211e7dd 100644 --- a/packages/@lwc/ssr-compiler/src/index.ts +++ b/packages/@lwc/ssr-compiler/src/index.ts @@ -5,6 +5,7 @@ * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT */ +import { generateCustomElementTagName } from '@lwc/shared'; import compileJS from './compile-js'; import compileTemplate from './compile-template'; import type { CompilationMode, TransformOptions } from './shared'; @@ -19,10 +20,11 @@ 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 tagName = generateCustomElementTagName(options.namespace, options.name); + const { code } = compileJS(src, filename, tagName, mode); return { code, map: undefined }; }