diff --git a/.changeset/fix-duplicate-loadscript-calls.md b/.changeset/fix-duplicate-loadscript-calls.md new file mode 100644 index 0000000000..b11d0a6c9b --- /dev/null +++ b/.changeset/fix-duplicate-loadscript-calls.md @@ -0,0 +1,12 @@ +--- +'@lynx-js/externals-loading-webpack-plugin': patch +--- + +fix: deduplicate `loadScript` calls for externals sharing the same (bundle, section) pair + +When multiple externals had different `libraryName` values but pointed to the same +bundle URL and section path, `createLoadExternalSync`/`createLoadExternalAsync` was +called once per external, causing `lynx.loadScript` to execute redundantly for the +same section. Now only the first external in each `(url, sectionPath)` group triggers +the load; subsequent externals in the group are assigned the already-loaded result +directly. diff --git a/packages/webpack/externals-loading-webpack-plugin/src/index.ts b/packages/webpack/externals-loading-webpack-plugin/src/index.ts index 13a18a6541..f9578183d2 100644 --- a/packages/webpack/externals-loading-webpack-plugin/src/index.ts +++ b/packages/webpack/externals-loading-webpack-plugin/src/index.ts @@ -428,6 +428,10 @@ function createLoadExternalSync(handler, sectionPath, timeout) { `; const hasUrlLibraryNamePairInjected = new Set(); + // Track which (urlKey, sectionPath) pairs have already generated a loadScript call. + // Maps to the mountVar of the first external that triggered the load, so subsequent + // externals sharing the same section can reuse the result without calling loadScript again. + const sectionLoadTracker = new Map(); for (const [pkgName, external] of finalExternals) { const { @@ -479,6 +483,24 @@ function createLoadExternalSync(handler, sectionPath, timeout) { externalsLoadingPluginOptions.globalObject, ) }[${JSON.stringify(libraryNameStr)}]`; + + // If another external already generated a loadScript call for this exact + // (bundle, section, async) triple, reuse its result instead of calling + // loadScript again. async is included in the key because sync and async + // externals resolve to different runtime shapes (plain value vs Promise), + // so they must not be merged even when they share the same section. + const sectionKey = `${urlKey}||${layerOptions.sectionPath}||${async}`; + const existingMountVar = sectionLoadTracker.get(sectionKey); + if (existingMountVar !== undefined) { + // Preserve any value the host may have pre-populated for this global, + // matching the same === undefined guard used on the primary load path. + loadCode.add( + `${mountVar} = ${mountVar} === undefined ? ${existingMountVar} : ${mountVar};`, + ); + continue; + } + sectionLoadTracker.set(sectionKey, mountVar); + if (async) { loadCode.add( `${mountVar} = ${mountVar} === undefined ? createLoadExternalAsync(handler${ diff --git a/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/merge-loadscript-calls/index.js b/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/merge-loadscript-calls/index.js new file mode 100644 index 0000000000..6e410e5ef3 --- /dev/null +++ b/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/merge-loadscript-calls/index.js @@ -0,0 +1,66 @@ +import { a } from 'pkg-a'; +import { b } from 'pkg-b'; +import { c } from 'pkg-c'; +import { f } from 'pkg-f'; + +const d = await import('pkg-d'); +const e = await import('pkg-e'); + +console.info(a, b, c, d, e, f); + +it('should call loadScript only once per (bundle, section) pair', async () => { + const fs = await import('node:fs'); + const path = await import('node:path'); + + const background = fs.readFileSync( + path.resolve(__dirname, 'main:background.js'), + 'utf-8', + ); + const mainThread = fs.readFileSync( + path.resolve(__dirname, 'main:main-thread.js'), + 'utf-8', + ); + + // Use concatenation to avoid the literal pattern appearing inside this compiled file itself. + + // pkg-a and pkg-b share the same (url, sectionPath). Only ONE createLoadExternalSync call + // should be generated for that pair; pkg-b reuses pkg-a's result directly. + // pkg-c has a different bundle URL, so it gets its own call. + // => Exactly one call per handler (2 total), not one per external (3 total). + + // Match actual invocations of the form `createLoadExternalSync/Async(handlerN, ...` + // The function definitions use `(handler,` (no digit), so they won't be counted here. + const syncH0 = 'createLoadExternalSync' + '(handler0,'; + const syncH1 = 'createLoadExternalSync' + '(handler1,'; + const syncH2 = 'createLoadExternalSync' + '(handler2,'; + const asyncH2 = 'createLoadExternalAsync' + '(handler2,'; + + // Sync-side: handler0 and handler1 each get exactly one Sync call (PkgB is merged into PkgA). + expect(background.split(syncH0).length).toBe(2); + expect(background.split(syncH1).length).toBe(2); + expect(mainThread.split(syncH0).length).toBe(2); + expect(mainThread.split(syncH1).length).toBe(2); + + // handler2 is shared by async pkg-d/pkg-e and sync pkg-f. The async group merges + // (PkgE reuses PkgD), so exactly one Async call. The sync pkg-f must NOT merge + // with the async group (different runtime shape), so exactly one Sync call on handler2. + expect(background.split(asyncH2).length).toBe(2); + expect(background.split(syncH2).length).toBe(2); + expect(mainThread.split(asyncH2).length).toBe(2); + expect(mainThread.split(syncH2).length).toBe(2); + + // PkgB reuses PkgA's already-loaded global — no createLoadExternal call. + // The === undefined guard is preserved so host-injected values are not overwritten. + const pkgBAssignment = '["PkgB"] === undefined ? ' + + 'lynx[Symbol.for(\'__LYNX_EXTERNAL_GLOBAL__\')]["PkgA"] : ' + + 'lynx[Symbol.for(\'__LYNX_EXTERNAL_GLOBAL__\')]["PkgB"]'; + expect(background).toContain(pkgBAssignment); + expect(mainThread).toContain(pkgBAssignment); + + // PkgE reuses PkgD (both async, same section) — no extra createLoadExternalAsync call. + const pkgEAssignment = '["PkgE"] === undefined ? ' + + 'lynx[Symbol.for(\'__LYNX_EXTERNAL_GLOBAL__\')]["PkgD"] : ' + + 'lynx[Symbol.for(\'__LYNX_EXTERNAL_GLOBAL__\')]["PkgE"]'; + expect(background).toContain(pkgEAssignment); + expect(mainThread).toContain(pkgEAssignment); +}); diff --git a/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/merge-loadscript-calls/rspack.config.js b/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/merge-loadscript-calls/rspack.config.js new file mode 100644 index 0000000000..6a40819ff3 --- /dev/null +++ b/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/merge-loadscript-calls/rspack.config.js @@ -0,0 +1,89 @@ +import { createConfig } from '../../../helpers/create-config.js'; + +/** @type {import('@rspack/core').Configuration} */ +export default { + context: __dirname, + ...createConfig( + { + backgroundLayer: 'background', + mainThreadLayer: 'main-thread', + externals: { + // Two different library names pointing to the same (url, sectionPath). + // Only one loadScript call should be generated per section. + 'pkg-a': { + libraryName: 'PkgA', + url: 'https://example.com/common.bundle', + async: false, + background: { + sectionPath: 'common', + }, + mainThread: { + sectionPath: 'common__main-thread', + }, + }, + 'pkg-b': { + libraryName: 'PkgB', + url: 'https://example.com/common.bundle', + async: false, + background: { + sectionPath: 'common', + }, + mainThread: { + sectionPath: 'common__main-thread', + }, + }, + // A third external with the same section but different bundle — should still + // generate its own loadScript call. + 'pkg-c': { + libraryName: 'PkgC', + url: 'https://example.com/other.bundle', + async: false, + background: { + sectionPath: 'common', + }, + mainThread: { + sectionPath: 'common__main-thread', + }, + }, + // Two async externals sharing the same (url, sectionPath). They should merge + // via createLoadExternalAsync. + 'pkg-d': { + libraryName: 'PkgD', + url: 'https://example.com/async.bundle', + async: true, + background: { + sectionPath: 'shared', + }, + mainThread: { + sectionPath: 'shared__main-thread', + }, + }, + 'pkg-e': { + libraryName: 'PkgE', + url: 'https://example.com/async.bundle', + async: true, + background: { + sectionPath: 'shared', + }, + mainThread: { + sectionPath: 'shared__main-thread', + }, + }, + // A sync external sharing the SAME (url, sectionPath) as the async ones above. + // Must NOT be merged with the async group because the runtime shape differs + // (plain value vs Promise). + 'pkg-f': { + libraryName: 'PkgF', + url: 'https://example.com/async.bundle', + async: false, + background: { + sectionPath: 'shared', + }, + mainThread: { + sectionPath: 'shared__main-thread', + }, + }, + }, + }, + ), +}; diff --git a/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/merge-loadscript-calls/test.config.cjs b/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/merge-loadscript-calls/test.config.cjs new file mode 100644 index 0000000000..2fa53abe09 --- /dev/null +++ b/packages/webpack/externals-loading-webpack-plugin/test/cases/externals-loading/merge-loadscript-calls/test.config.cjs @@ -0,0 +1,7 @@ +/** @type {import("@lynx-js/test-tools").TConfigCaseConfig} */ +module.exports = { + bundlePath: [ + 'main:main-thread.js', + 'main:background.js', + ], +};