Skip to content
Open
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/preserve-share-scopes-repeated-init.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@module-federation/webpack-bundler-runtime': patch
'@module-federation/runtime-core': patch
---

Preserve non-default share scopes on repeated container init. `resolveShareScope` falls back to the federation instance's existing `shareScopeMap` when the incoming host scope is missing or empty. `initShareScopeMap` merges incoming scopes with existing ones instead of replacing them to provide defense-in-depth against incomplete re-initialization data.
5 changes: 4 additions & 1 deletion packages/runtime-core/src/shared/disabled.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ export class DisabledSharedHandler {
scopeName: string,
shareScope: ShareScopeMap[string],
): void {
this.shareScopeMap[scopeName] = shareScope;
this.shareScopeMap[scopeName] = {
...this.shareScopeMap[scopeName],
...shareScope,
};
}
}
11 changes: 10 additions & 1 deletion packages/runtime-core/src/shared/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -650,7 +650,16 @@ export class SharedHandler {
extraOptions: { hostShareScopeMap?: ShareScopeMap } = {},
): void {
const { host } = this;
this.shareScopeMap[scopeName] = shareScope;
// Merge at scope level: preserves shared packages that were registered
// by previous init calls or by setShared() for this scope. A repeated
// container init with an empty or incomplete shareScope will no longer
// wipe out shared entries that were already resolved. When the incoming
// scope includes a package already present, the incoming version set
// replaces the existing one (the remote's view is authoritative).
this.shareScopeMap[scopeName] = {
...this.shareScopeMap[scopeName],
...shareScope,
};
this.hooks.lifecycle.initContainerShareScopeMap.emit({
shareScope,
options: host.options,
Expand Down
64 changes: 64 additions & 0 deletions packages/runtime/__tests__/shares.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -948,4 +948,68 @@ describe('load share while shared has multiple versions', () => {
assert(sharedRes, "sharedRes can't be null");
expect(sharedRes.version).toEqual('16.0.0');
});

it('should preserve existing shared packages on repeated initShareScopeMap with partial scope', () => {
const FM1 = new ModuleFederation({
name: '@shared/init-scope-merge',
shared: {
react: { singleton: true, version: '18.2.0' },
'@tanstack/react-query': { singleton: true, version: '5.0.0' },
},
});
FM1.registerPlugins();

FM1.initShareScopeMap('default', {
react: {
'18.2.0': {
scope: ['default'],
version: '18.2.0',
from: '@shared/init-scope-merge',
loaded: true,
},
},
});

expect(FM1.shareScopeMap['default']['react']).toBeDefined();

FM1.initShareScopeMap('default', {
'@tanstack/react-query': {
'5.0.0': {
scope: ['default'],
version: '5.0.0',
from: '@shared/init-scope-merge',
loaded: true,
},
},
});

// Both packages should exist after merge
expect(FM1.shareScopeMap['default']['react']).toBeDefined();
expect(FM1.shareScopeMap['default']['@tanstack/react-query']).toBeDefined();
});

it('should preserve existing shared packages on repeated initShareScopeMap with empty scope', () => {
const FM1 = new ModuleFederation({
name: '@shared/init-scope-merge-empty',
shared: {
react: { singleton: true, version: '18.2.0' },
},
});
FM1.registerPlugins();

FM1.initShareScopeMap('default', {
react: {
'18.2.0': {
scope: ['default'],
version: '18.2.0',
from: '@shared/init-scope-merge-empty',
loaded: true,
},
},
});

FM1.initShareScopeMap('default', {});

expect(FM1.shareScopeMap['default']['react']).toBeDefined();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ describe('initContainerEntry with array-based share scopes', () => {
});

// Execute
const result = initContainerEntry(mockOptions);
initContainerEntry(mockOptions);

// Verify
expect(
Expand Down Expand Up @@ -431,7 +431,7 @@ describe('initContainerEntry with array-based share scopes', () => {

test('should behave differently for proxyInitializeSharing=false vs true with array shareScopeKey', () => {
// Mock setup for shared=false (proxyInitializeSharing=false)
const mockIFunctionFalse = jest.fn().mockImplementation((key) => {
const mockIFunctionFalse = jest.fn().mockImplementation((_key) => {
return Promise.resolve(true);
});

Expand Down Expand Up @@ -491,7 +491,7 @@ describe('initContainerEntry with array-based share scopes', () => {

test('should handle proxyInitializeSharing=false with array shareScopeKey', async () => {
// Setup with shared: false (making proxyInitializeSharing false)
const mockIFunction = jest.fn().mockImplementation((key) => {
const mockIFunction = jest.fn().mockImplementation((_key) => {
return Promise.resolve(true);
});

Expand Down Expand Up @@ -1100,4 +1100,110 @@ describe('initContainerEntry with array-based share scopes', () => {
expect(mockIFunction).toHaveBeenCalledWith('key3', ['test-scope']);
expect(mockIFunction).toHaveBeenCalledTimes(3);
});

test('should preserve non-default share scopes across repeated init with incomplete host shareScopeMap', () => {
const defaultScope = {
react: {
'18.2.0': {
scope: ['default'],
},
},
};
const customScope = {
'@tanstack/react-query': {
'5.0.0': {
scope: ['custom'],
},
},
};
const shareScopeMap: Record<string, Record<string, any>> = {};
const federationInstance = createMockFederationInstance({
shareScopeMap,
initShareScopeMap: jest.fn(
(scopeName: string, scope: Record<string, any>) => {
shareScopeMap[scopeName] = {
...shareScopeMap[scopeName],
...scope,
};
},
),
});
const webpackRequire = createMockWebpackRequire({
I: jest.fn().mockReturnValue(Promise.resolve(true)),
federation: createMockFederation({
instance: federationInstance,
initOptions: {
name: 'test-app',
shared: false,
},
}),
});

initContainerEntry(
createMockOptions({
webpackRequire,
shareScopeKey: ['default', 'custom'],
shareScope: defaultScope,
remoteEntryInitOptions: createMockRemoteEntryInitOptions({
shareScopeKeys: ['default', 'custom'],
shareScopeMap: {
default: defaultScope,
custom: customScope,
},
}),
}),
);

expect(federationInstance.shareScopeMap.custom).toEqual(customScope);

// Isolation: default must not be polluted with custom-scope-only packages
expect(Object.keys(federationInstance.shareScopeMap.default)).toContain(
'react',
);
expect(Object.keys(federationInstance.shareScopeMap.default)).not.toContain(
'@tanstack/react-query',
);

const emptyCustomScopeInitOptions = createMockRemoteEntryInitOptions({
shareScopeKeys: ['default', 'custom'],
shareScopeMap: {
default: defaultScope,
custom: {},
},
});

initContainerEntry(
createMockOptions({
webpackRequire,
shareScopeKey: ['default', 'custom'],
shareScope: defaultScope,
remoteEntryInitOptions: emptyCustomScopeInitOptions,
}),
);

expect(federationInstance.shareScopeMap.custom).toEqual(customScope);

const missingCustomScopeInitOptions = createMockRemoteEntryInitOptions({
shareScopeKeys: ['default', 'custom'],
shareScopeMap: {
default: defaultScope,
},
});

initContainerEntry(
createMockOptions({
webpackRequire,
shareScopeKey: ['default', 'custom'],
shareScope: defaultScope,
remoteEntryInitOptions: missingCustomScopeInitOptions,
}),
);

expect(federationInstance.shareScopeMap.custom).toEqual(customScope);

// After missing custom re-init, default scope remains clean
expect(Object.keys(federationInstance.shareScopeMap.default)).not.toContain(
'@tanstack/react-query',
);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -690,4 +690,108 @@ describe('initContainerEntry with non-array-based share scopes', () => {
mockOptions.webpackRequire.federation.instance?.initShareScopeMap,
).toHaveBeenCalledWith('key2', expect.anything(), expect.anything());
});

test('should preserve non-default share scopes across repeated init with string shareScopeKey and array hostShareScopeKeys', () => {
const defaultScope = {
react: {
'18.2.0': {
scope: ['default'],
},
},
};
const customScope = {
'@tanstack/react-query': {
'5.0.0': {
scope: ['custom'],
},
},
};
const shareScopeMap: Record<string, Record<string, any>> = {};
const federationInstance = createMockFederationInstance({
shareScopeMap,
initShareScopeMap: jest.fn(
(scopeName: string, scope: Record<string, any>) => {
shareScopeMap[scopeName] = {
...shareScopeMap[scopeName],
...scope,
};
},
),
options: { shared: false },
});
const webpackRequire = createMockWebpackRequire({
I: jest.fn().mockReturnValue(Promise.resolve(true)),
federation: createMockFederation({
instance: federationInstance,
initOptions: {
name: 'test-app',
shared: false,
},
}),
});

const baseOptions = {
webpackRequire,
shareScopeKey: 'default',
shareScope: defaultScope,
};

initContainerEntry(
createMockOptions({
...baseOptions,
remoteEntryInitOptions: createMockRemoteEntryInitOptions({
shareScopeKeys: ['default', 'custom'],
shareScopeMap: {
default: defaultScope,
custom: customScope,
},
}),
}),
);

expect(federationInstance.shareScopeMap.custom).toEqual(customScope);

// Isolation: default must not be polluted with custom-scope-only packages
expect(Object.keys(federationInstance.shareScopeMap.default)).toContain(
'react',
);
expect(Object.keys(federationInstance.shareScopeMap.default)).not.toContain(
'@tanstack/react-query',
);

// Re-init with empty custom scope
initContainerEntry(
createMockOptions({
...baseOptions,
remoteEntryInitOptions: createMockRemoteEntryInitOptions({
shareScopeKeys: ['default', 'custom'],
shareScopeMap: {
default: defaultScope,
custom: {},
},
}),
}),
);

expect(federationInstance.shareScopeMap.custom).toEqual(customScope);

// Re-init with missing custom scope
initContainerEntry(
createMockOptions({
...baseOptions,
remoteEntryInitOptions: createMockRemoteEntryInitOptions({
shareScopeKeys: ['default', 'custom'],
shareScopeMap: {
default: defaultScope,
},
}),
}),
);

expect(federationInstance.shareScopeMap.custom).toEqual(customScope);

expect(Object.keys(federationInstance.shareScopeMap.default)).not.toContain(
'@tanstack/react-query',
);
});
});
Loading