@asteby/metacore-runtime-react 31.1.0 → 32.0.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.
@@ -0,0 +1,111 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+ import {
3
+ composeDisposables,
4
+ disposableFromRegisterResult,
5
+ isAddonFrontendCacheUrl,
6
+ resolvePluginExports,
7
+ runDispose,
8
+ shouldReregisterRemote,
9
+ markRemoteRegistered,
10
+ resetRemoteRegistry,
11
+ } from '../addon-fiber'
12
+
13
+ describe('isAddonFrontendCacheUrl', () => {
14
+ it('matches kernel and legacy frontend paths for that addon only', () => {
15
+ expect(
16
+ isAddonFrontendCacheUrl(
17
+ 'https://app.example/api/metacore/addons/pos/frontend/remoteEntry.js?v=abc',
18
+ 'pos',
19
+ ),
20
+ ).toBe(true)
21
+ expect(
22
+ isAddonFrontendCacheUrl(
23
+ 'https://app.example/api/addons/pos/frontend.js',
24
+ 'pos',
25
+ ),
26
+ ).toBe(true)
27
+ expect(
28
+ isAddonFrontendCacheUrl(
29
+ 'https://app.example/api/metacore/addons/kds/frontend/remoteEntry.js',
30
+ 'pos',
31
+ ),
32
+ ).toBe(false)
33
+ expect(
34
+ isAddonFrontendCacheUrl(
35
+ 'https://app.example/api/metadata/pos',
36
+ 'pos',
37
+ ),
38
+ ).toBe(false)
39
+ })
40
+ })
41
+
42
+ describe('resolvePluginExports', () => {
43
+ it('reads a named register export', () => {
44
+ const register = vi.fn()
45
+ expect(resolvePluginExports({ register }).register).toBe(register)
46
+ })
47
+
48
+ it('reads definePlugin default object', () => {
49
+ const register = vi.fn()
50
+ const dispose = vi.fn()
51
+ const resolved = resolvePluginExports({
52
+ default: { key: 'pos', register, dispose },
53
+ })
54
+ expect(resolved.register).toBeTypeOf('function')
55
+ expect(resolved.dispose).toBeTypeOf('function')
56
+ })
57
+
58
+ it('reads a default function module', () => {
59
+ const register = vi.fn()
60
+ expect(resolvePluginExports({ default: register }).register).toBe(register)
61
+ })
62
+
63
+ it('returns empty for null', () => {
64
+ expect(resolvePluginExports(null)).toEqual({})
65
+ })
66
+ })
67
+
68
+ describe('composeDisposables', () => {
69
+ it('runs disposers in reverse order (Cordis effects)', async () => {
70
+ const order: number[] = []
71
+ const d = composeDisposables(
72
+ () => {
73
+ order.push(1)
74
+ },
75
+ () => {
76
+ order.push(2)
77
+ },
78
+ )
79
+ await d()
80
+ expect(order).toEqual([2, 1])
81
+ })
82
+ })
83
+
84
+ describe('runDispose', () => {
85
+ it('swallows disposer errors', async () => {
86
+ await expect(
87
+ runDispose(() => {
88
+ throw new Error('boom')
89
+ }),
90
+ ).resolves.toBeUndefined()
91
+ })
92
+ })
93
+
94
+ describe('disposableFromRegisterResult', () => {
95
+ it('returns functions and ignores void', () => {
96
+ const d = () => {}
97
+ expect(disposableFromRegisterResult(d)).toBe(d)
98
+ expect(disposableFromRegisterResult(undefined)).toBeUndefined()
99
+ })
100
+ })
101
+
102
+ describe('remote registry', () => {
103
+ it('reregisters only when the entry URL changes', () => {
104
+ resetRemoteRegistry()
105
+ expect(shouldReregisterRemote('metacore_pos', '/r.js?v=1')).toBe(true)
106
+ markRemoteRegistered('metacore_pos', '/r.js?v=1')
107
+ expect(shouldReregisterRemote('metacore_pos', '/r.js?v=1')).toBe(false)
108
+ expect(shouldReregisterRemote('metacore_pos', '/r.js?v=2')).toBe(true)
109
+ resetRemoteRegistry()
110
+ })
111
+ })
@@ -0,0 +1,146 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { buildPrefillRows, isPrefillSpec, applyPrefillLock, type PrefillSpec } from '../action-modal-dispatcher'
3
+ import type { ActionFieldDef } from '../types'
4
+
5
+ // receive-goods-style item_fields: the canonical use case ($prefillFromRecord
6
+ // + map + remaining + lock), same shape inventory's receive_transfer and
7
+ // purchases' receive_goods declare in their manifest.json.
8
+ const receiveField = (overrides: Partial<ActionFieldDef> = {}): ActionFieldDef => ({
9
+ key: 'lines',
10
+ label: 'Renglones',
11
+ type: 'array',
12
+ itemFields: [
13
+ { key: 'product_id', label: 'Producto', type: 'dynamic_select', ref: 'Product' },
14
+ { key: 'ordered', label: 'Ordenado', type: 'number' },
15
+ { key: 'received_so_far', label: 'Ya recibido', type: 'number' },
16
+ { key: 'qty_received', label: 'Cantidad recibida', type: 'number', required: true },
17
+ ],
18
+ ...overrides,
19
+ })
20
+
21
+ describe('isPrefillSpec', () => {
22
+ it('reconoce un objeto con $prefillFromRecord como PrefillSpec', () => {
23
+ expect(isPrefillSpec({ $prefillFromRecord: 'items' })).toBe(true)
24
+ })
25
+
26
+ it('rechaza un default literal (string/number) o un objeto sin $prefillFromRecord', () => {
27
+ expect(isPrefillSpec('walk-in')).toBe(false)
28
+ expect(isPrefillSpec(42)).toBe(false)
29
+ expect(isPrefillSpec(null)).toBe(false)
30
+ expect(isPrefillSpec(undefined)).toBe(false)
31
+ expect(isPrefillSpec({ map: { a: 'b' } })).toBe(false)
32
+ })
33
+ })
34
+
35
+ describe('buildPrefillRows', () => {
36
+ it('proyecta record[$prefillFromRecord] a filas usando map', () => {
37
+ const spec: PrefillSpec = {
38
+ $prefillFromRecord: 'items',
39
+ map: { product_id: 'product_id', ordered: 'quantity' },
40
+ }
41
+ const record = {
42
+ items: [
43
+ { product_id: 'p1', quantity: 10 },
44
+ { product_id: 'p2', quantity: 5 },
45
+ ],
46
+ }
47
+ expect(buildPrefillRows(spec, record)).toEqual([
48
+ { product_id: 'p1', ordered: 10 },
49
+ { product_id: 'p2', ordered: 5 },
50
+ ])
51
+ })
52
+
53
+ it('calcula remaining.target = of - minus por fila', () => {
54
+ const spec: PrefillSpec = {
55
+ $prefillFromRecord: 'items',
56
+ map: { product_id: 'product_id' },
57
+ remaining: { target: 'qty_received', of: 'quantity', minus: 'received' },
58
+ }
59
+ const record = { items: [{ product_id: 'p1', quantity: 10, received: 4 }] }
60
+ expect(buildPrefillRows(spec, record)).toEqual([{ product_id: 'p1', qty_received: 6 }])
61
+ })
62
+
63
+ it('con remaining.minus omitido, remaining = of tal cual (minus lee como 0)', () => {
64
+ const spec: PrefillSpec = {
65
+ $prefillFromRecord: 'items',
66
+ remaining: { target: 'qty_received', of: 'quantity' },
67
+ }
68
+ const record = { items: [{ quantity: 7 }] }
69
+ expect(buildPrefillRows(spec, record)).toEqual([{ qty_received: 7 }])
70
+ })
71
+
72
+ it('omite filas ya satisfechas por completo (remaining <= 0)', () => {
73
+ const spec: PrefillSpec = {
74
+ $prefillFromRecord: 'items',
75
+ map: { product_id: 'product_id' },
76
+ remaining: { target: 'qty_received', of: 'quantity', minus: 'received' },
77
+ }
78
+ const record = {
79
+ items: [
80
+ { product_id: 'p1', quantity: 10, received: 10 }, // satisfecha -> fuera
81
+ { product_id: 'p2', quantity: 10, received: 12 }, // sobre-recibida -> fuera
82
+ { product_id: 'p3', quantity: 10, received: 3 }, // pendiente -> queda
83
+ ],
84
+ }
85
+ expect(buildPrefillRows(spec, record)).toEqual([{ product_id: 'p3', qty_received: 7 }])
86
+ })
87
+
88
+ it('combina map + remaining en el mismo caso real de receive_transfer/receive_goods', () => {
89
+ const spec: PrefillSpec = {
90
+ $prefillFromRecord: 'items',
91
+ map: { product_id: 'product_id', ordered: 'quantity', received_so_far: 'received' },
92
+ remaining: { target: 'qty_received', of: 'quantity', minus: 'received' },
93
+ lock: ['product_id', 'ordered', 'received_so_far'],
94
+ }
95
+ const record = { items: [{ product_id: 'p1', quantity: 10, received: 4 }] }
96
+ expect(buildPrefillRows(spec, record)).toEqual([
97
+ { product_id: 'p1', ordered: 10, received_so_far: 4, qty_received: 6 },
98
+ ])
99
+ })
100
+
101
+ it('registro sin filas (o campo ausente/no-array) da prefill vacío, sin explotar', () => {
102
+ const spec: PrefillSpec = { $prefillFromRecord: 'items' }
103
+ expect(buildPrefillRows(spec, { items: [] })).toEqual([])
104
+ expect(buildPrefillRows(spec, {})).toEqual([])
105
+ expect(buildPrefillRows(spec, { items: 'not-an-array' })).toEqual([])
106
+ expect(buildPrefillRows(spec, null)).toEqual([])
107
+ })
108
+
109
+ it('ignora entradas no-objeto dentro del array origen', () => {
110
+ const spec: PrefillSpec = { $prefillFromRecord: 'items', map: { product_id: 'product_id' } }
111
+ const record = { items: [null, { product_id: 'p1' }, undefined, 42] }
112
+ expect(buildPrefillRows(spec, record)).toEqual([{ product_id: 'p1' }])
113
+ })
114
+ })
115
+
116
+ describe('applyPrefillLock', () => {
117
+ it('marca readonly las columnas listadas en lock', () => {
118
+ const field = receiveField({
119
+ default: {
120
+ $prefillFromRecord: 'items',
121
+ lock: ['product_id', 'ordered', 'received_so_far'],
122
+ } as PrefillSpec,
123
+ } as Partial<ActionFieldDef>)
124
+ const patched = applyPrefillLock(field) as ActionFieldDef & { itemFields?: any[] }
125
+ const byKey = Object.fromEntries((patched.itemFields ?? []).map((c: any) => [c.key, c]))
126
+ expect(byKey.product_id.readonly).toBe(true)
127
+ expect(byKey.ordered.readonly).toBe(true)
128
+ expect(byKey.received_so_far.readonly).toBe(true)
129
+ expect(byKey.qty_received.readonly).toBeUndefined()
130
+ })
131
+
132
+ it('sin lock (o sin prefill spec) deja el field intacto', () => {
133
+ const plain = receiveField()
134
+ expect(applyPrefillLock(plain)).toBe(plain)
135
+
136
+ const noLock = receiveField({
137
+ default: { $prefillFromRecord: 'items' } as PrefillSpec,
138
+ } as Partial<ActionFieldDef>)
139
+ expect(applyPrefillLock(noLock)).toBe(noLock)
140
+ })
141
+
142
+ it('un default literal (no PrefillSpec) deja el field intacto', () => {
143
+ const field = receiveField({ default: 'walk-in' } as Partial<ActionFieldDef>)
144
+ expect(applyPrefillLock(field)).toBe(field)
145
+ })
146
+ })
@@ -81,7 +81,7 @@ export type { ActionMetadata, ActionModalProps }
81
81
  // "map": { "product_id": "product_id" },
