Skip to content
Merged
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
12 changes: 12 additions & 0 deletions .changeset/fix-duplicate-loadscript-calls.md
Original file line number Diff line number Diff line change
@@ -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.
22 changes: 22 additions & 0 deletions packages/webpack/externals-loading-webpack-plugin/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>();

for (const [pkgName, external] of finalExternals) {
const {
Expand Down Expand Up @@ -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);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

if (async) {
loadCode.add(
`${mountVar} = ${mountVar} === undefined ? createLoadExternalAsync(handler${
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
});
Original file line number Diff line number Diff line change
@@ -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',
},
},
},
},
),
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/** @type {import("@lynx-js/test-tools").TConfigCaseConfig} */
module.exports = {
bundlePath: [
'main:main-thread.js',
'main:background.js',
],
};
Loading