Skip to content

Commit edf0d7c

Browse files
authored
fix(ssr-compiler): implement scoped styles and scope tokens (#4567)
1 parent 3ca1070 commit edf0d7c

File tree

8 files changed

+151
-44
lines changed

8 files changed

+151
-44
lines changed

packages/@lwc/ssr-compiler/src/compile-js/generate-markup.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,11 +30,12 @@ const bGenerateMarkup = esTemplate<ExportNamedDeclaration>`
3030
instance.__internal__setState(props, __REFLECTED_PROPS__, attrs);
3131
instance.isConnected = true;
3232
instance.connectedCallback?.();
33+
const tmplFn = ${isIdentOrRenderCall} ?? __fallbackTmpl;
3334
yield \`<\${tagName}\`;
35+
yield tmplFn.stylesheetScopeTokenHostClass;
3436
yield *__renderAttrs(attrs)
3537
yield '>';
36-
const tmplFn = ${isIdentOrRenderCall} ?? __fallbackTmpl;
37-
yield* tmplFn(props, attrs, slotted, ${is.identifier}, instance, defaultStylesheets);
38+
yield* tmplFn(props, attrs, slotted, ${is.identifier}, instance);
3839
yield \`</\${tagName}>\`;
3940
}
4041
`;

packages/@lwc/ssr-compiler/src/compile-js/index.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { AriaPropNameToAttrNameMap } from '@lwc/shared';
1212

1313
import { replaceLwcImport } from './lwc-import';
1414
import { catalogTmplImport } from './catalog-tmpls';
15-
import { addStylesheetImports, catalogStaticStylesheets, catalogStyleImport } from './stylesheets';
15+
import { catalogStaticStylesheets, catalogStyleImport } from './stylesheets';
1616
import { addGenerateMarkupExport } from './generate-markup';
1717

1818
import type { Identifier as EsIdentifier, Program as EsProgram } from 'estree';
@@ -145,8 +145,15 @@ export default function compileJS(src: string, filename: string) {
145145
};
146146
}
147147

148+
if (state.cssExplicitImports || state.staticStylesheetIds) {
149+
throw new Error(
150+
`Unimplemented static stylesheets, but found:\n${[...state.cssExplicitImports!].join(
151+
' \n'
152+
)}`
153+
);
154+
}
155+
148156
addGenerateMarkupExport(ast, state, filename);
149-
addStylesheetImports(ast, state, filename);
150157

