Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/chatty-dryers-find.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@lynx-js/template-webpack-plugin": patch
"@lynx-js/react-webpack-plugin": patch
---

fix: deduplicate lazy bundles when the same file is imported via different paths
52 changes: 48 additions & 4 deletions packages/webpack/react-webpack-plugin/src/ReactWebpackPlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import * as fs from 'node:fs';
import { createRequire } from 'node:module';
import * as path from 'node:path';

import type { Chunk, Compilation, Compiler } from '@rspack/core';
import invariant from 'tiny-invariant';
Expand Down Expand Up @@ -313,13 +314,56 @@ class ReactWebpackPlugin {
});

// The react-transform will add `-react__${LAYER}` to the webpackChunkName.
// We replace it with an empty string here to make sure main-thread & background chunk match.
// We normalize the chunk name using the resolved module path to ensure
// that the same file imported via different paths produces a single bundle.
// See: https://github.com/lynx-family/lynx-stack/issues/455
hooks.asyncChunkName.tap(
this.constructor.name,
(chunkName) =>
chunkName
(chunkName, chunkGroup) => {
// First strip the layer suffix from the chunk name
const nameWithoutLayer = chunkName
?.replaceAll(`-react__background`, '')
?.replaceAll(`-react__main-thread`, ''),
?.replaceAll(`-react__main-thread`, '');

// Try to normalize using ChunkGroup origin information
// The origin contains the import request and the module that made the import
if (chunkGroup && chunkGroup.origins.length > 0) {
const origin = chunkGroup.origins[0];
// origin.module is the module that contains the import() statement
// origin.request is the original import string (e.g., './Foo.jsx')
if (origin?.module && origin.request) {
// Get the absolute path of the importing module directly from webpack
// The 'resource' property contains the resolved absolute path
const mod = origin.module as { resource?: string };
const importerPath = mod.resource;

if (!importerPath) {
return nameWithoutLayer;
}

// Only normalize relative imports (starting with . or ..)
if (
!origin.request.startsWith('./')
&& !origin.request.startsWith('../')
) {
return nameWithoutLayer;
}

// Resolve the import request relative to the importer's directory
const importerDir = path.dirname(importerPath);
const resolvedPath = path.resolve(importerDir, origin.request);

// Calculate relative path from project root (keep extension)
const rootContext = compilation.compiler.context;
const relativePath = path.relative(rootContext, resolvedPath);

return './' + relativePath;
}
}

// Fallback to the original behavior if no origin info available
return nameWithoutLayer;
},
);
});
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export function Foo() {
return 'Foo Component';
}

export default Foo;
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/// <reference types="vitest/globals" />

import { readdir, readFile } from 'node:fs/promises';
import { resolve } from 'node:path';

// Import using relative path from same directory
// This dynamic import triggers chunk creation for ./Foo.jsx
import('./Foo.jsx');

// Import using subdirectory importer (which uses ../Foo.jsx)
// This creates another import path to the same file
import { loadFooFromSubdir } from './subdir/importer.jsx';

it('should generate only ONE async bundle for Foo', async () => {
// Ensure loadFooFromSubdir is used to prevent tree-shaking
expect(loadFooFromSubdir).toBeDefined();

// The async bundles are generated in the 'async' folder relative to __dirname
const asyncDir = resolve(__dirname, 'async');
const asyncTemplates = await readdir(asyncDir);

// Filter to only .bundle files (exclude other artifacts)
const bundles = asyncTemplates.filter(f => f.endsWith('.bundle'));

// Key assertion: ./Foo.jsx and ../Foo.jsx should produce SINGLE bundle
// because they resolve to the same file
expect(bundles).toHaveLength(1);
});

