@openmrs/esm-app-shell 10.0.1-pre.5299 → 10.0.1-pre.5316
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +3 -3
- package/dist/c7697ee12c9ec28f.js +1 -0
- package/dist/c7697ee12c9ec28f.js.map +1 -0
- package/dist/index.html +1 -1
- package/dist/{openmrs.d4a15c42d7958603.js → openmrs.939f1d7d74ea6b93.js} +4 -4
- package/dist/openmrs.939f1d7d74ea6b93.js.map +1 -0
- package/lib/{befe575e4e6f2605.js → 15269eac2c63d068.js} +2 -2
- package/lib/openmrs.js +16 -6
- package/package.json +4 -3
- package/src/federation-plugins.ts +87 -0
- package/src/framework-share.test.ts +230 -0
- package/src/framework-share.ts +79 -0
- package/src/index.ts +5 -0
- package/dist/ee89ed14e0e1c591.js +0 -1
- package/dist/ee89ed14e0e1c591.js.map +0 -1
- package/dist/openmrs.d4a15c42d7958603.js.map +0 -1
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
// Reproduces the duplicate-framework bug against Module Federation's own resolver rather than a model
|
|
2
|
+
// of it, so the tests still mean something if Module Federation changes how it picks a provider. Each
|
|
3
|
+
// "without the pin" case is the bug: the resolver hands the consuming module a provider other than the
|
|
4
|
+
// app shell's, and taking that provider loads a second copy of the framework — a second store
|
|
5
|
+
// registry, a second config store, a second set of extensions, and roughly a megabyte of duplicated
|
|
6
|
+
// download. Nothing throws when that happens, which is why it needs a test rather than a smoke check.
|
|
7
|
+
import { getRegisteredShare } from '@module-federation/runtime-core';
|
|
8
|
+
import type { Shared, ShareScopeMap } from '@module-federation/runtime-core/types';
|
|
9
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
10
|
+
import type { ResolvedShare } from './federation-plugins';
|
|
11
|
+
import { pinFrameworkToAppShell } from './framework-share';
|
|
12
|
+
|
|
13
|
+
const appShell = '@openmrs/esm-app-shell';
|
|
14
|
+
const frontendModule = '@openmrs/esm-ward-app';
|
|
15
|
+
const publicKey = '@openmrs/esm-framework';
|
|
16
|
+
const internalKey = '@openmrs/esm-framework/src/internal';
|
|
17
|
+
const pluginName = 'openmrs-pin-framework-to-app-shell';
|
|
18
|
+
|
|
19
|
+
type VersionMap = Record<string, Shared>;
|
|
20
|
+
|
|
21
|
+
function provider(version: string, from: string, { loaded = false } = {}) {
|
|
22
|
+
return {
|
|
23
|
+
version,
|
|
24
|
+
from,
|
|
25
|
+
loaded: loaded || undefined,
|
|
26
|
+
loading: null,
|
|
27
|
+
lib: null,
|
|
28
|
+
get: () => Promise.resolve(() => ({})),
|
|
29
|
+
scope: ['default'],
|
|
30
|
+
strategy: 'version-first',
|
|
31
|
+
useIn: [],
|
|
32
|
+
deps: [],
|
|
33
|
+
shareConfig: { requiredVersion: `^${version}`, singleton: true, eager: false, strictVersion: false },
|
|
34
|
+
} as unknown as Shared;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** What a published frontend module asks for, as its remote entry declares it. */
|
|
38
|
+
function consumer(pkgName = publicKey) {
|
|
39
|
+
return {
|
|
40
|
+
from: frontendModule,
|
|
41
|
+
version: '10.0.1-pre.5263',
|
|
42
|
+
scope: ['default'],
|
|
43
|
+
strategy: 'version-first',
|
|
44
|
+
shareConfig: { requiredVersion: '10.x', singleton: true, eager: false, strictVersion: false },
|
|
45
|
+
} as unknown as Shared & { pkgName?: string };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function federationGlobal() {
|
|
49
|
+
return globalThis.__FEDERATION__ as unknown as {
|
|
50
|
+
__GLOBAL_PLUGIN__: Array<{ name: string; resolveShare?: (args: unknown) => unknown }>;
|
|
51
|
+
__INSTANCES__: Array<{ registerPlugins: (plugins: Array<unknown>) => void }>;
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Sets the page up the way `initializeSpa` does: the app shell has registered its framework and
|
|
57
|
+
* nothing else has loaded yet, so this is the only moment the app shell's own entry is identifiable.
|
|
58
|
+
* The two keys share one version map because `initializeSpa` aliases them onto each other.
|
|
59
|
+
*/
|
|
60
|
+
function pinWithAppShellProvider() {
|
|
61
|
+
const appShellEntry = provider('10.0.0', appShell);
|
|
62
|
+
const versions: VersionMap = { '10.0.0': appShellEntry };
|
|
63
|
+
pinFrameworkToAppShell({ [publicKey]: versions, [internalKey]: versions });
|
|
64
|
+
return { appShellEntry, versions };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Runs the real resolver, with the registered plugin tapped into `resolveShare` or not. */
|
|
68
|
+
function resolveShareResult(versions: VersionMap, { pinned }: { pinned: boolean }) {
|
|
69
|
+
const plugin = federationGlobal().__GLOBAL_PLUGIN__.find((candidate) => candidate.name === pluginName);
|
|
70
|
+
const resolveShare = pinned ? { emit: (args: unknown) => plugin?.resolveShare?.(args) } : { emit: () => undefined };
|
|
71
|
+
const shareScopeMap = { default: { [publicKey]: versions, [internalKey]: versions } } as unknown as ShareScopeMap;
|
|
72
|
+
|
|
73
|
+
return getRegisteredShare(shareScopeMap, publicKey, consumer(), resolveShare as never);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function resolveProvider(versions: VersionMap, options: { pinned: boolean }) {
|
|
77
|
+
return resolveShareResult(versions, options)?.shared;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
describe('pinning @openmrs/esm-framework to the app shell', () => {
|
|
81
|
+
// Importing the runtime defines `__FEDERATION__` as non-configurable, so it can be reassigned but
|
|
82
|
+
// not removed. Each test gets an empty one and the real value goes back afterwards.
|
|
83
|
+
const runtimeFederation = globalThis.__FEDERATION__;
|
|
84
|
+
|
|
85
|
+
beforeEach(() => {
|
|
86
|
+
(globalThis as Record<string, unknown>).__FEDERATION__ = { __GLOBAL_PLUGIN__: [], __INSTANCES__: [] };
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
afterEach(() => {
|
|
90
|
+
(globalThis as Record<string, unknown>).__FEDERATION__ = runtimeFederation;
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('leaves a healthy page alone: the app shell already wins when its copy loaded first', () => {
|
|
94
|
+
// Documents why this is a latent hazard rather than a constant failure — and why a smoke test
|
|
95
|
+
// would never catch it.
|
|
96
|
+
const { appShellEntry, versions } = pinWithAppShellProvider();
|
|
97
|
+
appShellEntry.loaded = true;
|
|
98
|
+
versions['10.0.1-pre.5263'] = provider('10.0.1-pre.5263', frontendModule);
|
|
99
|
+
|
|
100
|
+
expect(resolveProvider(versions, { pinned: false })?.from).toBe(appShell);
|
|
101
|
+
expect(resolveProvider(versions, { pinned: true })?.from).toBe(appShell);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("takes the module's own higher version while the app shell's copy is still loading", () => {
|
|
105
|
+
const { appShellEntry, versions } = pinWithAppShellProvider();
|
|
106
|
+
appShellEntry.loading = Promise.resolve() as never;
|
|
107
|
+
versions['10.0.1-pre.5263'] = provider('10.0.1-pre.5263', frontendModule);
|
|
108
|
+
|
|
109
|
+
// `version-first` does not count "loading" as loaded, so the higher version wins.
|
|
110
|
+
expect(resolveProvider(versions, { pinned: false })?.from).toBe(frontendModule);
|
|
111
|
+
expect(resolveProvider(versions, { pinned: true })?.from).toBe(appShell);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("takes the module's own copy when it was registered first, even against a loaded app shell", () => {
|
|
115
|
+
// `findVersion` seeds its reduce with the first key unconditionally and then only asks whether
|
|
116
|
+
// *that* entry is loaded, so an unloaded module entry sitting first is never displaced.
|
|
117
|
+
const { appShellEntry } = pinWithAppShellProvider();
|
|
118
|
+
appShellEntry.loaded = true;
|
|
119
|
+
const reordered: VersionMap = {
|
|
120
|
+
'10.0.1-pre.5263': provider('10.0.1-pre.5263', frontendModule),
|
|
121
|
+
'10.0.0': appShellEntry,
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
expect(resolveProvider(reordered, { pinned: false })?.from).toBe(frontendModule);
|
|
125
|
+
expect(resolveProvider(reordered, { pinned: true })?.from).toBe(appShell);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("takes the module's own copy after it takes over the app shell's slot at the same version", () => {
|
|
129
|
+
// Any locally built module provides the workspace version, which is the app shell's own, so
|
|
130
|
+
// `register` replaces the app shell's not-yet-loaded entry and inherits its `from`. Nothing
|
|
131
|
+
// identifying the app shell is left in the scope, which is why the pin holds the entry itself.
|
|
132
|
+
const { appShellEntry, versions } = pinWithAppShellProvider();
|
|
133
|
+
versions['10.0.0'] = provider('10.0.0', frontendModule);
|
|
134
|
+
|
|
135
|
+
expect(resolveProvider(versions, { pinned: false })?.from).toBe(frontendModule);
|
|
136
|
+
|
|
137
|
+
const pinnedTo = resolveProvider(versions, { pinned: true });
|
|
138
|
+
expect(pinnedTo?.from).toBe(appShell);
|
|
139
|
+
expect(pinnedTo).toBe(appShellEntry);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('pins a module whose federation instance has a share scope of its own', () => {
|
|
143
|
+
// Divergent scopes hold only the module's own provider, so there is nothing to look up.
|
|
144
|
+
const { appShellEntry } = pinWithAppShellProvider();
|
|
145
|
+
const divergent: VersionMap = { '10.0.1-pre.5263': provider('10.0.1-pre.5263', frontendModule) };
|
|
146
|
+
|
|
147
|
+
expect(resolveProvider(divergent, { pinned: false })?.from).toBe(frontendModule);
|
|
148
|
+
expect(resolveProvider(divergent, { pinned: true })).toBe(appShellEntry);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("prefers whichever of the app shell's providers is already backing the page", () => {
|
|
152
|
+
const older = provider('10.0.0', appShell);
|
|
153
|
+
const newer = provider('10.0.1', appShell, { loaded: true });
|
|
154
|
+
const versions: VersionMap = { '10.0.0': older, '10.0.1': newer };
|
|
155
|
+
pinFrameworkToAppShell({ [publicKey]: versions, [internalKey]: versions });
|
|
156
|
+
|
|
157
|
+
expect(resolveProvider(versions, { pinned: true })).toBe(newer);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('returns a provider that every Module Federation runtime generation can use', () => {
|
|
161
|
+
// The plugin is registered globally, so it is also applied to the runtime embedded in a frontend
|
|
162
|
+
// module built before the app shell started publishing one. Those runtimes use the resolver's
|
|
163
|
+
// return value as the share entry itself and immediately reach for `get`; 2.x instead destructures
|
|
164
|
+
// `{ shared, useTreesShaking }`. Returning only the newer shape kills the older modules with
|
|
165
|
+
// `get is not a function`, which is the compatibility the app shell promises them.
|
|
166
|
+
const { appShellEntry, versions } = pinWithAppShellProvider();
|
|
167
|
+
versions['10.0.1-pre.5263'] = provider('10.0.1-pre.5263', frontendModule);
|
|
168
|
+
|
|
169
|
+
const resolved = resolveShareResult(versions, { pinned: true }) as unknown as ResolvedShare;
|
|
170
|
+
|
|
171
|
+
// What 2.x reads.
|
|
172
|
+
expect(resolved.shared).toBe(appShellEntry);
|
|
173
|
+
expect(resolved.useTreesShaking).toBe(false);
|
|
174
|
+
|
|
175
|
+
// What an older runtime reads.
|
|
176
|
+
expect(resolved).toBe(appShellEntry);
|
|
177
|
+
expect(typeof resolved.get).toBe('function');
|
|
178
|
+
|
|
179
|
+
// `setShared` copies a share entry with a rest-spread, so neither key may be enumerable or it
|
|
180
|
+
// ends up stored in the share scope.
|
|
181
|
+
const { version, scope, ...shareInfo } = resolved;
|
|
182
|
+
expect(shareInfo).not.toHaveProperty('shared');
|
|
183
|
+
expect(shareInfo).not.toHaveProperty('useTreesShaking');
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('leaves every other shared module to Module Federation', () => {
|
|
187
|
+
// The plugin is applied to every federation instance in the page, so it sees every resolution.
|
|
188
|
+
pinWithAppShellProvider();
|
|
189
|
+
const plugin = federationGlobal().__GLOBAL_PLUGIN__.find((candidate) => candidate.name === pluginName);
|
|
190
|
+
const args = { pkgName: 'react', scope: 'default', resolver: () => 'untouched' };
|
|
191
|
+
|
|
192
|
+
expect(plugin?.resolveShare?.(args)).toBe(args);
|
|
193
|
+
expect(args.resolver()).toBe('untouched');
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('reaches federation instances that already exist', () => {
|
|
197
|
+
// The app shell's own instance is built by the webpack runtime before any of this code runs, so
|
|
198
|
+
// the global plugin list alone would miss it.
|
|
199
|
+
const registered: Array<Array<unknown>> = [];
|
|
200
|
+
federationGlobal().__INSTANCES__.push({ registerPlugins: (plugins) => registered.push(plugins) });
|
|
201
|
+
|
|
202
|
+
pinWithAppShellProvider();
|
|
203
|
+
|
|
204
|
+
expect(registered).toHaveLength(1);
|
|
205
|
+
expect((registered[0][0] as { name: string }).name).toBe(pluginName);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('registers once, however many times it is called', () => {
|
|
209
|
+
pinWithAppShellProvider();
|
|
210
|
+
pinWithAppShellProvider();
|
|
211
|
+
|
|
212
|
+
expect(federationGlobal().__GLOBAL_PLUGIN__.filter((p) => p.name === pluginName)).toHaveLength(1);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('leaves resolution alone when the app shell provides no framework at all', () => {
|
|
216
|
+
const consoleMock = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
217
|
+
|
|
218
|
+
try {
|
|
219
|
+
pinFrameworkToAppShell({});
|
|
220
|
+
|
|
221
|
+
expect(federationGlobal().__GLOBAL_PLUGIN__).toHaveLength(0);
|
|
222
|
+
expect(consoleMock).toHaveBeenCalledOnce();
|
|
223
|
+
expect(consoleMock).toHaveBeenCalledWith(
|
|
224
|
+
'The app shell has not registered @openmrs/esm-framework as a shared module, so frontend modules may each load their own copy of the framework. This is a bug in the app shell build.',
|
|
225
|
+
);
|
|
226
|
+
} finally {
|
|
227
|
+
consoleMock.mockReset();
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import {
|
|
2
|
+
asResolvedShare,
|
|
3
|
+
getFederationGlobal,
|
|
4
|
+
preferLoadedProvider,
|
|
5
|
+
registerFederationPlugin,
|
|
6
|
+
type ModuleFederationRuntimePlugin,
|
|
7
|
+
type Shared,
|
|
8
|
+
type ShareVersionMap,
|
|
9
|
+
} from './federation-plugins';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* openmrs-pin-framework-to-app-shell
|
|
13
|
+
*
|
|
14
|
+
* This is a runtime Module Federation plugin to induce Module Federation to work as we expect.
|
|
15
|
+
* Specifically, we expect the version of @openmrs/esm-framework used to match the version loaded
|
|
16
|
+
* by the shell regardless of the versions available elsewhere. With Module Federation v2's
|
|
17
|
+
* default "version-first" loading strategy, the exact version of a resolution can be changed as
|
|
18
|
+
* new apps are added. This runtime plugin ensures we only ever load the framework from the shell.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const pluginName = 'openmrs-pin-framework-to-app-shell';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Both keys name the same providers: the app shell shares the framework under its `/src/internal`
|
|
25
|
+
* entry point, and `initializeSpa` aliases the public name onto it for frontend modules to consume.
|
|
26
|
+
*/
|
|
27
|
+
const frameworkShareKeys = new Set(['@openmrs/esm-framework', '@openmrs/esm-framework/src/internal']);
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Makes every consume of `@openmrs/esm-framework` in the page resolve to the copy the app shell
|
|
31
|
+
* provides, regardless of what versions frontend modules offer or what order they registered in.
|
|
32
|
+
*
|
|
33
|
+
* No version check is needed on the way through: the app shell's framework is the one every module
|
|
34
|
+
* in the distribution is meant to run against, so it wins unconditionally. A rule pinning to some
|
|
35
|
+
* other provider could not assume that.
|
|
36
|
+
*
|
|
37
|
+
* Call this once, after `__webpack_init_sharing__` has run and before any frontend module is
|
|
38
|
+
* loaded; later calls are ignored.
|
|
39
|
+
*
|
|
40
|
+
* If the app shell somehow provides no framework at all, resolution is left alone rather than
|
|
41
|
+
* broken — a module falling back to its own copy is better than a module that cannot start.
|
|
42
|
+
*
|
|
43
|
+
* @param shareScope The `default` share scope, i.e. `__webpack_share_scopes__.default`.
|
|
44
|
+
*/
|
|
45
|
+
export function pinFrameworkToAppShell(shareScope: Record<string, unknown>) {
|
|
46
|
+
if (!getFederationGlobal()) {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Read while the app shell is the only registered provider, so these are its own entries and
|
|
51
|
+
// recognising them later needs neither a name nor a lookup.
|
|
52
|
+
const provided = shareScope['@openmrs/esm-framework/src/internal'] as ShareVersionMap | undefined;
|
|
53
|
+
const appShellProviders = Object.values(provided ?? {}).filter((entry): entry is Shared => Boolean(entry));
|
|
54
|
+
|
|
55
|
+
// Paranoia
|
|
56
|
+
if (appShellProviders.length === 0) {
|
|
57
|
+
console.error(
|
|
58
|
+
'The app shell has not registered @openmrs/esm-framework as a shared module, so frontend ' +
|
|
59
|
+
'modules may each load their own copy of the framework. This is a bug in the app shell build.',
|
|
60
|
+
);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const plugin: ModuleFederationRuntimePlugin = {
|
|
65
|
+
name: pluginName,
|
|
66
|
+
resolveShare(args) {
|
|
67
|
+
if (!frameworkShareKeys.has(args.pkgName)) {
|
|
68
|
+
return args;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// `asResolvedShare` is what keeps frontend modules carrying an older Module Federation runtime
|
|
72
|
+
// working; the app shell's plugin is applied to theirs too, and they expect the older shape.
|
|
73
|
+
args.resolver = () => asResolvedShare(preferLoadedProvider(appShellProviders));
|
|
74
|
+
return args;
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
registerFederationPlugin(plugin);
|
|
79
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { publishFederationRuntime } from './federation-runtime';
|
|
2
|
+
import { pinFrameworkToAppShell } from './framework-share';
|
|
2
3
|
import type { SpaConfig } from '@openmrs/esm-framework/src/internal';
|
|
3
4
|
|
|
4
5
|
// Before anything else: every frontend module reads the Module Federation runtime from the globals
|
|
@@ -111,6 +112,10 @@ function initializeSpa(config: SpaConfig) {
|
|
|
111
112
|
shareScope['@openmrs/esm-framework'] = shareScope['@openmrs/esm-framework/src/internal'];
|
|
112
113
|
}
|
|
113
114
|
|
|
115
|
+
// Frontend modules also register their own framework into this scope; this keeps them resolving
|
|
116
|
+
// to the app shell's copy no matter what version they bring.
|
|
117
|
+
pinFrameworkToAppShell(shareScope);
|
|
118
|
+
|
|
114
119
|
const { configUrls = [], offline = false } = config;
|
|
115
120
|
Object.defineProperty(window, 'offlineEnabled', {
|
|
116
121
|
value: offline,
|