151158
return {
152159
code: generate(ast, {}),
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { is } from 'estree-toolkit';
2+
import { generateScopeTokens } from '@lwc/template-compiler';
3+
import { builders as b } from 'estree-toolkit/dist/builders';
4+
import { esTemplate } from '../estemplate';
5+
import type { BlockStatement, ExportNamedDeclaration, Program, VariableDeclaration } from 'estree';
6+
7+
function generateStylesheetScopeToken(filename: string) {
8+
// FIXME: we should be getting the namespace/name from the config options,
9+
// since these actually come from the component filename, not the template filename.
10+
const split = filename.split('/');
11+
const namespace = split.at(-3)!;
12+
const baseName = split.at(-1)!;
13+
14+
const componentName = baseName.replace(/\.[^.]+$/, '');
15+
const {
16+
// FIXME: handle legacy scope token for older API versions
17+
scopeToken,
18+
} = generateScopeTokens(filename, namespace, componentName);
19+
20+
return scopeToken;
21+
}
22+
23+
const bStylesheetTokenDeclaration = esTemplate<VariableDeclaration>`
24+
const stylesheetScopeToken = '${is.literal}';
25+
`;
26+
27+
const bAdditionalDeclarations = [
28+
esTemplate<VariableDeclaration>`
29+
const hasScopedStylesheets = defaultScopedStylesheets && defaultScopedStylesheets.length > 0;
30+
`,
31+
esTemplate<ExportNamedDeclaration>`
32+
const stylesheetScopeTokenClass = hasScopedStylesheets ? \` class="\${stylesheetScopeToken}"\` : '';
33+
`,
34+
esTemplate<ExportNamedDeclaration>`
35+
const stylesheetScopeTokenHostClass = hasScopedStylesheets ? \` class="\${stylesheetScopeToken}-host"\` : '';
36+
`,
37+
esTemplate<ExportNamedDeclaration>`
38+
const stylesheetScopeTokenClassPrefix = hasScopedStylesheets ? (stylesheetScopeToken + ' ') : '';
39+
`,
40+
];
41+
42+
// Scope tokens are associated with a given template. This is assigned here so that it can be used in `generateMarkup`.
43+
const tmplAssignmentBlock = esTemplate<BlockStatement>`
44+
${is.identifier}.stylesheetScopeTokenHostClass = stylesheetScopeTokenHostClass;
45+
`;
46+
47+
export function addScopeTokenDeclarations(program: Program, filename: string) {
48+
const scopeToken = generateStylesheetScopeToken(filename);
49+
50+
program.body.unshift(
51+
bStylesheetTokenDeclaration(b.literal(scopeToken)),
52+
...bAdditionalDeclarations.map((declaration) => declaration())
53+
);
54+
55+
program.body.push(tmplAssignmentBlock(b.identifier('tmpl')));
56+
}

packages/@lwc/ssr-compiler/src/compile-js/stylesheets.ts

Lines changed: 13 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,17 @@ import { builders as b, is } from 'estree-toolkit';
99
import { esTemplate } from '../estemplate';
1010

1111
import type { NodePath } from 'estree-toolkit';
12-
import type { Program, ImportDeclaration } from 'estree';
12+
import type { ImportDeclaration } from 'estree';
1313
import type { ComponentMetaState } from './types';
1414

1515
const bDefaultStyleImport = esTemplate<ImportDeclaration>`
1616
import defaultStylesheets from '${is.literal}';
1717
`;
1818

19+
const bDefaultScopedStyleImport = esTemplate<ImportDeclaration>`
20+
import defaultScopedStylesheets from '${is.literal}';
21+
`;
22+
1923
export function catalogStyleImport(path: NodePath<ImportDeclaration>, state: ComponentMetaState) {
2024
const specifier = path.node!.specifiers[0];
2125

@@ -32,26 +36,19 @@ export function catalogStyleImport(path: NodePath<ImportDeclaration>, state: Com
3236
state.cssExplicitImports.set(specifier.local.name, path.node!.source.value);
3337
}
3438

35-
const componentNamePattern = /(?<componentName>[^/]+)\.[tj]s$/;
36-
3739
/**
3840
* This adds implicit style imports to the compiled component artifact.
3941
*/
40-
export function addStylesheetImports(ast: Program, state: ComponentMetaState, filepath: string) {
41-
const componentName = componentNamePattern.exec(filepath)?.groups?.componentName;
42-
if (!componentName) {
43-
throw new Error(`Could not determine component name from file path: ${filepath}`);
44-
}
45-
46-
if (state.cssExplicitImports || state.staticStylesheetIds) {
47-
throw new Error(
48-
`Unimplemented static stylesheets, but found:\n${[...state.cssExplicitImports!].join(
49-
' \n'
50-
)}`
51-
);
42+
export function getStylesheetImports(filepath: string) {
43+
const moduleName = /(?<moduleName>[^/]+)\.html$/.exec(filepath)?.groups?.moduleName;
44+
if (!moduleName) {
45+
throw new Error(`Could not determine module name from file path: ${filepath}`);
5246
}
5347

54-
ast.body.unshift(bDefaultStyleImport(b.literal(`./${componentName}.css`)));
48+
return [
49+
bDefaultStyleImport(b.literal(`./${moduleName}.css`)),
50+
bDefaultScopedStyleImport(b.literal(`./${moduleName}.scoped.css?scoped=true`)),
51+
];
5552
}
5653

5754
export function catalogStaticStylesheets(ids: string[], state: ComponentMetaState) {

packages/@lwc/ssr-compiler/src/compile-template/element.ts

Lines changed: 36 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import type {
1919
Property as IrProperty,
2020
} from '@lwc/template-compiler';
2121
import type {
22+
BinaryExpression,
2223
BlockStatement as EsBlockStatement,
2324
Expression as EsExpression,
2425
Statement as EsStatement,
@@ -28,31 +29,47 @@ import type { Transformer } from './types';
2829
const bYield = (expr: EsExpression) => b.expressionStatement(b.yieldExpression(expr));
2930
const bConditionalLiveYield = esTemplateWithYield<EsBlockStatement>`
3031
{
32+
const prefix = (${/* isClass */ is.literal} && stylesheetScopeTokenClassPrefix) || '';
3133
const attrOrPropValue = ${is.expression};
3234
const valueType = typeof attrOrPropValue;
3335
if (attrOrPropValue && (valueType === 'string' || valueType === 'boolean')) {
3436
yield ' ' + ${is.literal};
3537
if (valueType === 'string') {
36-
yield '="' + htmlEscape(attrOrPropValue, true) + '"';
38+
yield \`="\${prefix}\${htmlEscape(attrOrPropValue, true)}"\`;
3739
}
3840
}
3941
}
4042
`;
4143

42-
function yieldAttrOrPropLiteralValue(name: string, valueNode: IrLiteral): EsStatement[] {
44+
const bStringLiteralYield = esTemplateWithYield<EsBlockStatement>`
45+
{
46+
const prefix = (${/* isClass */ is.literal} && stylesheetScopeTokenClassPrefix) || '';
47+
yield ' ' + ${is.literal} + '="' + prefix + "${is.literal}" + '"'
48+
}
49+
`;
50+
51+
function yieldAttrOrPropLiteralValue(
52+
name: string,
53+
valueNode: IrLiteral,
54+
isClass: boolean
55+
): EsStatement[] {
4356
const { value, type } = valueNode;
4457
if (typeof value === 'string') {
4558
const yieldedValue = name === 'style' ? cleanStyleAttrVal(value) : value;
46-
return [bYield(b.literal(` ${name}="${yieldedValue}"`))];
59+
return [bStringLiteralYield(b.literal(isClass), b.literal(name), b.literal(yieldedValue))];
4760
} else if (typeof value === 'boolean') {
4861
return [bYield(b.literal(` ${name}`))];
4962
}
5063
throw new Error(`Unknown attr/prop literal: ${type}`);
5164
}
5265

53-
function yieldAttrOrPropLiveValue(name: string, value: IrExpression): EsStatement[] {
66+
function yieldAttrOrPropLiveValue(
67+
name: string,
68+
value: IrExpression | BinaryExpression,
69+
isClass: boolean
70+
): EsStatement[] {
5471
const instanceMemberRef = b.memberExpression(b.identifier('instance'), value as EsExpression);
55-
return [bConditionalLiveYield(instanceMemberRef, b.literal(name))];
72+
return [bConditionalLiveYield(b.literal(isClass), instanceMemberRef, b.literal(name))];
5673
}
5774

5875
function reorderAttributes(
@@ -88,12 +105,21 @@ export const Element: Transformer<IrElement> = function Element(node, cxt): EsSt
88105
node.properties
89106
);
90107

108+
let hasClassAttribute = false;
91109
const yieldAttrsAndProps = attrsAndProps.flatMap((attr) => {
110+
const { name, value, type } = attr;
111+
112+
// For classes, these may need to be prefixed with the scope token
113+
const isClass = type === 'Attribute' && name === 'class';
114+
if (isClass) {
115+
hasClassAttribute = true;
116+
}
117+
92118
cxt.hoist(bImportHtmlEscape(), importHtmlEscapeKey);
93-
if (attr.value.type === 'Literal') {
94-
return yieldAttrOrPropLiteralValue(attr.name, attr.value);
119+
if (value.type === 'Literal') {
120+
return yieldAttrOrPropLiteralValue(name, value, isClass);
95121
} else {
96-
return yieldAttrOrPropLiveValue(attr.name, attr.value);
122+
return yieldAttrOrPropLiveValue(name, value, isClass);
97123
}
98124
});
99125

@@ -103,6 +129,8 @@ export const Element: Transformer<IrElement> = function Element(node, cxt): EsSt
103129

104130
return [
105131
bYield(b.literal(`<${node.name}`)),
132+
// If we haven't already prefixed the scope token to an existing class, add an explicit class here
133+
...(hasClassAttribute ? [] : [bYield(b.identifier('stylesheetScopeTokenClass'))]),
106134
...yieldAttrsAndProps,
107135
bYield(b.literal(`>`)),
108136
...irChildrenToEs(node.children, cxt),

packages/@lwc/ssr-compiler/src/compile-template/index.ts

Lines changed: 24 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,10 @@ import { generate } from 'astring';
99
import { is, builders as b } from 'estree-toolkit';
1010
import { parse } from '@lwc/template-compiler';
1111
import { esTemplate } from '../estemplate';
12-
import { templateIrToEsTree } from './ir-to-es';
12+
import { getStylesheetImports } from '../compile-js/stylesheets';
13+
import { addScopeTokenDeclarations } from '../compile-js/stylesheet-scope-token';
1314
import { optimizeAdjacentYieldStmts } from './shared';
14-
15+
import { templateIrToEsTree } from './ir-to-es';
1516
import type {
1617
Node as EsNode,
1718
Statement as EsStatement,
@@ -25,30 +26,34 @@ const bExportTemplate = esTemplate<
2526
EsExportDefaultDeclaration,
2627
[EsLiteral, EsStatement[], EsLiteral]
2728
>`
28-
export default async function* tmpl(props, attrs, slotted, Cmp, instance, stylesheets) {
29+
export default async function* tmpl(props, attrs, slotted, Cmp, instance) {
2930
if (!${isBool} && Cmp.renderMode !== 'light') {
3031
yield \`<template shadowrootmode="open"\${Cmp.delegatesFocus ? ' shadowrootdelegatesfocus' : ''}>\`
3132
}
32-
33-
for (const stylesheet of stylesheets ?? []) {
34-
// TODO
35-
const token = null;
36-
const useActualHostSelector = true;
37-
const useNativeDirPseudoclass = null;
38-
yield '<style type="text/css">';
39-
yield stylesheet(token, useActualHostSelector, useNativeDirPseudoclass);
40-
yield '</style>';
33+
34+
if (defaultStylesheets || defaultScopedStylesheets) {
35+
// Flatten all stylesheets infinitely and concatenate
36+
const stylesheets = [defaultStylesheets, defaultScopedStylesheets].filter(Boolean).flat(Infinity);
37+
38+
for (const stylesheet of stylesheets) {
39+
const token = stylesheet.$scoped$ ? stylesheetScopeToken : undefined;
40+
const useActualHostSelector = !stylesheet.$scoped$ || Cmp.renderMode !== 'light';
41+
const useNativeDirPseudoclass = true;
42+
yield '<style' + stylesheetScopeTokenClass + ' type="text/css">';
43+
yield stylesheet(token, useActualHostSelector, useNativeDirPseudoclass);
44+
yield '</style>';
45+
}
4146
}
4247
4348
${is.statement};
4449
4550
if (!${isBool} && Cmp.renderMode !== 'light') {
46-
yield '</template>'
51+
yield '</template>';
4752
}
4853
}
4954
`;
5055

51-
export default function compileTemplate(src: string, _filename: string) {
56+
export default function compileTemplate(src: string, filename: string) {
5257
const { root, warnings } = parse(src);
5358
if (!root || warnings.length) {
5459
for (const warning of warnings) {
@@ -79,6 +84,11 @@ export default function compileTemplate(src: string, _filename: string) {
7984
];
8085
const program = b.program(moduleBody, 'module');
8186

87+
addScopeTokenDeclarations(program, filename);
88+
89+
const stylesheetImports = getStylesheetImports(filename);
90+
program.body.unshift(...stylesheetImports);
91+
8292
return {
8393
code: generate(program, {}),
8494
};

packages/@lwc/template-compiler/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export { CustomRendererConfig, CustomRendererElementConfig } from './shared/rend
2424
export { Config } from './config';
2525
export { toPropertyName } from './shared/utils';
2626
export { kebabcaseToCamelcase } from './shared/naming';
27+
export { generateScopeTokens } from './scopeTokens';
2728

2829
/**
2930
* Parses HTML markup into an AST

packages/@lwc/template-compiler/src/scopeTokens.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,12 +46,19 @@ export type scopeTokens = {
4646
cssScopeTokens: string[];
4747
};
4848

49+
/**
50+
* Generate the scope tokens for a given component. Note that this API is NOT stable and should be
51+
* considered internal to the LWC framework.
52+
* @param filename - full filename, e.g. `path/to/x/foo/foo.js`
53+
* @param namespace - namespace, e.g. 'x' for `x/foo/foo.js`
54+
* @param componentName - component name, e.g. 'foo' for `x/foo/foo.js`
55+
*/
4956
export function generateScopeTokens(
5057
filename: string,
5158
namespace: string | undefined,
52-
name: string | undefined
59+
componentName: string | undefined
5360
): scopeTokens {
54-
const uniqueToken = `${namespace}-${name}_${path.basename(filename, path.extname(filename))}`;
61+
const uniqueToken = `${namespace}-${componentName}_${path.basename(filename, path.extname(filename))}`;
5562

5663
// This scope token is all lowercase so that it works correctly in case-sensitive namespaces (e.g. SVG).
5764
// It is deliberately designed to discourage people from relying on it by appearing somewhat random.

0 commit comments

Comments
 (0)