// hotswap-reload-policy — closes the last piece of RFC-0001 D4 ("zero-polling // hot-swap") on the client. The {@link useManifestHotSwapSubscriber} hook // already invalidates the metadata cache when the kernel announces a manifest // change, but the federation container of an already-mounted addon keeps the // old code in memory. This module picks one of three policies for forcing the // new code to take effect: // // 1. `"rekey"` — the default, **recommended for most apps**. Maintains a // reactive map `addonKey → hashShort` that the host wires into // ``. When a swap arrives, the hash flips, // React unmounts and remounts the addon subtree, which causes the // federation loader to re-fetch `remoteEntry.js` (cache-busted via // {@link withVersionParam}) and re-evaluate the exposed module. State // inside the addon is lost — **intentional**, because the code version // changed and stale closures over old props/state would be a footgun. // // 2. `"page-reload"` — `window.location.reload()`. Opt-in escape hatch // for immersive addons with critical state (POS with an order in // progress, kitchen-display with partial confirmations). Pair with // `onBeforeReload` to surface an "unsaved changes" prompt before // blowing the page away. Returning `false` from `onBeforeReload` // cancels the reload. // // 3. `"manual"` — the hook only invokes the host-provided `onSwap` // callback. The host decides what to do (e.g. show a toast: "New // version available — reload when ready"). No automatic remount, no // reload. The `addonVersionMap` is still updated so a host that wires // `` later in the lifecycle picks up the new // hash on demand. // // ## Wiring example // // ```tsx // // Host shell (4-5 line wire-up): // const ws = useWebSocket() // useManifestHotSwapSubscriber(ws) // invalidates metadata // const { addonVersionMap } = useHotSwapReload({ strategy: "rekey" }) // // …in your router: // // // // ``` // // ## Federation runtime caveat // // The `"rekey"` strategy re-fetches `remoteEntry.js` with a `?v=` query // suffix (see {@link withVersionParam}). With `@module-federation/runtime`, the // {@link AddonLoader} re-registers the remote on every rekey remount via // `registerRemotes([{ name, entry: }], { force: true })`. The // `force` flag overwrites the previously registered container AND wipes that // remote's loaded-module cache, so the new code takes effect without any manual // `window[scope]` deletion — the old `@originjs` requirement to `delete // window[Container]` no longer applies. {@link clearFederationContainer} is kept // for backward compatibility (legacy hosts may still call it from `onSwap`) but // is a no-op under the MF runtime. import { useMemo, useRef, useState } from 'react' import { useManifestHotSwapSubscriber, type AddonManifestChangedMessage, type ManifestHotSwapClient, type WireHotSwapInvalidationOptions, } from './manifest-hotswap-subscriber' /** * One of three strategies for reacting to an `ADDON_MANIFEST_CHANGED` event: * * * `"rekey"` — re-mount the addon route by flipping the key. Default. * * `"page-reload"` — `window.location.reload()`. Opt-in. * * `"manual"` — no automatic action; the host handles it via `onSwap`. */ export type HotSwapReloadStrategy = 'rekey' | 'page-reload' | 'manual' /** * Config for {@link useHotSwapReload}. `strategy` is the only required field * — pass `{ strategy: "rekey" }` for the default behaviour or omit the * config entirely. */ export interface HotSwapReloadConfig { /** Reload policy. See {@link HotSwapReloadStrategy}. */ strategy?: HotSwapReloadStrategy /** * Optional gate invoked **before** the reload action fires. Return * `false` (or a Promise resolving to `false`) to cancel — useful for * "unsaved changes" prompts on immersive addons. Receives the original * `ADDON_MANIFEST_CHANGED` message so the prompt can name the addon. * * Runs for `"page-reload"` (cancels the `window.location.reload()`) * and `"rekey"` (cancels the version bump, leaving the addon mounted * with the old code — the host can re-trigger the swap later by * re-calling the hook output's `reload()` method). * * Ignored for `"manual"` — the host owns the reload there. */ onBeforeReload?: ( event: AddonManifestChangedMessage, ) => boolean | Promise /** * Side-effect hook invoked after the policy has run (or after * `onBeforeReload` returned `false`). Receives the message and the * effective action that was taken: `"rekey"`, `"page-reload"`, * `"cancelled"` or `"manual"`. Hosts wire telemetry / toasts here. */ onSwap?: ( event: AddonManifestChangedMessage, action: 'rekey' | 'page-reload' | 'cancelled' | 'manual', ) => void /** * Optional matcher forwarded to the underlying * {@link useManifestHotSwapSubscriber} for cache invalidation. */ matcher?: WireHotSwapInvalidationOptions['matcher'] } export interface UseHotSwapReloadResult { /** * Reactive map `addonKey → hashShort`. Stable identity per render * (only changes when a swap lands). Wire it into * `` so React * re-keys the subtree on hash change. * * Missing entries return `undefined`; the AddonRoute treats that as * "no version pinned yet" and keeps a stable key. */ addonVersionMap: Record } /** * Subscribe to manifest hot-swap events and apply a reload policy. * * **Strategy = `"rekey"` (default):** * maintains `addonVersionMap` so `` re-keys * the subtree on every swap. The federation loader picks the new hash * up via {@link withVersionParam}, fetches a fresh `remoteEntry.js`, * and registers a new container. * * **Strategy = `"page-reload"` (opt-in):** * calls `onBeforeReload` (if supplied); if it resolves truthy, * `window.location.reload()` fires. The `addonVersionMap` is still * updated for callers that want to mirror it elsewhere. * * **Strategy = `"manual"`:** * no automatic action. The `onSwap` callback fires with `"manual"`; * the host decides what to do. `addonVersionMap` is updated so a * later opt-in remount picks up the right hash. * * @example * const ws = useWebSocket() * useManifestHotSwapSubscriber(ws) // invalidates metadata cache * const { addonVersionMap } = useHotSwapReload({ strategy: 'rekey' }) * // …in your router: * * * */ /** * Effect that {@link applyHotSwapReload} can take. Useful as a discriminator * for tests and telemetry callbacks. `"noop"` is emitted when a malformed * message is ignored (e.g. missing `addonKey`). */ export type HotSwapReloadAction = | 'rekey' | 'page-reload' | 'cancelled' | 'manual' | 'noop' export interface HotSwapReloadDeps { /** Hash → versionMap setter. Receives an updater fn, à la React state. */ setVersionMap: ( updater: (prev: Record) => Record, ) => void /** Defaults to `window.location.reload`. Overridable for tests / SSR. */ reload?: () => void } /** * Pure (testable) implementation of the swap handler. Decides the action * given a message + config + deps, applies side effects via `deps`, and * returns the action it took so callers can fire telemetry. * * Exported for unit tests; the React hook below composes it with React * state. Hosts that want to drive the policy from a non-React context * (e.g. a vanilla web component shell) can call this directly. */ export async function applyHotSwapReload( message: AddonManifestChangedMessage, config: HotSwapReloadConfig, deps: HotSwapReloadDeps, ): Promise { const strategy: HotSwapReloadStrategy = config.strategy ?? 'rekey' const addonKey = message.payload?.addonKey if (!addonKey) return 'noop' const shortHash = shortenHash(message.payload?.newHash) if (strategy === 'manual') { if (shortHash) { deps.setVersionMap((m) => ({ ...m, [addonKey]: shortHash })) } config.onSwap?.(message, 'manual') return 'manual' } if (config.onBeforeReload) { const proceed = await Promise.resolve(config.onBeforeReload(message)) if (!proceed) { config.onSwap?.(message, 'cancelled') return 'cancelled' } } if (strategy === 'page-reload') { config.onSwap?.(message, 'page-reload') const reload = deps.reload ?? (typeof window !== 'undefined' ? () => window.location.reload() : undefined) if (reload) { // Defer so any setState before us commits before we tear down. queueMicrotask(reload) } return 'page-reload' } // strategy === "rekey" if (shortHash) { deps.setVersionMap((m) => { if (m[addonKey] === shortHash) return m return { ...m, [addonKey]: shortHash } }) } config.onSwap?.(message, 'rekey') return 'rekey' } export function useHotSwapReload( client: ManifestHotSwapClient | undefined | null, config: HotSwapReloadConfig = {}, ): UseHotSwapReloadResult { const [addonVersionMap, setAddonVersionMap] = useState< Record >({}) // Keep config behind a ref so changing callbacks between renders does // not re-subscribe to the WebSocket / tear down listeners. const configRef = useRef(config) configRef.current = config // Stable handler for the underlying subscriber — reads the latest // config out of the ref every time the WS emits. const handleSwap = useMemo( () => (message: AddonManifestChangedMessage) => { void applyHotSwapReload(message, configRef.current, { setVersionMap: setAddonVersionMap, }) }, [], ) useManifestHotSwapSubscriber(client, { matcher: config.matcher, onSwap: handleSwap, }) return { addonVersionMap } } /** * Append a `?v=` query string to a `remoteEntry.js` URL so the * browser treats it as a distinct resource and bypasses any HTTP / module * cache. Idempotent — calling twice with the same hash returns the same * URL. Preserves existing query params; replaces a previous `v=` entry if * present so successive bumps don't accumulate stale parameters. * * Pure function (no `window` access) — safe to call in SSR. * * @example * withVersionParam('/api/addons/pos/frontend/remoteEntry.js', 'abc123ef') * // → '/api/addons/pos/frontend/remoteEntry.js?v=abc123ef' * * withVersionParam('/r.js?foo=1', 'abc123ef') * // → '/r.js?foo=1&v=abc123ef' * * withVersionParam('/r.js?v=oldhash', 'abc123ef') * // → '/r.js?v=abc123ef' */ export function withVersionParam(url: string, hash: string | undefined): string { if (!hash) return url const short = shortenHash(hash) if (!short) return url const hashIdx = url.indexOf('#') const fragment = hashIdx >= 0 ? url.slice(hashIdx) : '' const base = hashIdx >= 0 ? url.slice(0, hashIdx) : url const qIdx = base.indexOf('?') if (qIdx < 0) return `${base}?v=${short}${fragment}` const head = base.slice(0, qIdx) const query = base.slice(qIdx + 1) // Drop any previous v= entry and re-append. const parts = query .split('&') .filter((p) => p.length > 0 && !p.startsWith('v=')) parts.push(`v=${short}`) return `${head}?${parts.join('&')}${fragment}` } /** * @deprecated Legacy `@originjs/vite-plugin-federation` helper. Under the * current `@module-federation/runtime` loader ({@link AddonLoader}), container * replacement on hot-swap is handled by `registerRemotes(..., { force: true })` * with the new `?v=` URL — there is no `window[scope]` container to delete. * * Kept for backward compatibility so existing host `onSwap` wiring keeps * compiling. Best-effort: removes a stale `window[scope]` if a legacy * `@originjs` remote left one behind, otherwise a no-op. Returns `true` if a * value was removed, `false` otherwise. */ export function clearFederationContainer(scope: string): boolean { if (typeof window === 'undefined') return false if (!(scope in (window as Record))) return false try { delete (window as Record)[scope] return true } catch { ;(window as Record)[scope] = undefined return true } } /** * Normalise a manifest hash for cache-busting. Accepts the full kernel * format (`sha256:abc...`), a bare hex digest, or `undefined`. Returns * an 8-character lowercase prefix that's short enough to keep URLs * readable while remaining collision-resistant across realistic addon * versioning timelines. * * Exported for tests; hosts that want the full hash for their own * telemetry should read `message.payload.newHash` directly. */ export function shortenHash(hash: string | undefined): string | undefined { if (!hash) return undefined const colonIdx = hash.indexOf(':') const digest = colonIdx >= 0 ? hash.slice(colonIdx + 1) : hash const trimmed = digest.trim() if (!trimmed) return undefined return trimmed.slice(0, 8).toLowerCase() }