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 1816a62188..2aa85bfbbd 100644 --- a/packages/@lwc/ssr-compiler/src/compile-js/generate-markup.ts +++ b/packages/@lwc/ssr-compiler/src/compile-js/generate-markup.ts @@ -30,11 +30,12 @@ const bGenerateMarkup = esTemplate` instance.__internal__setState(props, __REFLECTED_PROPS__, attrs); instance.isConnected = true; instance.connectedCallback?.(); + const tmplFn = ${isIdentOrRenderCall} ?? __fallbackTmpl; yield \`<\${tagName}\`; + yield tmplFn.stylesheetScopeTokenHostClass; yield *__renderAttrs(attrs) yield '>'; - const tmplFn = ${isIdentOrRenderCall} ?? __fallbackTmpl; - yield* tmplFn(props, attrs, slotted, ${is.identifier}, instance, defaultStylesheets); + yield* tmplFn(props, attrs, slotted, ${is.identifier}, instance); yield \`\`; } `; diff --git a/packages/@lwc/ssr-compiler/src/compile-js/index.ts b/packages/@lwc/ssr-compiler/src/compile-js/index.ts index 516542602a..ca360bdd6a 100644 --- a/packages/@lwc/ssr-compiler/src/compile-js/index.ts +++ b/packages/@lwc/ssr-compiler/src/compile-js/index.ts @@ -12,7 +12,7 @@ import { AriaPropNameToAttrNameMap } from '@lwc/shared'; import { replaceLwcImport } from './lwc-import'; import { catalogTmplImport } from './catalog-tmpls'; -import { addStylesheetImports, catalogStaticStylesheets, catalogStyleImport } from './stylesheets'; +import { catalogStaticStylesheets, catalogStyleImport } from './stylesheets'; import { addGenerateMarkupExport } from './generate-markup'; import type { Identifier as EsIdentifier, Program as EsProgram } from 'estree'; @@ -145,8 +145,15 @@ export default function compileJS(src: string, filename: string) { }; } + if (state.cssExplicitImports || state.staticStylesheetIds) { + throw new Error( + `Unimplemented static stylesheets, but found:\n${[...state.cssExplicitImports!].join( + ' \n' + )}` + ); + } + addGenerateMarkupExport(ast, state, filename); - addStylesheetImports(ast, state, filename); return { code: generate(ast, {}), diff --git a/packages/@lwc/ssr-compiler/src/compile-js/stylesheet-scope-token.ts b/packages/@lwc/ssr-compiler/src/compile-js/stylesheet-scope-token.ts new file mode 100644 index 0000000000..0f0696de9f --- /dev/null +++ b/packages/@lwc/ssr-compiler/src/compile-js/stylesheet-scope-token.ts @@ -0,0 +1,56 @@ +import { is } from 'estree-toolkit'; +import { generateScopeTokens } from '@lwc/template-compiler'; +import { builders as b } from 'estree-toolkit/dist/builders'; +import { esTemplate } from '../estemplate'; +import type { BlockStatement, ExportNamedDeclaration, Program, VariableDeclaration } from 'estree'; + +function generateStylesheetScopeToken(filename: string) { + // FIXME: we should be getting the namespace/name from the config options, + // since these actually come from the component filename, not the template filename. + const split = filename.split('/'); + const namespace = split.at(-3)!; + const baseName = split.at(-1)!; + + const componentName = baseName.replace(/\.[^.]+$/, ''); + const { + // FIXME: handle legacy scope token for older API versions + scopeToken, + } = generateScopeTokens(filename, namespace, componentName); + + return scopeToken; +} + +const bStylesheetTokenDeclaration = esTemplate` + const stylesheetScopeToken = '${is.literal}'; +`; + +const bAdditionalDeclarations = [ + esTemplate` + const hasScopedStylesheets = defaultScopedStylesheets && defaultScopedStylesheets.length > 0; + `, + esTemplate` + const stylesheetScopeTokenClass = hasScopedStylesheets ? \` class="\${stylesheetScopeToken}"\` : ''; + `, + esTemplate` + const stylesheetScopeTokenHostClass = hasScopedStylesheets ? \` class="\${stylesheetScopeToken}-host"\` : ''; + `, + esTemplate` + const stylesheetScopeTokenClassPrefix = hasScopedStylesheets ? (stylesheetScopeToken + ' ') : ''; + `, +]; + +// Scope tokens are associated with a given template. This is assigned here so that it can be used in `generateMarkup`. +const tmplAssignmentBlock = esTemplate` + ${is.identifier}.stylesheetScopeTokenHostClass = stylesheetScopeTokenHostClass; +`; + +export function addScopeTokenDeclarations(program: Program, filename: string) { + const scopeToken = generateStylesheetScopeToken(filename); + + program.body.unshift( + bStylesheetTokenDeclaration(b.literal(scopeToken)), + ...bAdditionalDeclarations.map((declaration) => declaration()) + ); + + program.body.push(tmplAssignmentBlock(b.identifier('tmpl'))); +} diff --git a/packages/@lwc/ssr-compiler/src/compile-js/stylesheets.ts b/packages/@lwc/ssr-compiler/src/compile-js/stylesheets.ts index d6a9669769..32a082326c 100644 --- a/packages/@lwc/ssr-compiler/src/compile-js/stylesheets.ts +++ b/packages/@lwc/ssr-compiler/src/compile-js/stylesheets.ts @@ -9,13 +9,17 @@ import { builders as b, is } from 'estree-toolkit'; import { esTemplate } from '../estemplate'; import type { NodePath } from 'estree-toolkit'; -import type { Program, ImportDeclaration } from 'estree'; +import type { ImportDeclaration } from 'estree'; import type { ComponentMetaState } from './types'; const bDefaultStyleImport = esTemplate` import defaultStylesheets from '${is.literal}'; `; +const bDefaultScopedStyleImport = esTemplate` + import defaultScopedStylesheets from '${is.literal}'; +`; + export function catalogStyleImport(path: NodePath, state: ComponentMetaState) { const specifier = path.node!.specifiers[0]; @@ -32,26 +36,19 @@ export function catalogStyleImport(path: NodePath, state: Com state.cssExplicitImports.set(specifier.local.name, path.node!.source.value); } -const componentNamePattern = /(?[^/]+)\.[tj]s$/; - /** * This adds implicit style imports to the compiled component artifact. */ -export function addStylesheetImports(ast: Program, state: ComponentMetaState, filepath: string) { - const componentName = componentNamePattern.exec(filepath)?.groups?.componentName; - if (!componentName) { - throw new Error(`Could not determine component name from file path: ${filepath}`); - } - - if (state.cssExplicitImports || state.staticStylesheetIds) { - throw new Error( - `Unimplemented static stylesheets, but found:\n${[...state.cssExplicitImports!].join( - ' \n' - )}` - ); +export function getStylesheetImports(filepath: string) { + const moduleName = /(?[^/]+)\.html$/.exec(filepath)?.groups?.moduleName; + if (!moduleName) { + throw new Error(`Could not determine module name from file path: ${filepath}`); } - ast.body.unshift(bDefaultStyleImport(b.literal(`./${componentName}.css`))); + return [ + bDefaultStyleImport(b.literal(`./${moduleName}.css`)), + bDefaultScopedStyleImport(b.literal(`./${moduleName}.scoped.css?scoped=true`)), + ]; } export function catalogStaticStylesheets(ids: string[], state: ComponentMetaState) { diff --git a/packages/@lwc/ssr-compiler/src/compile-template/element.ts b/packages/@lwc/ssr-compiler/src/compile-template/element.ts index 36ff15c8ef..e482a6c660 100644 --- a/packages/@lwc/ssr-compiler/src/compile-template/element.ts +++ b/packages/@lwc/ssr-compiler/src/compile-template/element.ts @@ -19,6 +19,7 @@ import type { Property as IrProperty, } from '@lwc/template-compiler'; import type { + BinaryExpression, BlockStatement as EsBlockStatement, Expression as EsExpression, Statement as EsStatement, @@ -28,31 +29,47 @@ import type { Transformer } from './types'; const bYield = (expr: EsExpression) => b.expressionStatement(b.yieldExpression(expr)); const bConditionalLiveYield = esTemplateWithYield` { + const prefix = (${/* isClass */ is.literal} && stylesheetScopeTokenClassPrefix) || ''; const attrOrPropValue = ${is.expression}; const valueType = typeof attrOrPropValue; if (attrOrPropValue && (valueType === 'string' || valueType === 'boolean')) { yield ' ' + ${is.literal}; if (valueType === 'string') { - yield '="' + htmlEscape(attrOrPropValue, true) + '"'; + yield \`="\${prefix}\${htmlEscape(attrOrPropValue, true)}"\`; } } } `; -function yieldAttrOrPropLiteralValue(name: string, valueNode: IrLiteral): EsStatement[] { +const bStringLiteralYield = esTemplateWithYield` + { + const prefix = (${/* isClass */ is.literal} && stylesheetScopeTokenClassPrefix) || ''; + yield ' ' + ${is.literal} + '="' + prefix + "${is.literal}" + '"' + } +`; + +function yieldAttrOrPropLiteralValue( + name: string, + valueNode: IrLiteral, + isClass: boolean +): EsStatement[] { const { value, type } = valueNode; if (typeof value === 'string') { const yieldedValue = name === 'style' ? cleanStyleAttrVal(value) : value; - return [bYield(b.literal(` ${name}="${yieldedValue}"`))]; + return [bStringLiteralYield(b.literal(isClass), b.literal(name), b.literal(yieldedValue))]; } else if (typeof value === 'boolean') { return [bYield(b.literal(` ${name}`))]; } throw new Error(`Unknown attr/prop literal: ${type}`); } -function yieldAttrOrPropLiveValue(name: string, value: IrExpression): EsStatement[] { +function yieldAttrOrPropLiveValue( + name: string, + value: IrExpression | BinaryExpression, + isClass: boolean +): EsStatement[] { const instanceMemberRef = b.memberExpression(b.identifier('instance'), value as EsExpression); - return [bConditionalLiveYield(instanceMemberRef, b.literal(name))]; + return [bConditionalLiveYield(b.literal(isClass), instanceMemberRef, b.literal(name))]; } function reorderAttributes( @@ -88,12 +105,21 @@ export const Element: Transformer = function Element(node, cxt): EsSt node.properties ); + let hasClassAttribute = false; const yieldAttrsAndProps = attrsAndProps.flatMap((attr) => { + const { name, value, type } = attr; + + // For classes, these may need to be prefixed with the scope token + const isClass = type === 'Attribute' && name === 'class'; + if (isClass) { + hasClassAttribute = true; + } + cxt.hoist(bImportHtmlEscape(), importHtmlEscapeKey); - if (attr.value.type === 'Literal') { - return yieldAttrOrPropLiteralValue(attr.name, attr.value); + if (value.type === 'Literal') { + return yieldAttrOrPropLiteralValue(name, value, isClass); } else { - return yieldAttrOrPropLiveValue(attr.name, attr.value); + return yieldAttrOrPropLiveValue(name, value, isClass); } }); @@ -103,6 +129,8 @@ export const Element: Transformer = function Element(node, cxt): EsSt return [ bYield(b.literal(`<${node.name}`)), + // If we haven't already prefixed the scope token to an existing class, add an explicit class here + ...(hasClassAttribute ? [] : [bYield(b.identifier('stylesheetScopeTokenClass'))]), ...yieldAttrsAndProps, bYield(b.literal(`>`)), ...irChildrenToEs(node.children, cxt), diff --git a/packages/@lwc/ssr-compiler/src/compile-template/index.ts b/packages/@lwc/ssr-compiler/src/compile-template/index.ts index 683db6a5b2..c41d3a1286 100644 --- a/packages/@lwc/ssr-compiler/src/compile-template/index.ts +++ b/packages/@lwc/ssr-compiler/src/compile-template/index.ts @@ -9,9 +9,10 @@ import { generate } from 'astring'; import { is, builders as b } from 'estree-toolkit'; import { parse } from '@lwc/template-compiler'; import { esTemplate } from '../estemplate'; -import { templateIrToEsTree } from './ir-to-es'; +import { getStylesheetImports } from '../compile-js/stylesheets'; +import { addScopeTokenDeclarations } from '../compile-js/stylesheet-scope-token'; import { optimizeAdjacentYieldStmts } from './shared'; - +import { templateIrToEsTree } from './ir-to-es'; import type { Node as EsNode, Statement as EsStatement, @@ -25,30 +26,34 @@ const bExportTemplate = esTemplate< EsExportDefaultDeclaration, [EsLiteral, EsStatement[], EsLiteral] >` - export default async function* tmpl(props, attrs, slotted, Cmp, instance, stylesheets) { + export default async function* tmpl(props, attrs, slotted, Cmp, instance) { if (!${isBool} && Cmp.renderMode !== 'light') { yield \`' + yield ''; } } `; -export default function compileTemplate(src: string, _filename: string) { +export default function compileTemplate(src: string, filename: string) { const { root, warnings } = parse(src); if (!root || warnings.length) { for (const warning of warnings) { @@ -79,6 +84,11 @@ export default function compileTemplate(src: string, _filename: string) { ]; const program = b.program(moduleBody, 'module'); + addScopeTokenDeclarations(program, filename); + + const stylesheetImports = getStylesheetImports(filename); + program.body.unshift(...stylesheetImports); + return { code: generate(program, {}), }; diff --git a/packages/@lwc/template-compiler/src/index.ts b/packages/@lwc/template-compiler/src/index.ts index 18f43b1164..375b03d7b9 100644 --- a/packages/@lwc/template-compiler/src/index.ts +++ b/packages/@lwc/template-compiler/src/index.ts @@ -24,6 +24,7 @@ export { CustomRendererConfig, CustomRendererElementConfig } from './shared/rend export { Config } from './config'; export { toPropertyName } from './shared/utils'; export { kebabcaseToCamelcase } from './shared/naming'; +export { generateScopeTokens } from './scopeTokens'; /** * Parses HTML markup into an AST diff --git a/packages/@lwc/template-compiler/src/scopeTokens.ts b/packages/@lwc/template-compiler/src/scopeTokens.ts index 3d117014e6..186bc1c238 100644 --- a/packages/@lwc/template-compiler/src/scopeTokens.ts +++ b/packages/@lwc/template-compiler/src/scopeTokens.ts @@ -46,12 +46,19 @@ export type scopeTokens = { cssScopeTokens: string[]; }; +/** + * Generate the scope tokens for a given component. Note that this API is NOT stable and should be + * considered internal to the LWC framework. + * @param filename - full filename, e.g. `path/to/x/foo/foo.js` + * @param namespace - namespace, e.g. 'x' for `x/foo/foo.js` + * @param componentName - component name, e.g. 'foo' for `x/foo/foo.js` + */ export function generateScopeTokens( filename: string, namespace: string | undefined, - name: string | undefined + componentName: string | undefined ): scopeTokens { - const uniqueToken = `${namespace}-${name}_${path.basename(filename, path.extname(filename))}`; + const uniqueToken = `${namespace}-${componentName}_${path.basename(filename, path.extname(filename))}`; // This scope token is all lowercase so that it works correctly in case-sensitive namespaces (e.g. SVG). // It is deliberately designed to discourage people from relying on it by appearing somewhat random.