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 \`\${tagName}>\`;
}
`;
@@ -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 };
}