Skip to content

Commit 5935426

Browse files
nolanlawsonwjhsf
andauthored
fix(ssr): add experimentalDynamicComponent aka dynamic imports (#5033)
Co-authored-by: Will Harney <[email protected]>
1 parent fda89ed commit 5935426

File tree

14 files changed

+235
-52
lines changed

14 files changed

+235
-52
lines changed

packages/@lwc/compiler/src/options.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,9 +100,10 @@ export interface TransformOptions {
100100
namespace?: string;
101101
/** @deprecated Ignored by compiler. */
102102
stylesheetConfig?: StylesheetConfig;
103-
// TODO [#3331]: deprecate / rename this compiler option in 246
103+
// TODO [#5031]: Unify dynamicImports and experimentalDynamicComponent options
104104
/** Config applied in usage of dynamic import statements in javascript */
105105
experimentalDynamicComponent?: DynamicImportConfig;
106+
// TODO [#3331]: deprecate and remove lwc:dynamic
106107
/** Flag to enable usage of dynamic component(lwc:dynamic) directive in HTML template */
107108
experimentalDynamicDirective?: boolean;
108109
/** Flag to enable usage of dynamic component(lwc:is) directive in HTML template */

packages/@lwc/compiler/src/transformers/javascript.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ export default function scriptTransform(
3333
): TransformResult {
3434
const {
3535
isExplicitImport,
36+
// TODO [#5031]: Unify dynamicImports and experimentalDynamicComponent options
3637
experimentalDynamicComponent: dynamicImports,
3738
outputConfig: { sourcemap },
3839
enableLightningWebSecurityTransforms,

packages/@lwc/rollup-plugin/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,10 @@ export interface RollupLwcOptions {
3838
stylesheetConfig?: StylesheetConfig;
3939
/** The configuration to pass to the `@lwc/template-compiler`. */
4040
preserveHtmlComments?: boolean;
41+
// TODO [#5031]: Unify dynamicImports and experimentalDynamicComponent options
4142
/** The configuration to pass to `@lwc/compiler`. */
4243
experimentalDynamicComponent?: DynamicImportConfig;
44+
// TODO [#3331]: deprecate and remove lwc:dynamic
4345
/** The configuration to pass to `@lwc/template-compiler`. */
4446
experimentalDynamicDirective?: boolean;
4547
/** The configuration to pass to `@lwc/template-compiler`. */

packages/@lwc/ssr-compiler/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@
5858
"meriyah": "^5.0.0"
5959
},
6060
"devDependencies": {
61+
"@lwc/babel-plugin-component": "8.12.0",
6162
"@types/estree": "^1.0.6"
6263
}
6364
}
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import path from 'node:path';
2+
import { beforeAll, describe, expect, test } from 'vitest';
3+
import { init, parse } from 'es-module-lexer';
4+
import { compileComponentForSSR } from '../index';
5+
6+
describe('dynamic imports', () => {
7+
type CompileOptions = {
8+
strictSpecifier: boolean;
9+
loader: undefined | string;
10+
isStrict: boolean;
11+
};
12+
13+
beforeAll(async () => {
14+
await init;
15+
});
16+
17+
// Generate all possible combinations of options
18+
const combinations = [false, true]
19+
.map((strictSpecifier) =>
20+
[undefined, 'myLoader'].map((loader) =>
21+
[false, true].map((isStrict) => ({
22+
strictSpecifier,
23+
loader,
24+
isStrict,
25+
}))
26+
)
27+
)
28+
.flat(Infinity) as Array<CompileOptions>;
29+
30+
test.each(combinations)(
31+
'strictSpecifier=$strictSpecifier, loader=$loader, isStrict=$isStrict',
32+
({ strictSpecifier, loader, isStrict }: CompileOptions) => {
33+
const source = `
34+
import { LightningElement } from 'lwc';
35+
export default class extends LightningElement {}
36+
export default async function rando () {
37+
await import(${isStrict ? '"x/foo"' : 'woohoo'});
38+
}
39+
`;
40+
const filename = path.resolve('component.js');
41+
let code;
42+
43+
const callback = () => {
44+
code = compileComponentForSSR(source, filename, {
45+
experimentalDynamicComponent: {
46+
loader,
47+
strictSpecifier,
48+
},
49+
}).code;
50+
};
51+
52+
if (strictSpecifier && !isStrict) {
53+
expect(callback).toThrowError(/INVALID_DYNAMIC_IMPORT_SOURCE_STRICT/);
54+
return;
55+
} else {
56+
callback();
57+
}
58+
59+
const imports = parse(code!)[0];
60+
61+
const importsWithLoader = expect.arrayContaining([
62+
expect.objectContaining({
63+
n: 'myLoader',
64+
}),
65+
]);
66+
67+
if (loader) {
68+
expect(imports).toEqual(importsWithLoader);
69+
} else {
70+
expect(imports).not.toEqual(importsWithLoader);
71+
}
72+
}
73+
);
74+
75+
test('imports are hoisted only once', () => {
76+
const source = `
77+
import { LightningElement } from 'lwc';
78+
export default class extends LightningElement {}
79+
export default async function rando () {
80+
await import('x/foo');
81+
await import('x/bar');
82+
await import('x/baz');
83+
}
84+
`;
85+
const filename = path.resolve('component.js');
86+
const { code } = compileComponentForSSR(source, filename, {
87+
experimentalDynamicComponent: {
88+
loader: 'myLoader',
89+
strictSpecifier: true,
90+
},
91+
});
92+
93+
const imports = parse(code!)[0];
94+
95+
expect(imports).toEqual(
96+
expect.arrayContaining([
97+
expect.objectContaining({
98+
n: 'myLoader',
99+
}),
100+
])
101+
);
102+
103+
// Validate that there is exactly one import of the loader
104+
expect(imports.filter((_) => _.n === 'myLoader')).toHaveLength(1);
105+
106+
expect(code).toContain(`x/foo`);
107+
expect(code).toContain(`x/bar`);
108+
expect(code).toContain(`x/baz`);
109+
});
110+
});

packages/@lwc/ssr-compiler/src/__tests__/fixtures.spec.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,10 @@ async function compileFixture({ input, dirname }: { input: string; dirname: stri
5555
// TODO [#3331]: remove usage of lwc:dynamic in 246
5656
experimentalDynamicDirective: true,
5757
modules: [{ dir: modulesDir }],
58+
experimentalDynamicComponent: {
59+
loader: path.join(__dirname, './utils/custom-loader.js'),
60+
strictSpecifier: false,
61+
},
5862
}),
5963
],
6064
onwarn({ message, code }) {
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export function load() {
2+
return Promise.resolve('stub');
3+
}

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

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,15 @@ import { traverse, builders as b, is } from 'estree-toolkit';
1010
import { parseModule } from 'meriyah';
1111

1212
import { transmogrify } from '../transmogrify';
13+
import { ImportManager } from '../imports';
1314
import { replaceLwcImport } from './lwc-import';
1415
import { catalogTmplImport } from './catalog-tmpls';
1516
import { catalogStaticStylesheets, catalogAndReplaceStyleImports } from './stylesheets';
1617
import { addGenerateMarkupFunction } from './generate-markup';
1718
import { catalogWireAdapters } from './wire';
1819

1920
import { removeDecoratorImport } from './remove-decorator-import';
21+
import type { ComponentTransformOptions } from '../shared';
2022
import type { Identifier as EsIdentifier, Program as EsProgram } from 'estree';
2123
import type { Visitors, ComponentMetaState } from './types';
2224
import type { CompilationMode } from '@lwc/shared';
@@ -33,13 +35,28 @@ const visitors: Visitors = {
3335
catalogAndReplaceStyleImports(path, state);
3436
removeDecoratorImport(path);
3537
},
36-
ImportExpression(path) {
37-
return path.replaceWith(
38-
b.callExpression(
39-
b.memberExpression(b.identifier('Promise'), b.identifier('resolve')),
40-
[]
41-
)
42-
);
38+
ImportExpression(path, state) {
39+
const { experimentalDynamicComponent, importManager } = state;
40+
if (!experimentalDynamicComponent) {
41+
// if no `experimentalDynamicComponent` config, then leave dynamic `import()`s as-is
42+
return;
43+
}
44+
if (experimentalDynamicComponent.strictSpecifier) {
45+
if (!is.literal(path.node?.source) || typeof path.node.source.value !== 'string') {
46+
// TODO [#5032]: Harmonize errors thrown in `@lwc/ssr-compiler`
47+
throw new Error('todo - LWCClassErrors.INVALID_DYNAMIC_IMPORT_SOURCE_STRICT');
48+
}
49+
}
50+
const loader = experimentalDynamicComponent.loader;
51+
if (!loader) {
52+
// if no `loader` defined, then leave dynamic `import()`s as-is
53+
return;
54+
}
55+
const source = path.node!.source!;
56+
// 1. insert `import { load as __load } from '${loader}'` at top of program
57+
importManager.add({ load: '__load' }, loader);
58+
// 2. replace this import with `__load(${source})`
59+
path.replaceWith(b.callExpression(b.identifier('__load'), [structuredClone(source)]));
4360
},
4461
ClassDeclaration(path, state) {
4562
const { node } = path;
@@ -171,12 +188,22 @@ const visitors: Visitors = {
171188
path.parentPath.node.arguments = [b.identifier('propsAvailableAtConstruction')];
172189
}
173190
},
191+
Program: {
192+
leave(path, state) {
193+
// After parsing the whole tree, insert needed imports
194+
const importDeclarations = state.importManager.getImportDeclarations();
195+
if (importDeclarations.length > 0) {
196+
path.node?.body.unshift(...importDeclarations);
197+
}
198+
},
199+
},
174200
};
175201

176202
export default function compileJS(
177203
src: string,
178204
filename: string,
179205
tagName: string,
206+
options: ComponentTransformOptions,
180207
compilationMode: CompilationMode
181208
) {
182209
let ast = parseModule(src, {
@@ -200,6 +227,8 @@ export default function compileJS(
200227
publicFields: [],
201228
privateFields: [],
202229
wireAdapters: [],
230+
experimentalDynamicComponent: options.experimentalDynamicComponent,
231+
importManager: new ImportManager(),
203232
};
204233

205234
traverse(ast, visitors, state);

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT
66
*/
77

8+
import type { ImportManager } from '../imports';
9+
import type { ComponentTransformOptions } from '../shared';
810
import type { traverse } from 'estree-toolkit';
911
import type {
1012
Identifier,
@@ -54,4 +56,8 @@ export interface ComponentMetaState {
5456
privateFields: Array<string>;
5557
// indicates whether the LightningElement has any wired props
5658
wireAdapters: WireAdapter[];
59+
// dynamic imports configuration
60+
experimentalDynamicComponent: ComponentTransformOptions['experimentalDynamicComponent'];
61+
// imports to add to the top of the program after parsing
62+
importManager: ImportManager;
5763
}

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,22 +42,26 @@ function getWireParams(
4242
const { decorators } = node;
4343

4444
if (decorators.length > 1) {
45+
// TODO [#5032]: Harmonize errors thrown in `@lwc/ssr-compiler`
4546
throw new Error('todo - multiple decorators at once');
4647
}
4748

4849
// validate the parameters
4950
const wireDecorator = decorators[0].expression;
5051
if (!is.callExpression(wireDecorator)) {
52+
// TODO [#5032]: Harmonize errors thrown in `@lwc/ssr-compiler`
5153
throw new Error('todo - invalid usage');
5254
}
5355

5456
const args = wireDecorator.arguments;
5557
if (args.length === 0 || args.length > 2) {
58+
// TODO [#5032]: Harmonize errors thrown in `@lwc/ssr-compiler`
5659
throw new Error('todo - wrong number of args');
5760
}
5861

5962
const [id, config] = args;
6063
if (is.spreadElement(id) || is.spreadElement(config)) {
64+
// TODO [#5032]: Harmonize errors thrown in `@lwc/ssr-compiler`
6165
throw new Error('todo - spread in params');
6266
}
6367
return [id, config];
@@ -72,20 +76,24 @@ function validateWireId(
7276

7377
if (is.memberExpression(id)) {
7478
if (id.computed) {
79+
// TODO [#5032]: Harmonize errors thrown in `@lwc/ssr-compiler`
7580
throw new Error('todo - FUNCTION_IDENTIFIER_CANNOT_HAVE_COMPUTED_PROPS');
7681
}
7782
if (!is.identifier(id.object)) {
83+
// TODO [#5032]: Harmonize errors thrown in `@lwc/ssr-compiler`
7884
throw new Error('todo - FUNCTION_IDENTIFIER_CANNOT_HAVE_NESTED_MEMBER_EXRESSIONS');
7985
}
8086
wireAdapterVar = id.object.name;
8187
} else if (!is.identifier(id)) {
88+
// TODO [#5032]: Harmonize errors thrown in `@lwc/ssr-compiler`
8289
throw new Error('todo - invalid adapter name');
8390
} else {
8491
wireAdapterVar = id.name;
8592
}
8693

8794
// This is not the exact same validation done in @lwc/babel-plugin-component but it accomplishes the same thing
8895
if (path.scope?.getBinding(wireAdapterVar)?.kind !== 'module') {
96+
// TODO [#5032]: Harmonize errors thrown in `@lwc/ssr-compiler`
8997
throw new Error('todo - WIRE_ADAPTER_SHOULD_BE_IMPORTED');
9098
}
9199
}
@@ -95,6 +103,7 @@ function validateWireConfig(
95103
path: NodePath<PropertyDefinition | MethodDefinition>
96104
): asserts config is NoSpreadObjectExpression {
97105
if (!is.objectExpression(config)) {
106+
// TODO [#5032]: Harmonize errors thrown in `@lwc/ssr-compiler`
98107
throw new Error('todo - CONFIG_OBJECT_SHOULD_BE_SECOND_PARAMETER');
99108
}
100109
for (const property of config.properties) {
@@ -113,12 +122,14 @@ function validateWireConfig(
113122
if (is.templateLiteral(key)) {
114123
// A template literal is not guaranteed to always result in the same value
115124
// (e.g. `${Math.random()}`), so we disallow them entirely.
125+
// TODO [#5032]: Harmonize errors thrown in `@lwc/ssr-compiler`
116126
throw new Error('todo - COMPUTED_PROPERTY_CANNOT_BE_TEMPLATE_LITERAL');
117127
} else if (!('regex' in key)) {
118128
// A literal can be a regexp, template literal, or primitive; only allow primitives
119129
continue;
120130
}
121131
}
132+
// TODO [#5032]: Harmonize errors thrown in `@lwc/ssr-compiler`
122133
throw new Error('todo - COMPUTED_PROPERTY_MUST_BE_CONSTANT_OR_LITERAL');
123134
}
124135
}

0 commit comments

Comments
 (0)