Skip to content
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

fix(ssr): add experimentalDynamicComponent aka dynamic imports #5033

Merged
merged 14 commits into from
Dec 16, 2024
Merged
3 changes: 2 additions & 1 deletion packages/@lwc/compiler/src/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,9 +100,10 @@ export interface TransformOptions {
namespace?: string;
/** @deprecated Ignored by compiler. */
stylesheetConfig?: StylesheetConfig;
// TODO [#3331]: deprecate / rename this compiler option in 246
// TODO [#5031]: Unify dynamicImports and experimentalDynamicComponent options
/** Config applied in usage of dynamic import statements in javascript */
experimentalDynamicComponent?: DynamicImportConfig;
// TODO [#3331]: deprecate and remove lwc:dynamic
/** Flag to enable usage of dynamic component(lwc:dynamic) directive in HTML template */
experimentalDynamicDirective?: boolean;
/** Flag to enable usage of dynamic component(lwc:is) directive in HTML template */
Expand Down
1 change: 1 addition & 0 deletions packages/@lwc/compiler/src/transformers/javascript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export default function scriptTransform(
): TransformResult {
const {
isExplicitImport,
// TODO [#5031]: Unify dynamicImports and experimentalDynamicComponent options
experimentalDynamicComponent: dynamicImports,
outputConfig: { sourcemap },
enableLightningWebSecurityTransforms,
Expand Down
2 changes: 2 additions & 0 deletions packages/@lwc/rollup-plugin/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,10 @@ export interface RollupLwcOptions {
stylesheetConfig?: StylesheetConfig;
/** The configuration to pass to the `@lwc/template-compiler`. */
preserveHtmlComments?: boolean;
// TODO [#5031]: Unify dynamicImports and experimentalDynamicComponent options
/** The configuration to pass to `@lwc/compiler`. */
experimentalDynamicComponent?: DynamicImportConfig;
// TODO [#3331]: deprecate and remove lwc:dynamic
/** The configuration to pass to `@lwc/template-compiler`. */
experimentalDynamicDirective?: boolean;
/** The configuration to pass to `@lwc/template-compiler`. */
Expand Down
1 change: 1 addition & 0 deletions packages/@lwc/ssr-compiler/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
"meriyah": "^5.0.0"
},
"devDependencies": {
"@lwc/babel-plugin-component": "8.12.0",
"@types/estree": "^1.0.6"
}
}
110 changes: 110 additions & 0 deletions packages/@lwc/ssr-compiler/src/__tests__/dynamic-imports.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import path from 'node:path';
import { beforeAll, describe, expect, test } from 'vitest';
import { init, parse } from 'es-module-lexer';
import { compileComponentForSSR } from '../index';

describe('dynamic imports', () => {
type CompileOptions = {
strictSpecifier: boolean;
loader: undefined | string;
isStrict: boolean;
};

beforeAll(async () => {
await init;
});

// Generate all possible combinations of options
const combinations = [false, true]
.map((strictSpecifier) =>
[undefined, 'myLoader'].map((loader) =>
[false, true].map((isStrict) => ({
strictSpecifier,
loader,
isStrict,
}))
)
)
.flat(Infinity) as Array<CompileOptions>;

test.each(combinations)(
'strictSpecifier=$strictSpecifier, loader=$loader, isStrict=$isStrict',
({ strictSpecifier, loader, isStrict }: CompileOptions) => {
const source = `
import { LightningElement } from 'lwc';
export default class extends LightningElement {}
export default async function rando () {
await import(${isStrict ? '"x/foo"' : 'woohoo'});
}
`;
const filename = path.resolve('component.js');
let code;

const callback = () => {
code = compileComponentForSSR(source, filename, {
experimentalDynamicComponent: {
loader,
strictSpecifier,
},
}).code;
};

if (strictSpecifier && !isStrict) {
expect(callback).toThrowError(/INVALID_DYNAMIC_IMPORT_SOURCE_STRICT/);
return;
} else {
callback();
}

const imports = parse(code!)[0];

const importsWithLoader = expect.arrayContaining([
expect.objectContaining({
n: 'myLoader',
}),
]);

if (loader) {
expect(imports).toEqual(importsWithLoader);
} else {
expect(imports).not.toEqual(importsWithLoader);
}
}
);

test('imports are hoisted only once', () => {
const source = `
import { LightningElement } from 'lwc';
export default class extends LightningElement {}
export default async function rando () {
await import('x/foo');
await import('x/bar');
await import('x/baz');
}
`;
const filename = path.resolve('component.js');
const { code } = compileComponentForSSR(source, filename, {
experimentalDynamicComponent: {
loader: 'myLoader',
strictSpecifier: true,
},
});

const imports = parse(code!)[0];

expect(imports).toEqual(
expect.arrayContaining([
expect.objectContaining({
n: 'myLoader',
}),
])
);

// Validate that there is exactly one import of the loader
expect(imports.filter((_) => _.n === 'myLoader')).toHaveLength(1);
nolanlawson marked this conversation as resolved.
Show resolved Hide resolved

expect(code).toContain(`x/foo`);
expect(code).toContain(`x/bar`);
expect(code).toContain(`x/baz`);
});
});
4 changes: 4 additions & 0 deletions packages/@lwc/ssr-compiler/src/__tests__/fixtures.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,10 @@ async function compileFixture({ input, dirname }: { input: string; dirname: stri
// TODO [#3331]: remove usage of lwc:dynamic in 246
experimentalDynamicDirective: true,
modules: [{ dir: modulesDir }],
experimentalDynamicComponent: {
loader: path.join(__dirname, './utils/custom-loader.js'),
strictSpecifier: false,
},
}),
],
onwarn({ message, code }) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function load() {
return Promise.resolve('stub');
}
43 changes: 36 additions & 7 deletions packages/@lwc/ssr-compiler/src/compile-js/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,15 @@ import { traverse, builders as b, is } from 'estree-toolkit';
import { parseModule } from 'meriyah';

import { transmogrify } from '../transmogrify';
import { ImportManager } from '../imports';
import { replaceLwcImport } from './lwc-import';
import { catalogTmplImport } from './catalog-tmpls';
import { catalogStaticStylesheets, catalogAndReplaceStyleImports } from './stylesheets';
import { addGenerateMarkupFunction } from './generate-markup';
import { catalogWireAdapters } from './wire';

import { removeDecoratorImport } from './remove-decorator-import';
import type { ComponentTransformOptions } from '../shared';
import type { Identifier as EsIdentifier, Program as EsProgram } from 'estree';
import type { Visitors, ComponentMetaState } from './types';
import type { CompilationMode } from '@lwc/shared';
Expand All @@ -33,13 +35,28 @@ const visitors: Visitors = {
catalogAndReplaceStyleImports(path, state);
removeDecoratorImport(path);
},
ImportExpression(path) {
return path.replaceWith(
b.callExpression(
b.memberExpression(b.identifier('Promise'), b.identifier('resolve')),
[]
)
);
ImportExpression(path, state) {
const { experimentalDynamicComponent, importManager } = state;
if (!experimentalDynamicComponent) {
// if no `experimentalDynamicComponent` config, then leave dynamic `import()`s as-is
return;
nolanlawson marked this conversation as resolved.
Show resolved Hide resolved
}
if (experimentalDynamicComponent.strictSpecifier) {
if (!is.literal(path.node?.source) || typeof path.node.source.value !== 'string') {
// TODO [#5032]: Harmonize errors thrown in `@lwc/ssr-compiler`
throw new Error('todo - LWCClassErrors.INVALID_DYNAMIC_IMPORT_SOURCE_STRICT');
}
}
const loader = experimentalDynamicComponent.loader;
if (!loader) {
// if no `loader` defined, then leave dynamic `import()`s as-is
return;
nolanlawson marked this conversation as resolved.
Show resolved Hide resolved
}
const source = path.node!.source!;
// 1. insert `import { load as __load } from '${loader}'` at top of program
importManager.add({ load: '__load' }, loader);
// 2. replace this import with `__load(${source})`
path.replaceWith(b.callExpression(b.identifier('__load'), [structuredClone(source)]));
},
ClassDeclaration(path, state) {
const { node } = path;
Expand Down Expand Up @@ -171,12 +188,22 @@ const visitors: Visitors = {
path.parentPath.node.arguments = [b.identifier('propsAvailableAtConstruction')];
}
},
Program: {
leave(path, state) {
// After parsing the whole tree, insert needed imports
const importDeclarations = state.importManager.getImportDeclarations();
nolanlawson marked this conversation as resolved.
Show resolved Hide resolved
if (importDeclarations.length > 0) {
path.node?.body.unshift(...importDeclarations);
}
},
},
};

export default function compileJS(
src: string,
filename: string,
tagName: string,
options: ComponentTransformOptions,
compilationMode: CompilationMode
) {
let ast = parseModule(src, {
Expand All @@ -200,6 +227,8 @@ export default function compileJS(
publicFields: [],
privateFields: [],
wireAdapters: [],
experimentalDynamicComponent: options.experimentalDynamicComponent,
importManager: new ImportManager(),
};

traverse(ast, visitors, state);
Expand Down
6 changes: 6 additions & 0 deletions packages/@lwc/ssr-compiler/src/compile-js/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT
*/

import type { ImportManager } from '../imports';
import type { ComponentTransformOptions } from '../shared';
import type { traverse } from 'estree-toolkit';
import type {
Identifier,
Expand Down Expand Up @@ -54,4 +56,8 @@ export interface ComponentMetaState {
privateFields: Array<string>;
// indicates whether the LightningElement has any wired props
wireAdapters: WireAdapter[];
// dynamic imports configuration
experimentalDynamicComponent: ComponentTransformOptions['experimentalDynamicComponent'];
// imports to add to the top of the program after parsing
importManager: ImportManager;
}
11 changes: 11 additions & 0 deletions packages/@lwc/ssr-compiler/src/compile-js/wire.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,22 +42,26 @@ function getWireParams(
const { decorators } = node;

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

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

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

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

if (is.memberExpression(id)) {
if (id.computed) {
// TODO [#5032]: Harmonize errors thrown in `@lwc/ssr-compiler`
throw new Error('todo - FUNCTION_IDENTIFIER_CANNOT_HAVE_COMPUTED_PROPS');
}
if (!is.identifier(id.object)) {
// TODO [#5032]: Harmonize errors thrown in `@lwc/ssr-compiler`
throw new Error('todo - FUNCTION_IDENTIFIER_CANNOT_HAVE_NESTED_MEMBER_EXRESSIONS');
}
wireAdapterVar = id.object.name;
} else if (!is.identifier(id)) {
// TODO [#5032]: Harmonize errors thrown in `@lwc/ssr-compiler`
throw new Error('todo - invalid adapter name');
} else {
wireAdapterVar = id.name;
}

// This is not the exact same validation done in @lwc/babel-plugin-component but it accomplishes the same thing
if (path.scope?.getBinding(wireAdapterVar)?.kind !== 'module') {
// TODO [#5032]: Harmonize errors thrown in `@lwc/ssr-compiler`
throw new Error('todo - WIRE_ADAPTER_SHOULD_BE_IMPORTED');
}
}
Expand All @@ -95,6 +103,7 @@ function validateWireConfig(
path: NodePath<PropertyDefinition | MethodDefinition>
): asserts config is NoSpreadObjectExpression {
if (!is.objectExpression(config)) {
// TODO [#5032]: Harmonize errors thrown in `@lwc/ssr-compiler`
throw new Error('todo - CONFIG_OBJECT_SHOULD_BE_SECOND_PARAMETER');
}
for (const property of config.properties) {
Expand All @@ -113,12 +122,14 @@ function validateWireConfig(
if (is.templateLiteral(key)) {
// A template literal is not guaranteed to always result in the same value
// (e.g. `${Math.random()}`), so we disallow them entirely.
// TODO [#5032]: Harmonize errors thrown in `@lwc/ssr-compiler`
throw new Error('todo - COMPUTED_PROPERTY_CANNOT_BE_TEMPLATE_LITERAL');
} else if (!('regex' in key)) {
// A literal can be a regexp, template literal, or primitive; only allow primitives
continue;
}
}
// TODO [#5032]: Harmonize errors thrown in `@lwc/ssr-compiler`
throw new Error('todo - COMPUTED_PROPERTY_MUST_BE_CONSTANT_OR_LITERAL');
}
}
Expand Down
Loading