@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 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
@@ -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":"AAIA,OAAO,KAAK,EAAE,QAAQ,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAA;AAGjE,OAAO,CAAC,MAAM,CAAC;IACX,UAAU,MAAM;QACZ,CAAC,GAAG,EAAE,MAAM,GAAG,GAAG,CAAA;QAClB,wBAAwB,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAA;QAC3D,wBAAwB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;KACrD;CACJ;AAED,MAAM,WAAW,gBAAgB;IAC7B,uEAAuE;IACvE,KAAK,EAAE,MAAM,CAAA;IACb,gDAAgD;IAChD,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;AAuCD,wBAAgB,WAAW,CAAC,EACxB,KAAK,EACL,GAAG,EACH,MAAqB,EACrB,GAAG,EACH,QAAe,EACf,OAAO,EACP,OAAO,EACP,MAAM,EACN,QAAQ,GACX,EAAE,gBAAgB,2CAsClB"}
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"}
@@ -1,39 +1,46 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- // Minimal federated-module addon loader. Injects a remoteEntry.js <script>,
3
- // waits for the `window[scope]` container to initialize, then calls the
4
- // addon's `register(api)` export with the AddonAPI injected by the host.
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
- const loadedScripts = new Map();
8
- function loadScript(url, scope) {
9
- const key = `${scope}::${url}`;
10
- const existing = loadedScripts.get(key);
11
- if (existing)
12
- return existing;
13
- const promise = new Promise((resolve, reject) => {
14
- const el = document.createElement('script');
15
- el.src = url;
16
- el.type = 'text/javascript';
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 loadRemote(scope, module) {
26
- if (typeof window.__webpack_init_sharing__ === 'function') {
27
- await window.__webpack_init_sharing__('default');
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
- const container = window[scope];
30
- if (!container)
31
- throw new Error(`Addon container "${scope}" not found on window`);
32
- if (typeof container.init === 'function' && window.__webpack_share_scopes__) {
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 loadScript(url, scope);
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
- if (!didRegister.current && typeof mod?.register === 'function') {
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(mod.register(api));
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
- setError(e);
75
+ const err = e instanceof Error ? e : new Error(String(e));
76
+ setError(err);
68
77
  setStatus('error');
69
- onError?.(e);
78
+ onError?.(err);
70
79
  }
71
80
  })();
72
- return () => { cancelled = true; };
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
- * Remove the federation container previously registered on `window[scope]`.
130
- * Hosts call this from `onSwap` before letting the addon route re-mount
131
- * so the next `remoteEntry.js` injection creates a fresh container instead
132
- * of short-circuiting on the cached one.
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
- * Best-effort: if `window` is undefined (SSR) or the scope was never
135
- * registered, this is a no-op. Returns `true` if a container was actually
136
- * removed, `false` otherwise useful for telemetry.
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":"AA+DA,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;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,wBAAwB,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAa/D;AAED;;;;;;;;;GASG;AACH,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CAOxE"}
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}). For the new container to replace the
48
- // old one, the federation loader **must** `delete window[Container]` before
49
- // loading the new script otherwise the cached container object short-
50
- // circuits the loader and you get `Container already registered` style errors
51
- // or, worse, the old code silently keeps running. This module exports
52
- // {@link clearFederationContainer} for that purpose; the host's federation
53
- // loader should call it from its `onSwap` hook.
54
- //
55
- // We deliberately keep the `delete window[Container]` side-effect OUT of this
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
- * Remove the federation container previously registered on `window[scope]`.
177
- * Hosts call this from `onSwap` before letting the addon route re-mount
178
- * so the next `remoteEntry.js` injection creates a fresh container instead
179
- * of short-circuiting on the cached one.
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
- * **Caveat:** some federation runtimes wrap the container in a Proxy
186
- * whose internal state survives `delete`. If you hit `Container already
187
- * registered` after calling this, the federation runtime is holding the
188
- * reference internally and the only reliable swap is `"page-reload"`.
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.0.0",
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": {
@@ -1,22 +1,24 @@
1
- // Minimal federated-module addon loader. Injects a remoteEntry.js <script>,
2
- // waits for the `window[scope]` container to initialize, then calls the
3
- // addon's `register(api)` export with the AddonAPI injected by the host.
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
- const loadedScripts = new Map<string, Promise<void>>()
47
-
48
- function loadScript(url: string, scope: string): Promise<void> {
49
- const key = `${scope}::${url}`
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
- interface FederationContainer {
66
- init: (shareScope: unknown) => Promise<void>
67
- get: (module: string) => Promise<() => any>
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 loadRemote(scope: string, module: string) {
71
- if (typeof window.__webpack_init_sharing__ === 'function') {
72
- await window.__webpack_init_sharing__('default')
73
- }
74
- const container = window[scope] as FederationContainer | undefined
75
- if (!container) throw new Error(`Addon container "${scope}" not found on window`)
76
- if (typeof container.init === 'function' && window.__webpack_share_scopes__) {
77
- await container.init(window.__webpack_share_scopes__.default)
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
- const factory = await container.get(module)
80
- return factory()
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 loadScript(url, scope)
116
+ const mod = await loadAddon(scope, url, module)
109
117
  if (cancelled) return
110
- const mod = await loadRemote(scope, module)
111
- if (cancelled) return
112
- if (!didRegister.current && typeof mod?.register === 'function') {
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(mod.register(api))
126
+ await Promise.resolve(register(api))
115
127
  }
116
128
  setStatus('ready')
117
129
  onReady?.()
118
- } catch (e: any) {
130
+ } catch (e: unknown) {
119
131
  if (cancelled) return
120
- setError(e)
132
+ const err = e instanceof Error ? e : new Error(String(e))
133
+ setError(err)
121
134
  setStatus('error')
122
- onError?.(e)
135
+ onError?.(err)
123
136
  }
124
137
  })()
125
- return () => { cancelled = true }
138
+ return () => {
139
+ cancelled = true
140
+ }
126
141
  }, [scope, url, module])
127
142
 
128
143
  if (status === 'loading') return <>{fallback}</>
129
- if (status === 'error') return <div className="text-sm text-red-500">Addon load error: {error?.message}</div>
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}). For the new container to replace the
48
- // old one, the federation loader **must** `delete window[Container]` before
49
- // loading the new script otherwise the cached container object short-
50
- // circuits the loader and you get `Container already registered` style errors
51
- // or, worse, the old code silently keeps running. This module exports
52
- // {@link clearFederationContainer} for that purpose; the host's federation
53
- // loader should call it from its `onSwap` hook.
54
- //
55
- // We deliberately keep the `delete window[Container]` side-effect OUT of this
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
- * Remove the federation container previously registered on `window[scope]`.
315
- * Hosts call this from `onSwap` before letting the addon route re-mount
316
- * so the next `remoteEntry.js` injection creates a fresh container instead
317
- * of short-circuiting on the cached one.
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
- * **Caveat:** some federation runtimes wrap the container in a Proxy
324
- * whose internal state survives `delete`. If you hit `Container already
325
- * registered` after calling this, the federation runtime is holding the
326
- * reference internally and the only reliable swap is `"page-reload"`.
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
  }