it('should have correct imports in the compiled code', async () => {
// Verify that webpack code references both imports
const mainFile = await readFile(__filename, 'utf-8');

// The import statements should exist in the compiled code
expect(mainFile).toContain('./Foo.jsx');
expect(mainFile).toContain('./subdir/importer.jsx');
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import {
LynxEncodePlugin,
LynxTemplatePlugin,
} from '@lynx-js/template-webpack-plugin';

import { createConfig } from '../../../create-react-config.js';

const config = createConfig();

/** @type {import('@rspack/core').Configuration} */
export default {
context: __dirname,
...config,
output: {
...config.output,
chunkFilename: '.rspeedy/async/[name].js',
},
plugins: [
...config.plugins,
new LynxEncodePlugin(),
new LynxTemplatePlugin({
...LynxTemplatePlugin.defaultOptions,
chunks: ['main__main-thread', 'main__background'],
filename: 'main/template.js',
intermediate: '.rspeedy/main',
}),
],
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// Import using relative path going up one directory
const FooPromise = import('../Foo.jsx');

export async function loadFooFromSubdir() {
const { Foo } = await FooPromise;
return Foo();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/** @type {import("@lynx-js/test-tools").TConfigCaseConfig} */
module.exports = {
bundlePath: [
// We do not run main-thread.js since the async chunk has been modified by LynxTemplatePlugin.
// 'main__main-thread.js',
'main__background.js',
],
};
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,20 @@
// Licensed under the Apache License Version 2.0 that can be found in the
// LICENSE file in the root directory of this source tree.

import type { RuntimeModule } from 'webpack';
import type { Chunk, RuntimeModule } from 'webpack';

import { RuntimeGlobals } from '@lynx-js/webpack-runtime-globals';

type LynxAsyncChunksRuntimeModule = new(
getChunkName: (chunkName: string) => string,
getChunkName: (chunkName: string, chunk: Chunk) => string,
) => RuntimeModule;

export function createLynxAsyncChunksRuntimeModule(
webpack: typeof import('webpack'),
): LynxAsyncChunksRuntimeModule {
return class LynxAsyncChunksRuntimeModule extends webpack.RuntimeModule {
constructor(
public getChunkName: (chunkName: string) => string,
public getChunkName: (chunkName: string, chunk: Chunk) => string,
) {
super('Lynx async chunks', webpack.RuntimeModule.STAGE_ATTACH);
}
Expand All @@ -29,7 +29,7 @@ ${RuntimeGlobals.lynxAsyncChunkIds} = {${
Array.from(chunk.getAllAsyncChunks())
.filter(c => c.name !== null && c.name !== undefined)
.map(c => {
const filename = this.getChunkName(c.name!);
const filename = this.getChunkName(c.name!, c);

// Modified from https://github.com/webpack/webpack/blob/11449f02175f055a4540d76aa4478958c4cb297e/lib/runtime/GetChunkFilenameRuntimeModule.js#L154-L157
const chunkPath = compilation.getPath(filename, {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,9 +84,12 @@ export interface TemplateHooks {
/**
* Get the real name of an async chunk. The files with the same `asyncChunkName` will be placed in the same template.
*
* @param chunkName - The original chunk name from webpackChunkName comment
* @param chunkGroup - The ChunkGroup containing module information for path normalization
*
* @alpha
*/
asyncChunkName: SyncWaterfallHook<string>;
asyncChunkName: SyncWaterfallHook<[string, ChunkGroup | undefined]>;

/**
* Called before the encode process. Can be used to modify the encode options.
Expand Down Expand Up @@ -143,7 +146,7 @@ export interface TemplateHooks {
*/
function createLynxTemplatePluginHooks(): TemplateHooks {
return {
asyncChunkName: new SyncWaterfallHook(['pluginArgs']),
asyncChunkName: new SyncWaterfallHook(['chunkName', 'chunkGroup']),
beforeEncode: new AsyncSeriesWaterfallHook(['pluginArgs']),
encode: new AsyncSeriesBailHook(['pluginArgs']),
beforeEmit: new AsyncSeriesWaterfallHook(['pluginArgs']),
Expand Down Expand Up @@ -545,8 +548,12 @@ class LynxTemplatePluginImpl {

compilation.addRuntimeModule(
chunk,
new LynxAsyncChunksRuntimeModule((chunkName) => {
const filename = hooks.asyncChunkName.call(chunkName);
new LynxAsyncChunksRuntimeModule((chunkName, asyncChunk) => {
// Find the ChunkGroup for this chunk to pass to the hook
const chunkGroup = compilation.chunkGroups.find(
cg => cg.chunks.includes(asyncChunk) && cg.name === chunkName,
);
const filename = hooks.asyncChunkName.call(chunkName, chunkGroup);

return this.#getAsyncFilenameTemplate(filename);
}),
Expand Down Expand Up @@ -636,7 +643,7 @@ class LynxTemplatePluginImpl {
compilation.chunkGroups
.filter(cg => !cg.isInitial())
.filter(cg => cg.name !== null && cg.name !== undefined),
cg => hooks.asyncChunkName.call(cg.name!),
cg => hooks.asyncChunkName.call(cg.name!, cg),
);

LynxTemplatePluginImpl.#asyncChunkGroups.set(compilation, asyncChunkGroups);
Expand Down Expand Up @@ -676,7 +683,7 @@ class LynxTemplatePluginImpl {
// We use the chunk name(provided by `webpackChunkName`) as filename
chunkGroups
.filter(cg => cg.name !== null && cg.name !== undefined)
.map(cg => hooks.asyncChunkName.call(cg.name!));
.map(cg => hooks.asyncChunkName.call(cg.name!, cg));

const filename = Array.from(new Set(chunkNames)).join('_');

Expand Down