82
82
  // "remaining": { "target": "qty_received", "of": "quantity", "minus": "received" }
83
83
  // }
84
- interface PrefillSpec {
84
+ export interface PrefillSpec {
85
85
  $prefillFromRecord: string
86
86
  map?: Record<string, string>
87
87
  remaining?: { target: string; of: string; minus?: string }
@@ -94,7 +94,7 @@ interface PrefillSpec {
94
94
  lock?: string[]
95
95
  }
96
96
 
97
- function isPrefillSpec(v: unknown): v is PrefillSpec {
97
+ export function isPrefillSpec(v: unknown): v is PrefillSpec {
98
98
  return (
99
99
  typeof v === 'object' &&
100
100
  v !== null &&
@@ -122,7 +122,7 @@ function toNum(v: unknown): number {
122
122
  // untouched when there is no prefill spec or no lock list (the create flow,
123
123
  // which carries no prefill, stays fully editable). The readonly flag is set on
124
124
  // BOTH itemFields aliases the renderers tolerate.
125
- function applyPrefillLock(field: ActionFieldDef): ActionFieldDef {
125
+ export function applyPrefillLock(field: ActionFieldDef): ActionFieldDef {
126
126
  const spec = lineItemsDefault(field)
127
127
  if (!isPrefillSpec(spec) || !spec.lock || spec.lock.length === 0) return field
128
128
  const lock = new Set(spec.lock)
@@ -134,7 +134,7 @@ function applyPrefillLock(field: ActionFieldDef): ActionFieldDef {
134
134
  }
135
135
 
136
136
  // buildPrefillRows projects record[spec.$prefillFromRecord] into modal rows.
137
- function buildPrefillRows(spec: PrefillSpec, record: any): Array<Record<string, any>> {
137
+ export function buildPrefillRows(spec: PrefillSpec, record: any): Array<Record<string, any>> {
138
138
  const src = record?.[spec.$prefillFromRecord]
139
139
  if (!Array.isArray(src)) return []
140
140
  const rows: Array<Record<string, any>> = []
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Addon fiber primitives — Cordis-style dispose/reload for federated modules
3
+ * running inside a PWA host. Pure helpers so they unit-test without React
4
+ * or the Module Federation runtime.
5
+ */
6
+
7
+ import type { AddonAPI, Disposable, Plugin } from '@asteby/metacore-sdk'
8
+
9
+ export const PURGE_ADDON_MESSAGE = 'PURGE_ADDON' as const
10
+
11
+ export interface PurgeAddonMessage {
12
+ type: typeof PURGE_ADDON_MESSAGE
13
+ key: string
14
+ }
15
+
16
+ /** Shape of the exposed `./register` (or `./plugin`) module. */
17
+ export interface AddonRegisterModule {
18
+ register?: Plugin['register']
19
+ dispose?: Plugin['dispose']
20
+ default?: Plugin['register'] | Plugin
21
+ }
22
+
23
+ export interface ResolvedPlugin {
24
+ register?: Plugin['register']
25
+ dispose?: Plugin['dispose']
26
+ }
27
+
28
+ /**
29
+ * Accept every historical export shape:
30
+ * - `{ register, dispose? }`
31
+ * - `{ default: { register, dispose? } }` (definePlugin)
32
+ * - `{ default: (api) => ... }` (function module)
33
+ */
34
+ export function resolvePluginExports(mod: AddonRegisterModule | null | undefined): ResolvedPlugin {
35
+ if (!mod) return {}
36
+ const fromDefault =
37
+ mod.default && typeof mod.default === 'object'
38
+ ? (mod.default as Plugin)
39
+ : undefined
40
+ const registerFn =
41
+ typeof mod.register === 'function'
42
+ ? mod.register
43
+ : typeof fromDefault?.register === 'function'
44
+ ? fromDefault.register.bind(fromDefault)
45
+ : typeof mod.default === 'function'
46
+ ? mod.default
47
+ : undefined
48
+ const disposeFn =
49
+ typeof mod.dispose === 'function'
50
+ ? mod.dispose
51
+ : typeof fromDefault?.dispose === 'function'
52
+ ? fromDefault.dispose.bind(fromDefault)
53
+ : undefined
54
+ return { register: registerFn, dispose: disposeFn }
55
+ }
56
+
57
+ export async function runDispose(dispose?: Disposable | null): Promise<void> {
58
+ if (typeof dispose !== 'function') return
59
+ try {
60
+ await Promise.resolve(dispose())
61
+ } catch {
62
+ /* a failing disposer must not block the next fiber mount */
63
+ }
64
+ }
65
+
66
+ export function composeDisposables(...fns: Array<Disposable | undefined | null>): Disposable {
67
+ const list = fns.filter((f): f is Disposable => typeof f === 'function')
68
+ return async () => {
69
+ for (let i = list.length - 1; i >= 0; i--) {
70
+ await runDispose(list[i])
71
+ }
72
+ }
73
+ }
74
+
75
+ /**
76
+ * True when a Cache API / SW request is a federated frontend asset of `addonKey`.
77
+ * Matches both `/api/addons/<key>/frontend` and `/api/metacore/addons/<key>/frontend`.
78
+ */
79
+ export function isAddonFrontendCacheUrl(url: string, addonKey: string): boolean {
80
+ if (!addonKey || !url) return false
81
+ try {
82
+ const path = new URL(url, 'http://local.invalid').pathname
83
+ const escaped = addonKey.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
84
+ return new RegExp(`/api/(metacore/)?addons/${escaped}/frontend(/|\\.js)`).test(path)
85
+ } catch {
86
+ return false
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Drop cached federation assets for one addon. Primary path is the page-side
92
+ * Cache API (works even before the SW learns `PURGE_ADDON`). Also posts the
93
+ * message so a current SW can drop its own entries.
94
+ *
95
+ * Never unregisters the SW and never deletes other caches — L1, not L2.
96
+ */
97
+ export async function purgeAddonFrontendCache(addonKey: string): Promise<void> {
98
+ if (!addonKey) return
99
+ try {
100
+ if (typeof caches !== 'undefined') {
101
+ const names = await caches.keys()
102
+ await Promise.all(
103
+ names.map(async (name) => {
104
+ if (!name.includes('addon-federation')) return
105
+ const cache = await caches.open(name)
106
+ const requests = await cache.keys()
107
+ await Promise.all(
108
+ requests
109
+ .filter((req) => isAddonFrontendCacheUrl(req.url, addonKey))
110
+ .map((req) => cache.delete(req)),
111
+ )
112
+ }),
113
+ )
114
+ }
115
+ } catch {
116
+ /* private mode / no Cache API */
117
+ }
118
+ try {
119
+ if (typeof navigator !== 'undefined' && 'serviceWorker' in navigator) {
120
+ const controller = navigator.serviceWorker.controller
121
+ controller?.postMessage({ type: PURGE_ADDON_MESSAGE, key: addonKey } satisfies PurgeAddonMessage)
122
+ }
123
+ } catch {
124
+ /* no SW */
125
+ }
126
+ }
127
+
128
+ /** Test seam: which remote URL is currently registered per federation scope. */
129
+ export const remoteEntryByScope = new Map<string, string>()
130
+
131
+ export function shouldReregisterRemote(scope: string, url: string): boolean {
132
+ return remoteEntryByScope.get(scope) !== url
133
+ }
134
+
135
+ export function markRemoteRegistered(scope: string, url: string): void {
136
+ remoteEntryByScope.set(scope, url)
137
+ }
138
+
139
+ /** @internal tests */
140
+ export function resetRemoteRegistry(): void {
141
+ remoteEntryByScope.clear()
142
+ }
143
+
144
+ /**
145
+ * Safe no-op when `register()` returns void. Used by AddonLoader to treat
146
+ * both historical and fiber-style plugins uniformly.
147
+ */
148
+ export function disposableFromRegisterResult(
149
+ result: void | Disposable,
150
+ ): Disposable | undefined {
151
+ return typeof result === 'function' ? result : undefined
152
+ }
153
+
154
+ /** Type-only re-export so AddonLoader does not import Plugin internals twice. */
155
+ export type { AddonAPI, Disposable }
@@ -3,17 +3,26 @@
3
3
  // `remoteEntry.js` as an ESM container, loads the exposed `./register` module,
4
4
  // and calls `register(api)` with the AddonAPI injected by the host.
5
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.
6
+ // Fiber lifecycle (Cordis-style): when `url` (the `?v=` cache-bust) changes,
7
+ // the previous plugin.dispose / returned Disposable run, the host registry
8
+ // unbinds that addonKey, the SW addon-federation cache for that key is
9
+ // purged, and register() runs again against the new remote. The host shell
10
+ // (auth, QueryClient, WebSocket, service worker controller) is not touched.
13
11
  import { useEffect, useRef, useState } from 'react'
14
12
  import { registerRemotes, loadRemote } from '@module-federation/runtime'
15
- import type { AddonAPI, AddonLayout } from '@asteby/metacore-sdk'
13
+ import type { AddonAPI, AddonLayout, Registry } from '@asteby/metacore-sdk'
16
14
  import { useDeclareAddonLayout } from './addon-layout-context'
15
+ import {
16
+ composeDisposables,
17
+ disposableFromRegisterResult,
18
+ markRemoteRegistered,
19
+ purgeAddonFrontendCache,
20
+ resolvePluginExports,
21
+ runDispose,
22
+ shouldReregisterRemote,
23
+ type AddonRegisterModule,
24
+ type Disposable,
25
+ } from './addon-fiber'
17
26
 
18
27
  export interface AddonLoaderProps {
19
28
  /** Unique key of the addon — maps to the federation container name. */
@@ -24,9 +33,25 @@ export interface AddonLoaderProps {
24
33
  module?: string
25
34
  /** Host-provided API passed to the addon's register() call. */
26
35
  api: AddonAPI
36
+ /**
37
+ * Host registry used to {@link Registry.unbind} this addon's contributions
38
+ * on dispose. Optional so legacy hosts keep compiling; without it, fiber
39
+ * remounts leak routes/actions.
40
+ */
41
+ hostRegistry?: Registry
42
+ /**
43
+ * Addon key for SW cache purge. Defaults to `api.manifest.key`.
44
+ */
45
+ addonKey?: string
46
+ /**
47
+ * Registry owner passed to {@link Registry.unbind}. Defaults to `addonKey`.
48
+ * Immersive `./plugin` fibers use `${key}::view` so they don't wipe the
49
+ * shell `./register` contributions of the same addon.
50
+ */
51
+ unbindKey?: string
27
52
  /** Optional rendering while loading. */
28
53
  fallback?: React.ReactNode
29
- /** Called once the addon has successfully registered. */
54
+ /** Called once the addon has successfully registered (including re-register). */
30
55
  onReady?: () => void
31
56
  /** Called if loading fails. */
32
57
  onError?: (err: Error) => void
@@ -45,17 +70,6 @@ export interface AddonLoaderProps {
45
70
  children?: React.ReactNode
46
71
  }
47
72
 
48
- /** Shape of the exposed `./register` module. */
49
- interface AddonRegisterModule {
50
- register?: (api: AddonAPI) => void | Promise<void>
51
- default?: (api: AddonAPI) => void | Promise<void>
52
- }
53
-
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
73
  // Derive the `loadRemote` id from the scope + exposed module name. MF resolves
60
74
  // `"<remoteName>/<expose>"` — e.g. `metacore_tickets/register` for the
61
75
  // `"./register"` expose. We strip the leading `./` of the expose path.
@@ -100,28 +114,15 @@ async function loadAddon(
100
114
  url: string,
101
115
  module: string,
102
116
  ): Promise<AddonRegisterModule | null> {
103
- // Register the remote container as an ES module. `type: 'module'` matches
104
- // the `@module-federation/vite` remote (remoteEntry.js is an ESM bundle).
105
- // The `url` already carries the `?v=` cache-bust the host computed, so the
106
- // browser refetches a fresh remoteEntry when the addon version changes.
107
- //
108
- // Both calls are wrapped in `withRuntimeReady` because EITHER can throw
109
- // RUNTIME-009 when an addon mounts ahead of the host's federation init —
110
- // registration is what actually touches the (maybe-uninitialised) runtime.
111
- if (!registered.has(scope)) {
117
+ // Re-register whenever the `?v=` URL changes so a fiber swap actually
118
+ // fetches the new remoteEntry. `force: true` wipes that remote's module
119
+ // cache without it, loadRemote would keep serving the previous bundle.
120
+ if (shouldReregisterRemote(scope, url)) {
112
121
  await withRuntimeReady(() =>
113
- registerRemotes(
114
- [{ name: scope, entry: url, type: 'module' }],
115
- // `force: true` so a re-registration with a new `?v=` URL (addon
116
- // hot-swap / version bump) overwrites the stale entry + cache.
117
- { force: true },
118
- ),
122
+ registerRemotes([{ name: scope, entry: url, type: 'module' }], { force: true }),
119
123
  )
120
- registered.add(scope)
124
+ markRemoteRegistered(scope, url)
121
125
  }
122
- // loadRemote("<scope>/<expose>") returns the exposed module namespace (or
123
- // null if it can't be resolved). No manual share-scope init — the host's
124
- // federation runtime already initialised it.
125
126
  return withRuntimeReady(() =>
126
127
  loadRemote<AddonRegisterModule>(remoteId(scope, module)),
127
128
  )
@@ -132,6 +133,9 @@ export function AddonLoader({
132
133
  url,
133
134
  module = './register',
134
135
  api,
136
+ hostRegistry,
137
+ addonKey,
138
+ unbindKey,
135
139
  fallback = null,
136
140
  onReady,
137
141
  onError,
@@ -140,7 +144,7 @@ export function AddonLoader({
140
144
  }: AddonLoaderProps) {
141
145
  const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading')
142
146
  const [error, setError] = useState<Error | null>(null)
143
- const didRegister = useRef(false)
147
+ const disposeRef = useRef<Disposable | undefined>(undefined)
144
148
 
145
149
  // Propagate the addon's preferred layout to the host shell via context.
146
150
  // No-op when `layout` is undefined or `"shell"` (legacy default). Cleanup
@@ -150,20 +154,28 @@ export function AddonLoader({
150
154
 
151
155
  useEffect(() => {
152
156
  let cancelled = false
157
+ const key = addonKey || api.manifest?.key
158
+ const owner = unbindKey || key
153
159
  ;(async () => {
154
160
  try {
161
+ setStatus('loading')
162
+ if (key) await purgeAddonFrontendCache(key)
155
163
  const mod = await loadAddon(scope, url, module)
156
164
  if (cancelled) return
157
- const register = mod?.register ?? mod?.default
158
- if (typeof register !== 'function') {
165
+ const plugin = resolvePluginExports(mod)
166
+ if (typeof plugin.register !== 'function') {
159
167
  throw new Error(
160
168
  `Addon "${scope}" module "${module}" has no register() export`,
161
169
  )
162
170
  }
163
- if (!didRegister.current) {
164
- didRegister.current = true
165
- await Promise.resolve(register(api))
166
- }
171
+ // Drop leftover contributions from a previous fiber of this key
172
+ // before the new register() runs (idempotent if none).
173
+ if (owner && hostRegistry) hostRegistry.unbind(owner)
174
+ const ret = await Promise.resolve(plugin.register(api))
175
+ disposeRef.current = composeDisposables(
176
+ disposableFromRegisterResult(ret),
177
+ plugin.dispose,
178
+ )
167
179
  setStatus('ready')
168
180
  onReady?.()
169
181
  } catch (e: unknown) {
@@ -176,8 +188,15 @@ export function AddonLoader({
176
188
  })()
177
189
  return () => {
178
190
  cancelled = true
191
+ const d = disposeRef.current
192
+ disposeRef.current = undefined
193
+ void runDispose(d)
194
+ if (owner && hostRegistry) hostRegistry.unbind(owner)
179
195
  }
180
- }, [scope, url, module])
196
+ // api identity is expected to be stable per addon; including it would
197
+ // re-register on every parent render. Fiber identity is (scope, url, module).
198
+ // eslint-disable-next-line react-hooks/exhaustive-deps
199
+ }, [scope, url, module, addonKey, unbindKey, hostRegistry])
181
200
 
182
201
  if (status === 'loading') return <>{fallback}</>
183
202
  if (status === 'error')
package/src/index.ts CHANGED
@@ -122,6 +122,17 @@ export {
122
122
  type ActionPlacement,
123
123
  } from './model-action-toolbar'
124
124
  export * from './addon-loader'
125
+ export {
126
+ PURGE_ADDON_MESSAGE,
127
+ isAddonFrontendCacheUrl,
128
+ purgeAddonFrontendCache,
129
+ resolvePluginExports,
130
+ composeDisposables,
131
+ runDispose,
132
+ type PurgeAddonMessage,
133
+ type AddonRegisterModule,
134
+ type ResolvedPlugin,
135
+ } from './addon-fiber'
125
136
  export {
126
137
  AddonLayoutProvider,
127
138
  useAddonLayout,