@asteby/metacore-runtime-react 13.0.0 → 13.2.0
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/CHANGELOG.md +20 -0
- package/dist/addon-loader.d.ts +1 -8
- package/dist/addon-loader.d.ts.map +1 -1
- package/dist/addon-loader.js +51 -40
- package/dist/hotswap-reload-policy.d.ts +8 -12
- package/dist/hotswap-reload-policy.d.ts.map +1 -1
- package/dist/hotswap-reload-policy.js +17 -30
- package/package.json +2 -1
- package/src/addon-loader.tsx +68 -52
- package/src/hotswap-reload-policy.ts +18 -31
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# @asteby/metacore-runtime-react
|
|
2
2
|
|
|
3
|
+
## 13.2.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- fb45ad4: Migrate federation tooling from the broken `@originjs/vite-plugin-federation` to the official `@module-federation/vite` + `@module-federation/runtime`.
|
|
8
|
+
|
|
9
|
+
**BREAKING (federation runtime swap — hosts and addons must rebuild):**
|
|
10
|
+
- `metacoreFederationShared()` (starter-config) now returns a `@module-federation/vite` `federation()` config instead of an `@originjs` config. Same signature/call-sites: `metacoreFederationShared({ host })` → host config (name + shared, empty remotes — remotes register dynamically at runtime); `metacoreFederationShared({ host, exposes })` → remote config (name + filename + exposes + shared). Hosts MUST switch their `vite.config.ts` to `import { federation } from '@module-federation/vite'`.
|
|
11
|
+
- The shared singleton list is now `{ singleton: true }` (no `requiredVersion: false`) and matches the addon + ops host contract exactly: `react`, `react-dom`, `react/jsx-runtime`, `react-i18next`, `i18next`, `@asteby/metacore-ui`, `@asteby/metacore-runtime-react`, `@asteby/metacore-sdk`, `@asteby/metacore-app-providers`, `@asteby/metacore-theme`, `@asteby/metacore-auth`. **Build-time gotcha:** `@module-federation/vite` resolves every shared bare specifier at build time, so each must be an installed (dev)dependency of the building package.
|
|
12
|
+
- `AddonLoader` (runtime-react) now uses `@module-federation/runtime` (`registerRemotes` + `loadRemote`) instead of the manual `init`/`get`/`window[scope]` machinery. The host's `@module-federation/vite` build auto-initialises the shared scope, so the remote consumes the HOST's React/SDK singletons — fixing the `useState`-null crash.
|
|
13
|
+
- `clearFederationContainer()` is now a deprecated no-op under the MF runtime (container replacement on hot-swap is handled by `registerRemotes(..., { force: true })`).
|
|
14
|
+
|
|
15
|
+
## 13.1.0
|
|
16
|
+
|
|
17
|
+
### Minor Changes
|
|
18
|
+
|
|
19
|
+
- 8d9c602: AddonLoader carga remotes de federación ESM vía `import()` dinámico (fix "Cannot use import statement outside a module").
|
|
20
|
+
|
|
21
|
+
Los remotes built con Vite/@originjs `format:"esm"` (el estándar de `metacoreFederationShared`) son módulos ES que hacen `import` top-level y exportan `{ init, get }` — DEBEN cargarse como módulo. El `AddonLoader` los inyectaba como `<script>` clásico → el browser tiraba `Cannot use import statement outside a module` y la UI federada nunca cargaba. Ahora hace `import()` dinámico (vía `new Function` para que ningún bundler reescriba el import del URL externo) y usa el namespace del módulo como container; los remotes legacy "var"/window siguen soportados con fallback a `<script>` + `window[scope]`.
|
|
22
|
+
|
|
3
23
|
## 13.0.0
|
|
4
24
|
|
|
5
25
|
### Minor Changes
|
package/dist/addon-loader.d.ts
CHANGED
|
@@ -1,15 +1,8 @@
|
|
|
1
1
|
import type { AddonAPI, AddonLayout } from '@asteby/metacore-sdk';
|
|
2
|
-
declare global {
|
|
3
|
-
interface Window {
|
|
4
|
-
[key: string]: any;
|
|
5
|
-
__webpack_init_sharing__?: (scope: string) => Promise<void>;
|
|
6
|
-
__webpack_share_scopes__?: Record<string, unknown>;
|
|
7
|
-
}
|
|
8
|
-
}
|
|
9
2
|
export interface AddonLoaderProps {
|
|
10
3
|
/** Unique key of the addon — maps to the federation container name. */
|
|
11
4
|
scope: string;
|
|
12
|
-
/** URL of the addon's remoteEntry.js bundle. */
|
|
5
|
+
/** URL of the addon's remoteEntry.js bundle (may carry a `?v=` cache-bust). */
|
|
13
6
|
url: string;
|
|
14
7
|
/** Exposed module to import from the remote (e.g. './register'). */
|
|
15
8
|
module?: string;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"addon-loader.d.ts","sourceRoot":"","sources":["../src/addon-loader.tsx"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"addon-loader.d.ts","sourceRoot":"","sources":["../src/addon-loader.tsx"],"names":[],"mappings":"AAcA,OAAO,KAAK,EAAE,QAAQ,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAA;AAGjE,MAAM,WAAW,gBAAgB;IAC7B,uEAAuE;IACvE,KAAK,EAAE,MAAM,CAAA;IACb,+EAA+E;IAC/E,GAAG,EAAE,MAAM,CAAA;IACX,oEAAoE;IACpE,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,+DAA+D;IAC/D,GAAG,EAAE,QAAQ,CAAA;IACb,wCAAwC;IACxC,QAAQ,CAAC,EAAE,KAAK,CAAC,SAAS,CAAA;IAC1B,yDAAyD;IACzD,OAAO,CAAC,EAAE,MAAM,IAAI,CAAA;IACpB,+BAA+B;IAC/B,OAAO,CAAC,EAAE,CAAC,GAAG,EAAE,KAAK,KAAK,IAAI,CAAA;IAC9B;;;;;;;;;;OAUG;IACH,MAAM,CAAC,EAAE,WAAW,CAAA;IACpB,QAAQ,CAAC,EAAE,KAAK,CAAC,SAAS,CAAA;CAC7B;AA6CD,wBAAgB,WAAW,CAAC,EACxB,KAAK,EACL,GAAG,EACH,MAAqB,EACrB,GAAG,EACH,QAAe,EACf,OAAO,EACP,OAAO,EACP,MAAM,EACN,QAAQ,GACX,EAAE,gBAAgB,2CA8ClB"}
|
package/dist/addon-loader.js
CHANGED
|
@@ -1,39 +1,46 @@
|
|
|
1
1
|
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
2
|
+
// Federated-module addon loader, built on the official Module Federation
|
|
3
|
+
// runtime (`@module-federation/runtime`). Per addon it registers the remote's
|
|
4
|
+
// `remoteEntry.js` as an ESM container, loads the exposed `./register` module,
|
|
5
|
+
// and calls `register(api)` with the AddonAPI injected by the host.
|
|
6
|
+
//
|
|
7
|
+
// Why @module-federation/runtime (not the old manual init/get machinery):
|
|
8
|
+
// the host's Vite build uses `@module-federation/vite`'s `federation()` plugin,
|
|
9
|
+
// which auto-initialises the shared scope at host boot. `registerRemotes` +
|
|
10
|
+
// `loadRemote` then transparently wire the remote into that already-initialised
|
|
11
|
+
// share scope — so the remote consumes the HOST's React/SDK singletons instead
|
|
12
|
+
// of bundling its own. That's the whole point: it fixes the `useState`-null
|
|
13
|
+
// crash WITHOUT this loader ever touching a share scope manually.
|
|
5
14
|
import { useEffect, useRef, useState } from 'react';
|
|
15
|
+
import { registerRemotes, loadRemote } from '@module-federation/runtime';
|
|
6
16
|
import { useDeclareAddonLayout } from './addon-layout-context';
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
el.async = true;
|
|
18
|
-
el.onload = () => resolve();
|
|
19
|
-
el.onerror = () => reject(new Error(`Failed to load addon script: ${url}`));
|
|
20
|
-
document.head.appendChild(el);
|
|
21
|
-
});
|
|
22
|
-
loadedScripts.set(key, promise);
|
|
23
|
-
return promise;
|
|
17
|
+
// `registerRemotes` is additive + idempotent across re-mounts; we still track
|
|
18
|
+
// which scopes we've registered to avoid redundant `force` churn (each `force`
|
|
19
|
+
// re-register wipes that remote's module cache and logs a runtime warning).
|
|
20
|
+
const registered = new Set();
|
|
21
|
+
// Derive the `loadRemote` id from the scope + exposed module name. MF resolves
|
|
22
|
+
// `"<remoteName>/<expose>"` — e.g. `metacore_tickets/register` for the
|
|
23
|
+
// `"./register"` expose. We strip the leading `./` of the expose path.
|
|
24
|
+
function remoteId(scope, module) {
|
|
25
|
+
const expose = module.replace(/^\.\//, '');
|
|
26
|
+
return `${scope}/${expose}`;
|
|
24
27
|
}
|
|
25
|
-
async function
|
|
26
|
-
|
|
27
|
-
|
|
28
|
+
async function loadAddon(scope, url, module) {
|
|
29
|
+
// Register the remote container as an ES module. `type: 'module'` matches
|
|
30
|
+
// the `@module-federation/vite` remote (remoteEntry.js is an ESM bundle).
|
|
31
|
+
// The `url` already carries the `?v=` cache-bust the host computed, so the
|
|
32
|
+
// browser refetches a fresh remoteEntry when the addon version changes.
|
|
33
|
+
if (!registered.has(scope)) {
|
|
34
|
+
registerRemotes([{ name: scope, entry: url, type: 'module' }],
|
|
35
|
+
// `force: true` so a re-registration with a new `?v=` URL (addon
|
|
36
|
+
// hot-swap / version bump) overwrites the stale entry + cache.
|
|
37
|
+
{ force: true });
|
|
38
|
+
registered.add(scope);
|
|
28
39
|
}
|
|
29
|
-
|
|
30
|
-
if
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
await container.init(window.__webpack_share_scopes__.default);
|
|
34
|
-
}
|
|
35
|
-
const factory = await container.get(module);
|
|
36
|
-
return factory();
|
|
40
|
+
// loadRemote("<scope>/<expose>") returns the exposed module namespace (or
|
|
41
|
+
// null if it can't be resolved). No manual share-scope init — the host's
|
|
42
|
+
// federation runtime already initialised it.
|
|
43
|
+
return loadRemote(remoteId(scope, module));
|
|
37
44
|
}
|
|
38
45
|
export function AddonLoader({ scope, url, module = './register', api, fallback = null, onReady, onError, layout, children, }) {
|
|
39
46
|
const [status, setStatus] = useState('loading');
|
|
@@ -48,15 +55,16 @@ export function AddonLoader({ scope, url, module = './register', api, fallback =
|
|
|
48
55
|
let cancelled = false;
|
|
49
56
|
(async () => {
|
|
50
57
|
try {
|
|
51
|
-
await
|
|
52
|
-
if (cancelled)
|
|
53
|
-
return;
|
|
54
|
-
const mod = await loadRemote(scope, module);
|
|
58
|
+
const mod = await loadAddon(scope, url, module);
|
|
55
59
|
if (cancelled)
|
|
56
60
|
return;
|
|
57
|
-
|
|
61
|
+
const register = mod?.register ?? mod?.default;
|
|
62
|
+
if (typeof register !== 'function') {
|
|
63
|
+
throw new Error(`Addon "${scope}" module "${module}" has no register() export`);
|
|
64
|
+
}
|
|
65
|
+
if (!didRegister.current) {
|
|
58
66
|
didRegister.current = true;
|
|
59
|
-
await Promise.resolve(
|
|
67
|
+
await Promise.resolve(register(api));
|
|
60
68
|
}
|
|
61
69
|
setStatus('ready');
|
|
62
70
|
onReady?.();
|
|
@@ -64,12 +72,15 @@ export function AddonLoader({ scope, url, module = './register', api, fallback =
|
|
|
64
72
|
catch (e) {
|
|
65
73
|
if (cancelled)
|
|
66
74
|
return;
|
|
67
|
-
|
|
75
|
+
const err = e instanceof Error ? e : new Error(String(e));
|
|
76
|
+
setError(err);
|
|
68
77
|
setStatus('error');
|
|
69
|
-
onError?.(
|
|
78
|
+
onError?.(err);
|
|
70
79
|
}
|
|
71
80
|
})();
|
|
72
|
-
return () => {
|
|
81
|
+
return () => {
|
|
82
|
+
cancelled = true;
|
|
83
|
+
};
|
|
73
84
|
}, [scope, url, module]);
|
|
74
85
|
if (status === 'loading')
|
|
75
86
|
return _jsx(_Fragment, { children: fallback });
|
|
@@ -126,19 +126,15 @@ export declare function useHotSwapReload(client: ManifestHotSwapClient | undefin
|
|
|
126
126
|
*/
|
|
127
127
|
export declare function withVersionParam(url: string, hash: string | undefined): string;
|
|
128
128
|
/**
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
129
|
+
* @deprecated Legacy `@originjs/vite-plugin-federation` helper. Under the
|
|
130
|
+
* current `@module-federation/runtime` loader ({@link AddonLoader}), container
|
|
131
|
+
* replacement on hot-swap is handled by `registerRemotes(..., { force: true })`
|
|
132
|
+
* with the new `?v=` URL — there is no `window[scope]` container to delete.
|
|
133
133
|
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
* **Caveat:** some federation runtimes wrap the container in a Proxy
|
|
139
|
-
* whose internal state survives `delete`. If you hit `Container already
|
|
140
|
-
* registered` after calling this, the federation runtime is holding the
|
|
141
|
-
* reference internally and the only reliable swap is `"page-reload"`.
|
|
134
|
+
* Kept for backward compatibility so existing host `onSwap` wiring keeps
|
|
135
|
+
* compiling. Best-effort: removes a stale `window[scope]` if a legacy
|
|
136
|
+
* `@originjs` remote left one behind, otherwise a no-op. Returns `true` if a
|
|
137
|
+
* value was removed, `false` otherwise.
|
|
142
138
|
*/
|
|
143
139
|
export declare function clearFederationContainer(scope: string): boolean;
|
|
144
140
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"hotswap-reload-policy.d.ts","sourceRoot":"","sources":["../src/hotswap-reload-policy.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"hotswap-reload-policy.d.ts","sourceRoot":"","sources":["../src/hotswap-reload-policy.ts"],"names":[],"mappings":"AAyDA,OAAO,EAEH,KAAK,2BAA2B,EAChC,KAAK,qBAAqB,EAC1B,KAAK,8BAA8B,EACtC,MAAM,+BAA+B,CAAA;AAEtC;;;;;;GAMG;AACH,MAAM,MAAM,qBAAqB,GAAG,OAAO,GAAG,aAAa,GAAG,QAAQ,CAAA;AAEtE;;;;GAIG;AACH,MAAM,WAAW,mBAAmB;IAChC,wDAAwD;IACxD,QAAQ,CAAC,EAAE,qBAAqB,CAAA;IAChC;;;;;;;;;;;;OAYG;IACH,cAAc,CAAC,EAAE,CACb,KAAK,EAAE,2BAA2B,KACjC,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAA;IAC/B;;;;;OAKG;IACH,MAAM,CAAC,EAAE,CACL,KAAK,EAAE,2BAA2B,EAClC,MAAM,EAAE,OAAO,GAAG,aAAa,GAAG,WAAW,GAAG,QAAQ,KACvD,IAAI,CAAA;IACT;;;OAGG;IACH,OAAO,CAAC,EAAE,8BAA8B,CAAC,SAAS,CAAC,CAAA;CACtD;AAED,MAAM,WAAW,sBAAsB;IACnC;;;;;;;;OAQG;IACH,eAAe,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CAC1C;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH;;;;GAIG;AACH,MAAM,MAAM,mBAAmB,GACzB,OAAO,GACP,aAAa,GACb,WAAW,GACX,QAAQ,GACR,MAAM,CAAA;AAEZ,MAAM,WAAW,iBAAiB;IAC9B,0EAA0E;IAC1E,aAAa,EAAE,CACX,OAAO,EAAE,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,KAAK,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,KAChE,IAAI,CAAA;IACT,yEAAyE;IACzE,MAAM,CAAC,EAAE,MAAM,IAAI,CAAA;CACtB;AAED;;;;;;;;GAQG;AACH,wBAAsB,kBAAkB,CACpC,OAAO,EAAE,2BAA2B,EACpC,MAAM,EAAE,mBAAmB,EAC3B,IAAI,EAAE,iBAAiB,GACxB,OAAO,CAAC,mBAAmB,CAAC,CA6C9B;AAED,wBAAgB,gBAAgB,CAC5B,MAAM,EAAE,qBAAqB,GAAG,SAAS,GAAG,IAAI,EAChD,MAAM,GAAE,mBAAwB,GACjC,sBAAsB,CA2BxB;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,CAiB9E;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,wBAAwB,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAU/D;AAED;;;;;;;;;GASG;AACH,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CAOxE"}
|
|
@@ -44,21 +44,15 @@
|
|
|
44
44
|
// ## Federation runtime caveat
|
|
45
45
|
//
|
|
46
46
|
// The `"rekey"` strategy re-fetches `remoteEntry.js` with a `?v=<hash8>` query
|
|
47
|
-
// suffix (see {@link withVersionParam}).
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
// module's default behaviour. Some federation runtimes (vite-plugin-federation
|
|
57
|
-
// in dev, webpack 5 with `runtime: false`) wrap the container in a `Proxy`
|
|
58
|
-
// that mutates internal state on every access; blindly deleting it from
|
|
59
|
-
// here would race against any unmounting consumer that still holds a
|
|
60
|
-
// reference. Hosts that hit `Container already registered` should call
|
|
61
|
-
// `clearFederationContainer(scope)` from `onSwap` as documented below.
|
|
47
|
+
// suffix (see {@link withVersionParam}). With `@module-federation/runtime`, the
|
|
48
|
+
// {@link AddonLoader} re-registers the remote on every rekey remount via
|
|
49
|
+
// `registerRemotes([{ name, entry: <new ?v= url> }], { force: true })`. The
|
|
50
|
+
// `force` flag overwrites the previously registered container AND wipes that
|
|
51
|
+
// remote's loaded-module cache, so the new code takes effect without any manual
|
|
52
|
+
// `window[scope]` deletion — the old `@originjs` requirement to `delete
|
|
53
|
+
// window[Container]` no longer applies. {@link clearFederationContainer} is kept
|
|
54
|
+
// for backward compatibility (legacy hosts may still call it from `onSwap`) but
|
|
55
|
+
// is a no-op under the MF runtime.
|
|
62
56
|
import { useMemo, useRef, useState } from 'react';
|
|
63
57
|
import { useManifestHotSwapSubscriber, } from './manifest-hotswap-subscriber';
|
|
64
58
|
/**
|
|
@@ -173,19 +167,15 @@ export function withVersionParam(url, hash) {
|
|
|
173
167
|
return `${head}?${parts.join('&')}${fragment}`;
|
|
174
168
|
}
|
|
175
169
|
/**
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
* Best-effort: if `window` is undefined (SSR) or the scope was never
|
|
182
|
-
* registered, this is a no-op. Returns `true` if a container was actually
|
|
183
|
-
* removed, `false` otherwise — useful for telemetry.
|
|
170
|
+
* @deprecated Legacy `@originjs/vite-plugin-federation` helper. Under the
|
|
171
|
+
* current `@module-federation/runtime` loader ({@link AddonLoader}), container
|
|
172
|
+
* replacement on hot-swap is handled by `registerRemotes(..., { force: true })`
|
|
173
|
+
* with the new `?v=` URL — there is no `window[scope]` container to delete.
|
|
184
174
|
*
|
|
185
|
-
*
|
|
186
|
-
*
|
|
187
|
-
*
|
|
188
|
-
*
|
|
175
|
+
* Kept for backward compatibility so existing host `onSwap` wiring keeps
|
|
176
|
+
* compiling. Best-effort: removes a stale `window[scope]` if a legacy
|
|
177
|
+
* `@originjs` remote left one behind, otherwise a no-op. Returns `true` if a
|
|
178
|
+
* value was removed, `false` otherwise.
|
|
189
179
|
*/
|
|
190
180
|
export function clearFederationContainer(scope) {
|
|
191
181
|
if (typeof window === 'undefined')
|
|
@@ -197,9 +187,6 @@ export function clearFederationContainer(scope) {
|
|
|
197
187
|
return true;
|
|
198
188
|
}
|
|
199
189
|
catch {
|
|
200
|
-
// Some browsers refuse to delete non-configurable globals. Set
|
|
201
|
-
// to undefined as a fallback so the loader's `if (!window[scope])`
|
|
202
|
-
// check still triggers a re-inject.
|
|
203
190
|
;
|
|
204
191
|
window[scope] = undefined;
|
|
205
192
|
return true;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@asteby/metacore-runtime-react",
|
|
3
|
-
"version": "13.
|
|
3
|
+
"version": "13.2.0",
|
|
4
4
|
"description": "React runtime for metacore hosts — renders addon contributions dynamically",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
}
|
|
18
18
|
},
|
|
19
19
|
"dependencies": {
|
|
20
|
+
"@module-federation/runtime": "^2.5.0",
|
|
20
21
|
"zod": "^4.3.0"
|
|
21
22
|
},
|
|
22
23
|
"peerDependencies": {
|
package/src/addon-loader.tsx
CHANGED
|
@@ -1,22 +1,24 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// Federated-module addon loader, built on the official Module Federation
|
|
2
|
+
// runtime (`@module-federation/runtime`). Per addon it registers the remote's
|
|
3
|
+
// `remoteEntry.js` as an ESM container, loads the exposed `./register` module,
|
|
4
|
+
// and calls `register(api)` with the AddonAPI injected by the host.
|
|
5
|
+
//
|
|
6
|
+
// Why @module-federation/runtime (not the old manual init/get machinery):
|
|
7
|
+
// the host's Vite build uses `@module-federation/vite`'s `federation()` plugin,
|
|
8
|
+
// which auto-initialises the shared scope at host boot. `registerRemotes` +
|
|
9
|
+
// `loadRemote` then transparently wire the remote into that already-initialised
|
|
10
|
+
// share scope — so the remote consumes the HOST's React/SDK singletons instead
|
|
11
|
+
// of bundling its own. That's the whole point: it fixes the `useState`-null
|
|
12
|
+
// crash WITHOUT this loader ever touching a share scope manually.
|
|
4
13
|
import { useEffect, useRef, useState } from 'react'
|
|
14
|
+
import { registerRemotes, loadRemote } from '@module-federation/runtime'
|
|
5
15
|
import type { AddonAPI, AddonLayout } from '@asteby/metacore-sdk'
|
|
6
16
|
import { useDeclareAddonLayout } from './addon-layout-context'
|
|
7
17
|
|
|
8
|
-
declare global {
|
|
9
|
-
interface Window {
|
|
10
|
-
[key: string]: any
|
|
11
|
-
__webpack_init_sharing__?: (scope: string) => Promise<void>
|
|
12
|
-
__webpack_share_scopes__?: Record<string, unknown>
|
|
13
|
-
}
|
|
14
|
-
}
|
|
15
|
-
|
|
16
18
|
export interface AddonLoaderProps {
|
|
17
19
|
/** Unique key of the addon — maps to the federation container name. */
|
|
18
20
|
scope: string
|
|
19
|
-
/** URL of the addon's remoteEntry.js bundle. */
|
|
21
|
+
/** URL of the addon's remoteEntry.js bundle (may carry a `?v=` cache-bust). */
|
|
20
22
|
url: string
|
|
21
23
|
/** Exposed module to import from the remote (e.g. './register'). */
|
|
22
24
|
module?: string
|
|
@@ -43,41 +45,47 @@ export interface AddonLoaderProps {
|
|
|
43
45
|
children?: React.ReactNode
|
|
44
46
|
}
|
|
45
47
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
const existing = loadedScripts.get(key)
|
|
51
|
-
if (existing) return existing
|
|
52
|
-
const promise = new Promise<void>((resolve, reject) => {
|
|
53
|
-
const el = document.createElement('script')
|
|
54
|
-
el.src = url
|
|
55
|
-
el.type = 'text/javascript'
|
|
56
|
-
el.async = true
|
|
57
|
-
el.onload = () => resolve()
|
|
58
|
-
el.onerror = () => reject(new Error(`Failed to load addon script: ${url}`))
|
|
59
|
-
document.head.appendChild(el)
|
|
60
|
-
})
|
|
61
|
-
loadedScripts.set(key, promise)
|
|
62
|
-
return promise
|
|
48
|
+
/** Shape of the exposed `./register` module. */
|
|
49
|
+
interface AddonRegisterModule {
|
|
50
|
+
register?: (api: AddonAPI) => void | Promise<void>
|
|
51
|
+
default?: (api: AddonAPI) => void | Promise<void>
|
|
63
52
|
}
|
|
64
53
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
54
|
+
// `registerRemotes` is additive + idempotent across re-mounts; we still track
|
|
55
|
+
// which scopes we've registered to avoid redundant `force` churn (each `force`
|
|
56
|
+
// re-register wipes that remote's module cache and logs a runtime warning).
|
|
57
|
+
const registered = new Set<string>()
|
|
58
|
+
|
|
59
|
+
// Derive the `loadRemote` id from the scope + exposed module name. MF resolves
|
|
60
|
+
// `"<remoteName>/<expose>"` — e.g. `metacore_tickets/register` for the
|
|
61
|
+
// `"./register"` expose. We strip the leading `./` of the expose path.
|
|
62
|
+
function remoteId(scope: string, module: string): string {
|
|
63
|
+
const expose = module.replace(/^\.\//, '')
|
|
64
|
+
return `${scope}/${expose}`
|
|
68
65
|
}
|
|
69
66
|
|
|
70
|
-
async function
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
67
|
+
async function loadAddon(
|
|
68
|
+
scope: string,
|
|
69
|
+
url: string,
|
|
70
|
+
module: string,
|
|
71
|
+
): Promise<AddonRegisterModule | null> {
|
|
72
|
+
// Register the remote container as an ES module. `type: 'module'` matches
|
|
73
|
+
// the `@module-federation/vite` remote (remoteEntry.js is an ESM bundle).
|
|
74
|
+
// The `url` already carries the `?v=` cache-bust the host computed, so the
|
|
75
|
+
// browser refetches a fresh remoteEntry when the addon version changes.
|
|
76
|
+
if (!registered.has(scope)) {
|
|
77
|
+
registerRemotes(
|
|
78
|
+
[{ name: scope, entry: url, type: 'module' }],
|
|
79
|
+
// `force: true` so a re-registration with a new `?v=` URL (addon
|
|
80
|
+
// hot-swap / version bump) overwrites the stale entry + cache.
|
|
81
|
+
{ force: true },
|
|
82
|
+
)
|
|
83
|
+
registered.add(scope)
|
|
78
84
|
}
|
|
79
|
-
|
|
80
|
-
|
|
85
|
+
// loadRemote("<scope>/<expose>") returns the exposed module namespace (or
|
|
86
|
+
// null if it can't be resolved). No manual share-scope init — the host's
|
|
87
|
+
// federation runtime already initialised it.
|
|
88
|
+
return loadRemote<AddonRegisterModule>(remoteId(scope, module))
|
|
81
89
|
}
|
|
82
90
|
|
|
83
91
|
export function AddonLoader({
|
|
@@ -105,27 +113,35 @@ export function AddonLoader({
|
|
|
105
113
|
let cancelled = false
|
|
106
114
|
;(async () => {
|
|
107
115
|
try {
|
|
108
|
-
await
|
|
116
|
+
const mod = await loadAddon(scope, url, module)
|
|
109
117
|
if (cancelled) return
|
|
110
|
-
const
|
|
111
|
-
if (
|
|
112
|
-
|
|
118
|
+
const register = mod?.register ?? mod?.default
|
|
119
|
+
if (typeof register !== 'function') {
|
|
120
|
+
throw new Error(
|
|
121
|
+
`Addon "${scope}" module "${module}" has no register() export`,
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
if (!didRegister.current) {
|
|
113
125
|
didRegister.current = true
|
|
114
|
-
await Promise.resolve(
|
|
126
|
+
await Promise.resolve(register(api))
|
|
115
127
|
}
|
|
116
128
|
setStatus('ready')
|
|
117
129
|
onReady?.()
|
|
118
|
-
} catch (e:
|
|
130
|
+
} catch (e: unknown) {
|
|
119
131
|
if (cancelled) return
|
|
120
|
-
|
|
132
|
+
const err = e instanceof Error ? e : new Error(String(e))
|
|
133
|
+
setError(err)
|
|
121
134
|
setStatus('error')
|
|
122
|
-
onError?.(
|
|
135
|
+
onError?.(err)
|
|
123
136
|
}
|
|
124
137
|
})()
|
|
125
|
-
return () => {
|
|
138
|
+
return () => {
|
|
139
|
+
cancelled = true
|
|
140
|
+
}
|
|
126
141
|
}, [scope, url, module])
|
|
127
142
|
|
|
128
143
|
if (status === 'loading') return <>{fallback}</>
|
|
129
|
-
if (status === 'error')
|
|
144
|
+
if (status === 'error')
|
|
145
|
+
return <div className="text-sm text-red-500">Addon load error: {error?.message}</div>
|
|
130
146
|
return <>{children}</>
|
|
131
147
|
}
|
|
@@ -44,21 +44,15 @@
|
|
|
44
44
|
// ## Federation runtime caveat
|
|
45
45
|
//
|
|
46
46
|
// The `"rekey"` strategy re-fetches `remoteEntry.js` with a `?v=<hash8>` query
|
|
47
|
-
// suffix (see {@link withVersionParam}).
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
// module's default behaviour. Some federation runtimes (vite-plugin-federation
|
|
57
|
-
// in dev, webpack 5 with `runtime: false`) wrap the container in a `Proxy`
|
|
58
|
-
// that mutates internal state on every access; blindly deleting it from
|
|
59
|
-
// here would race against any unmounting consumer that still holds a
|
|
60
|
-
// reference. Hosts that hit `Container already registered` should call
|
|
61
|
-
// `clearFederationContainer(scope)` from `onSwap` as documented below.
|
|
47
|
+
// suffix (see {@link withVersionParam}). With `@module-federation/runtime`, the
|
|
48
|
+
// {@link AddonLoader} re-registers the remote on every rekey remount via
|
|
49
|
+
// `registerRemotes([{ name, entry: <new ?v= url> }], { force: true })`. The
|
|
50
|
+
// `force` flag overwrites the previously registered container AND wipes that
|
|
51
|
+
// remote's loaded-module cache, so the new code takes effect without any manual
|
|
52
|
+
// `window[scope]` deletion — the old `@originjs` requirement to `delete
|
|
53
|
+
// window[Container]` no longer applies. {@link clearFederationContainer} is kept
|
|
54
|
+
// for backward compatibility (legacy hosts may still call it from `onSwap`) but
|
|
55
|
+
// is a no-op under the MF runtime.
|
|
62
56
|
|
|
63
57
|
import { useMemo, useRef, useState } from 'react'
|
|
64
58
|
import {
|
|
@@ -311,30 +305,23 @@ export function withVersionParam(url: string, hash: string | undefined): string
|
|
|
311
305
|
}
|
|
312
306
|
|
|
313
307
|
/**
|
|
314
|
-
*
|
|
315
|
-
*
|
|
316
|
-
*
|
|
317
|
-
*
|
|
318
|
-
*
|
|
319
|
-
* Best-effort: if `window` is undefined (SSR) or the scope was never
|
|
320
|
-
* registered, this is a no-op. Returns `true` if a container was actually
|
|
321
|
-
* removed, `false` otherwise — useful for telemetry.
|
|
308
|
+
* @deprecated Legacy `@originjs/vite-plugin-federation` helper. Under the
|
|
309
|
+
* current `@module-federation/runtime` loader ({@link AddonLoader}), container
|
|
310
|
+
* replacement on hot-swap is handled by `registerRemotes(..., { force: true })`
|
|
311
|
+
* with the new `?v=` URL — there is no `window[scope]` container to delete.
|
|
322
312
|
*
|
|
323
|
-
*
|
|
324
|
-
*
|
|
325
|
-
*
|
|
326
|
-
*
|
|
313
|
+
* Kept for backward compatibility so existing host `onSwap` wiring keeps
|
|
314
|
+
* compiling. Best-effort: removes a stale `window[scope]` if a legacy
|
|
315
|
+
* `@originjs` remote left one behind, otherwise a no-op. Returns `true` if a
|
|
316
|
+
* value was removed, `false` otherwise.
|
|
327
317
|
*/
|
|
328
318
|
export function clearFederationContainer(scope: string): boolean {
|
|
329
319
|
if (typeof window === 'undefined') return false
|
|
330
|
-
if (!(scope in window)) return false
|
|
320
|
+
if (!(scope in (window as Record<string, unknown>))) return false
|
|
331
321
|
try {
|
|
332
322
|
delete (window as Record<string, unknown>)[scope]
|
|
333
323
|
return true
|
|
334
324
|
} catch {
|
|
335
|
-
// Some browsers refuse to delete non-configurable globals. Set
|
|
336
|
-
// to undefined as a fallback so the loader's `if (!window[scope])`
|
|
337
|
-
// check still triggers a re-inject.
|
|
338
325
|
;(window as Record<string, unknown>)[scope] = undefined
|
|
339
326
|
return true
|
|
340
327
|
}
|