-
Notifications
You must be signed in to change notification settings - Fork 392
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
Changes from 9 commits
5395e79
78eded2
81ec6de
87ed874
871fdf5
67d62c7
772bc55
e310b9b
37c8499
7712268
ab92e9e
371318d
edf8420
2746e71
9ba0901
2bb0491
6ca1545
ad1a403
8cd5d6a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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'; | ||
} |
Original file line number | Diff line number | Diff line change | ||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|
|
@@ -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 { | ||||||||||||
|
@@ -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} | ||||||||||||
nolanlawson marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||||||
attrs = attrs ?? Object.create(null); | ||||||||||||
props = props ?? Object.create(null); | ||||||||||||
props = __filterProperties( | ||||||||||||
|
@@ -87,10 +89,17 @@ const bAssignGenerateMarkupToComponentClass = esTemplate` | |||||||||||
export function addGenerateMarkupExport( | ||||||||||||
program: Program, | ||||||||||||
state: ComponentMetaState, | ||||||||||||
options: TransformOptions, | ||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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}`); | ||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Just double checking: is the There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. lwc/packages/@lwc/rollup-plugin/src/index.ts Lines 324 to 328 in 29a641e
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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( | ||||||||||||
|
@@ -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, | ||||||||||||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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']); | ||
|
@@ -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( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Open to keeping it in the |
||
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 |
---|---|---|
@@ -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) | ||
) | ||
), | ||
]; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.`);
}
} There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I wasn't sure what parts we wanted to keep in an I'll redo this to use an There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
} else { | ||
return []; | ||
} | ||
}; |
There was a problem hiding this comment.
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:
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.