@asteby/metacore-runtime-react 9.1.0 → 10.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.
Files changed (51) hide show
  1. package/CHANGELOG.md +175 -0
  2. package/dist/addon-layout-context.d.ts +49 -0
  3. package/dist/addon-layout-context.d.ts.map +1 -0
  4. package/dist/addon-layout-context.js +94 -0
  5. package/dist/addon-loader.d.ts +14 -2
  6. package/dist/addon-loader.d.ts.map +1 -1
  7. package/dist/addon-loader.js +7 -1
  8. package/dist/dynamic-form-schema.d.ts +5 -0
  9. package/dist/dynamic-form-schema.d.ts.map +1 -1
  10. package/dist/dynamic-form-schema.js +34 -0
  11. package/dist/dynamic-form.d.ts.map +1 -1
  12. package/dist/dynamic-form.js +18 -2
  13. package/dist/dynamic-relation.d.ts.map +1 -1
  14. package/dist/dynamic-relation.js +59 -22
  15. package/dist/hotswap-reload-policy.d.ts +155 -0
  16. package/dist/hotswap-reload-policy.d.ts.map +1 -0
  17. package/dist/hotswap-reload-policy.js +227 -0
  18. package/dist/index.d.ts +6 -0
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +6 -0
  21. package/dist/manifest-hotswap-subscriber.d.ts +83 -0
  22. package/dist/manifest-hotswap-subscriber.d.ts.map +1 -0
  23. package/dist/manifest-hotswap-subscriber.js +104 -0
  24. package/dist/metadata-cache.d.ts +35 -0
  25. package/dist/metadata-cache.d.ts.map +1 -1
  26. package/dist/metadata-cache.js +55 -0
  27. package/dist/types.d.ts +20 -0
  28. package/dist/types.d.ts.map +1 -1
  29. package/dist/use-options-resolver.d.ts +87 -0
  30. package/dist/use-options-resolver.d.ts.map +1 -0
  31. package/dist/use-options-resolver.js +147 -0
  32. package/dist/use-org-config-bridge.d.ts +28 -0
  33. package/dist/use-org-config-bridge.d.ts.map +1 -0
  34. package/dist/use-org-config-bridge.js +50 -0
  35. package/package.json +4 -4
  36. package/src/__tests__/hotswap-reload-policy.test.ts +249 -0
  37. package/src/__tests__/manifest-hotswap-subscriber.test.ts +179 -0
  38. package/src/__tests__/use-options-resolver.test.ts +127 -0
  39. package/src/__tests__/wasm-client-sri.test.ts +82 -0
  40. package/src/addon-layout-context.tsx +137 -0
  41. package/src/addon-loader.tsx +21 -1
  42. package/src/dynamic-form-schema.ts +36 -0
  43. package/src/dynamic-form.tsx +40 -2
  44. package/src/dynamic-relation.tsx +55 -20
  45. package/src/hotswap-reload-policy.ts +360 -0
  46. package/src/index.ts +43 -0
  47. package/src/manifest-hotswap-subscriber.ts +164 -0
  48. package/src/metadata-cache.ts +86 -0
  49. package/src/types.ts +24 -0
  50. package/src/use-options-resolver.ts +232 -0
  51. package/src/use-org-config-bridge.ts +60 -0
@@ -10,6 +10,7 @@ import { Plus, Trash2, Pencil } from 'lucide-react';
10
10
  import { useApi } from './api-context';
11
11
  import { useMetadataCache } from './metadata-cache';
12
12
  import { DynamicForm } from './dynamic-form';
13
+ import { useOptionsResolver } from './use-options-resolver';
13
14
  import { buildCreatePayload, buildPivotAttachPayload, buildPivotRowIndex, buildRelationFilterParams, deriveRelationFormFields, diffSelection, extractSelectedTargetIds, pickOptionLabel, relationRowKey, } from './dynamic-relation-helpers';
14
15
  export { buildCreatePayload, buildPivotAttachPayload, buildPivotRowIndex, buildRelationFilterParams, deriveRelationFormFields, diffSelection, extractSelectedTargetIds, pickOptionLabel, relationRowKey, } from './dynamic-relation-helpers';
15
16
  const DEFAULT_STRINGS = {
@@ -140,33 +141,58 @@ function ManyToManyRelation({ kind, through, references, foreignKey, referencesK
140
141
  const labels = { ...DEFAULT_STRINGS, ...(strings || {}) };
141
142
  const refKey = referencesKey || `${references}_id`;
142
143
  const pivotPath = pivotEndpoint || `/data/${through}`;
143
- const targetPath = referencesEndpoint || `/data/${references}`;
144
+ // referencesEndpoint is preserved as a legacy escape hatch — when set
145
+ // we keep the old `/data/<references>` raw fetch path (so apps that
146
+ // depend on a custom server route do not break). When unset we use
147
+ // the canonical `/api/options/:references` endpoint via
148
+ // useOptionsResolver, which is what the kernel auto-derives Ref to.
149
+ const useResolver = !referencesEndpoint;
150
+ const legacyTargetPath = referencesEndpoint || `/data/${references}`;
144
151
  const cachedTargetMeta = getMetadata(references);
145
152
  const [targetMeta, setTargetMeta] = useState(cachedTargetMeta || null);
146
153
  const [targetRows, setTargetRows] = useState([]);
147
154
  const [pivotRows, setPivotRows] = useState([]);
148
155
  const [loading, setLoading] = useState(true);
149
156
  const [syncing, setSyncing] = useState(false);
150
- const fetchAll = useCallback(async () => {
157
+ // Canonical path: SDK options resolver. Only fires when no legacy
158
+ // override is set. The hook is a no-op when `useResolver` is false.
159
+ const resolved = useOptionsResolver({
160
+ modelKey: '',
161
+ fieldKey: 'id',
162
+ ref: useResolver ? references : undefined,
163
+ enabled: useResolver,
164
+ });
165
+ const fetchPivotAndMeta = useCallback(async () => {
151
166
  setLoading(true);
152
167
  try {
153
168
  const params = buildRelationFilterParams(foreignKey, parentId);
154
- const [metaRes, pivotRes, targetRes] = await Promise.all([
155
- targetMeta ? Promise.resolve(null) : api.get(`/metadata/table/${references}`),
169
+ const tasks = [
156
170
  api.get(pivotPath, { params }),
157
- api.get(targetPath),
158
- ]);
159
- if (metaRes && metaRes.data?.success) {
160
- const fresh = metaRes.data.data;
161
- setTargetMeta(fresh);
162
- cacheMetadata(references, fresh);
171
+ ];
172
+ if (!targetMeta)
173
+ tasks.push(api.get(`/metadata/table/${references}`));
174
+ // Legacy fallback path: the resolver is disabled, fetch the
175
+ // target rows the old way so callers that depend on a custom
176
+ // route keep working.
177
+ if (!useResolver)
178
+ tasks.push(api.get(legacyTargetPath));
179
+ const results = await Promise.all(tasks);
180
+ const pivotRes = results[0];
181
+ if (pivotRes.data.success)
182
+ setPivotRows(pivotRes.data.data || []);
183
+ let cursor = 1;
184
+ if (!targetMeta) {
185
+ const metaRes = results[cursor++];
186
+ if (metaRes.data?.success) {
187
+ setTargetMeta(metaRes.data.data);
188
+ cacheMetadata(references, metaRes.data.data);
189
+ }
190
+ }
191
+ if (!useResolver) {
192
+ const targetRes = results[cursor++];
193
+ if (targetRes.data.success)
194
+ setTargetRows(targetRes.data.data || []);
163
195
  }
164
- const pivotList = pivotRes.data;
165
- if (pivotList.success)
166
- setPivotRows(pivotList.data || []);
167
- const targetList = targetRes.data;
168
- if (targetList.success)
169
- setTargetRows(targetList.data || []);
170
196
  }
171
197
  catch (err) {
172
198
  console.error('DynamicRelation m2m fetch error', err);
@@ -174,16 +200,22 @@ function ManyToManyRelation({ kind, through, references, foreignKey, referencesK
174
200
  finally {
175
201
  setLoading(false);
176
202
  }
177
- }, [api, pivotPath, targetPath, foreignKey, parentId, references, targetMeta, cacheMetadata]);
178
- useEffect(() => { fetchAll(); }, [fetchAll]);
203
+ }, [api, pivotPath, foreignKey, parentId, references, targetMeta, cacheMetadata, useResolver, legacyTargetPath]);
204
+ useEffect(() => { fetchPivotAndMeta(); }, [fetchPivotAndMeta]);
179
205
  const options = useMemo(() => {
206
+ if (useResolver) {
207
+ return resolved.options.map((o) => ({
208
+ value: String(o.id),
209
+ label: o.label,
210
+ }));
211
+ }
180
212
  return targetRows
181
213
  .filter(r => r && r.id !== undefined && r.id !== null && r.id !== '')
182
214
  .map(r => ({
183
215
  value: String(r.id),
184
216
  label: pickOptionLabel(r, displayKey, targetMeta?.columns),
185
217
  }));
186
- }, [targetRows, displayKey, targetMeta]);
218
+ }, [useResolver, resolved.options, targetRows, displayKey, targetMeta]);
187
219
  const selectedIds = useMemo(() => extractSelectedTargetIds(pivotRows, refKey), [pivotRows, refKey]);
188
220
  const pivotIndex = useMemo(() => buildPivotRowIndex(pivotRows, refKey), [pivotRows, refKey]);
189
221
  const handleChange = useCallback(async (next) => {
@@ -212,7 +244,12 @@ function ManyToManyRelation({ kind, through, references, foreignKey, referencesK
212
244
  if (!res.data?.success)
213
245
  throw new Error('detach failed');
214
246
  }
215
- await fetchAll();
247
+ await fetchPivotAndMeta();
248
+ // Refresh resolver-driven options when active so newly attached
249
+ // targets reflect immediately. Refetching the pivot rows alone
250
+ // is enough when the resolver branch is off.
251
+ if (useResolver)
252
+ resolved.refetch();
216
253
  onChange?.();
217
254
  }
218
255
  catch (err) {
@@ -221,6 +258,6 @@ function ManyToManyRelation({ kind, through, references, foreignKey, referencesK
221
258
  finally {
222
259
  setSyncing(false);
223
260
  }
224
- }, [api, canCreate, canDelete, fetchAll, foreignKey, onChange, parentId, pivotIndex, pivotPath, refKey, selectedIds, syncing]);
225
- return (_jsxs("div", { className: className, "data-relation-kind": kind, "data-relation-through": through, "data-relation-references": references, children: [labels.title && (_jsx("div", { className: "pb-3", children: _jsx("h3", { className: "text-sm font-medium", children: labels.title }) })), loading ? (_jsx(Skeleton, { className: "h-10 w-full" })) : options.length === 0 ? (_jsx("div", { className: "text-center text-sm text-muted-foreground py-8 border rounded-md bg-muted/30", children: labels.emptyState })) : (_jsx(MultiSelect, { options: options, selected: selectedIds, onChange: handleChange, placeholder: labels.selectPlaceholder, searchPlaceholder: labels.selectSearchPlaceholder, emptyMessage: labels.selectEmpty }))] }));
261
+ }, [api, canCreate, canDelete, fetchPivotAndMeta, useResolver, resolved, foreignKey, onChange, parentId, pivotIndex, pivotPath, refKey, selectedIds, syncing]);
262
+ return (_jsxs("div", { className: className, "data-relation-kind": kind, "data-relation-through": through, "data-relation-references": references, children: [labels.title && (_jsx("div", { className: "pb-3", children: _jsx("h3", { className: "text-sm font-medium", children: labels.title }) })), (loading || (useResolver && resolved.loading)) ? (_jsx(Skeleton, { className: "h-10 w-full" })) : options.length === 0 ? (_jsx("div", { className: "text-center text-sm text-muted-foreground py-8 border rounded-md bg-muted/30", children: labels.emptyState })) : (_jsx(MultiSelect, { options: options, selected: selectedIds, onChange: handleChange, placeholder: labels.selectPlaceholder, searchPlaceholder: labels.selectSearchPlaceholder, emptyMessage: labels.selectEmpty }))] }));
226
263
  }
@@ -0,0 +1,155 @@
1
+ import { type AddonManifestChangedMessage, type ManifestHotSwapClient, type WireHotSwapInvalidationOptions } from './manifest-hotswap-subscriber';
2
+ /**
3
+ * One of three strategies for reacting to an `ADDON_MANIFEST_CHANGED` event:
4
+ *
5
+ * * `"rekey"` — re-mount the addon route by flipping the key. Default.
6
+ * * `"page-reload"` — `window.location.reload()`. Opt-in.
7
+ * * `"manual"` — no automatic action; the host handles it via `onSwap`.
8
+ */
9
+ export type HotSwapReloadStrategy = 'rekey' | 'page-reload' | 'manual';
10
+ /**
11
+ * Config for {@link useHotSwapReload}. `strategy` is the only required field
12
+ * — pass `{ strategy: "rekey" }` for the default behaviour or omit the
13
+ * config entirely.
14
+ */
15
+ export interface HotSwapReloadConfig {
16
+ /** Reload policy. See {@link HotSwapReloadStrategy}. */
17
+ strategy?: HotSwapReloadStrategy;
18
+ /**
19
+ * Optional gate invoked **before** the reload action fires. Return
20
+ * `false` (or a Promise resolving to `false`) to cancel — useful for
21
+ * "unsaved changes" prompts on immersive addons. Receives the original
22
+ * `ADDON_MANIFEST_CHANGED` message so the prompt can name the addon.
23
+ *
24
+ * Runs for `"page-reload"` (cancels the `window.location.reload()`)
25
+ * and `"rekey"` (cancels the version bump, leaving the addon mounted
26
+ * with the old code — the host can re-trigger the swap later by
27
+ * re-calling the hook output's `reload()` method).
28
+ *
29
+ * Ignored for `"manual"` — the host owns the reload there.
30
+ */
31
+ onBeforeReload?: (event: AddonManifestChangedMessage) => boolean | Promise<boolean>;
32
+ /**
33
+ * Side-effect hook invoked after the policy has run (or after
34
+ * `onBeforeReload` returned `false`). Receives the message and the
35
+ * effective action that was taken: `"rekey"`, `"page-reload"`,
36
+ * `"cancelled"` or `"manual"`. Hosts wire telemetry / toasts here.
37
+ */
38
+ onSwap?: (event: AddonManifestChangedMessage, action: 'rekey' | 'page-reload' | 'cancelled' | 'manual') => void;
39
+ /**
40
+ * Optional matcher forwarded to the underlying
41
+ * {@link useManifestHotSwapSubscriber} for cache invalidation.
42
+ */
43
+ matcher?: WireHotSwapInvalidationOptions['matcher'];
44
+ }
45
+ export interface UseHotSwapReloadResult {
46
+ /**
47
+ * Reactive map `addonKey → hashShort`. Stable identity per render
48
+ * (only changes when a swap lands). Wire it into
49
+ * `<AddonRoute version={addonVersionMap[addonKey]} ... />` so React
50
+ * re-keys the subtree on hash change.
51
+ *
52
+ * Missing entries return `undefined`; the AddonRoute treats that as
53
+ * "no version pinned yet" and keeps a stable key.
54
+ */
55
+ addonVersionMap: Record<string, string>;
56
+ }
57
+ /**
58
+ * Subscribe to manifest hot-swap events and apply a reload policy.
59
+ *
60
+ * **Strategy = `"rekey"` (default):**
61
+ * maintains `addonVersionMap` so `<AddonRoute version=...>` re-keys
62
+ * the subtree on every swap. The federation loader picks the new hash
63
+ * up via {@link withVersionParam}, fetches a fresh `remoteEntry.js`,
64
+ * and registers a new container.
65
+ *
66
+ * **Strategy = `"page-reload"` (opt-in):**
67
+ * calls `onBeforeReload` (if supplied); if it resolves truthy,
68
+ * `window.location.reload()` fires. The `addonVersionMap` is still
69
+ * updated for callers that want to mirror it elsewhere.
70
+ *
71
+ * **Strategy = `"manual"`:**
72
+ * no automatic action. The `onSwap` callback fires with `"manual"`;
73
+ * the host decides what to do. `addonVersionMap` is updated so a
74
+ * later opt-in remount picks up the right hash.
75
+ *
76
+ * @example
77
+ * const ws = useWebSocket()
78
+ * useManifestHotSwapSubscriber(ws) // invalidates metadata cache
79
+ * const { addonVersionMap } = useHotSwapReload({ strategy: 'rekey' })
80
+ * // …in your router:
81
+ * <AddonRoute version={addonVersionMap[addonKey]}>
82
+ * <AddonLoader scope={addonKey} url={url} api={api} />
83
+ * </AddonRoute>
84
+ */
85
+ /**
86
+ * Effect that {@link applyHotSwapReload} can take. Useful as a discriminator
87
+ * for tests and telemetry callbacks. `"noop"` is emitted when a malformed
88
+ * message is ignored (e.g. missing `addonKey`).
89
+ */
90
+ export type HotSwapReloadAction = 'rekey' | 'page-reload' | 'cancelled' | 'manual' | 'noop';
91
+ export interface HotSwapReloadDeps {
92
+ /** Hash → versionMap setter. Receives an updater fn, à la React state. */
93
+ setVersionMap: (updater: (prev: Record<string, string>) => Record<string, string>) => void;
94
+ /** Defaults to `window.location.reload`. Overridable for tests / SSR. */
95
+ reload?: () => void;
96
+ }
97
+ /**
98
+ * Pure (testable) implementation of the swap handler. Decides the action
99
+ * given a message + config + deps, applies side effects via `deps`, and
100
+ * returns the action it took so callers can fire telemetry.
101
+ *
102
+ * Exported for unit tests; the React hook below composes it with React
103
+ * state. Hosts that want to drive the policy from a non-React context
104
+ * (e.g. a vanilla web component shell) can call this directly.
105
+ */
106
+ export declare function applyHotSwapReload(message: AddonManifestChangedMessage, config: HotSwapReloadConfig, deps: HotSwapReloadDeps): Promise<HotSwapReloadAction>;
107
+ export declare function useHotSwapReload(client: ManifestHotSwapClient | undefined | null, config?: HotSwapReloadConfig): UseHotSwapReloadResult;
108
+ /**
109
+ * Append a `?v=<hash8>` query string to a `remoteEntry.js` URL so the
110
+ * browser treats it as a distinct resource and bypasses any HTTP / module
111
+ * cache. Idempotent — calling twice with the same hash returns the same
112
+ * URL. Preserves existing query params; replaces a previous `v=` entry if
113
+ * present so successive bumps don't accumulate stale parameters.
114
+ *
115
+ * Pure function (no `window` access) — safe to call in SSR.
116
+ *
117
+ * @example
118
+ * withVersionParam('/api/addons/pos/frontend/remoteEntry.js', 'abc123ef')
119
+ * // → '/api/addons/pos/frontend/remoteEntry.js?v=abc123ef'
120
+ *
121
+ * withVersionParam('/r.js?foo=1', 'abc123ef')
122
+ * // → '/r.js?foo=1&v=abc123ef'
123
+ *
124
+ * withVersionParam('/r.js?v=oldhash', 'abc123ef')
125
+ * // → '/r.js?v=abc123ef'
126
+ */
127
+ export declare function withVersionParam(url: string, hash: string | undefined): string;
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.
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"`.
142
+ */
143
+ export declare function clearFederationContainer(scope: string): boolean;
144
+ /**
145
+ * Normalise a manifest hash for cache-busting. Accepts the full kernel
146
+ * format (`sha256:abc...`), a bare hex digest, or `undefined`. Returns
147
+ * an 8-character lowercase prefix that's short enough to keep URLs
148
+ * readable while remaining collision-resistant across realistic addon
149
+ * versioning timelines.
150
+ *
151
+ * Exported for tests; hosts that want the full hash for their own
152
+ * telemetry should read `message.payload.newHash` directly.
153
+ */
154
+ export declare function shortenHash(hash: string | undefined): string | undefined;
155
+ //# sourceMappingURL=hotswap-reload-policy.d.ts.map
@@ -0,0 +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"}
@@ -0,0 +1,227 @@
1
+ // hotswap-reload-policy — closes the last piece of RFC-0001 D4 ("zero-polling
2
+ // hot-swap") on the client. The {@link useManifestHotSwapSubscriber} hook
3
+ // already invalidates the metadata cache when the kernel announces a manifest
4
+ // change, but the federation container of an already-mounted addon keeps the
5
+ // old code in memory. This module picks one of three policies for forcing the
6
+ // new code to take effect:
7
+ //
8
+ // 1. `"rekey"` — the default, **recommended for most apps**. Maintains a
9
+ // reactive map `addonKey → hashShort` that the host wires into
10
+ // `<AddonRoute version={...} />`. When a swap arrives, the hash flips,
11
+ // React unmounts and remounts the addon subtree, which causes the
12
+ // federation loader to re-fetch `remoteEntry.js` (cache-busted via
13
+ // {@link withVersionParam}) and re-evaluate the exposed module. State
14
+ // inside the addon is lost — **intentional**, because the code version
15
+ // changed and stale closures over old props/state would be a footgun.
16
+ //
17
+ // 2. `"page-reload"` — `window.location.reload()`. Opt-in escape hatch
18
+ // for immersive addons with critical state (POS with an order in
19
+ // progress, kitchen-display with partial confirmations). Pair with
20
+ // `onBeforeReload` to surface an "unsaved changes" prompt before
21
+ // blowing the page away. Returning `false` from `onBeforeReload`
22
+ // cancels the reload.
23
+ //
24
+ // 3. `"manual"` — the hook only invokes the host-provided `onSwap`
25
+ // callback. The host decides what to do (e.g. show a toast: "New
26
+ // version available — reload when ready"). No automatic remount, no
27
+ // reload. The `addonVersionMap` is still updated so a host that wires
28
+ // `<AddonRoute version=...>` later in the lifecycle picks up the new
29
+ // hash on demand.
30
+ //
31
+ // ## Wiring example
32
+ //
33
+ // ```tsx
34
+ // // Host shell (4-5 line wire-up):
35
+ // const ws = useWebSocket()
36
+ // useManifestHotSwapSubscriber(ws) // invalidates metadata
37
+ // const { addonVersionMap } = useHotSwapReload({ strategy: "rekey" })
38
+ // // …in your router:
39
+ // <AddonRoute version={addonVersionMap[addonKey]} shell={renderShell}>
40
+ // <AddonLoader scope={addonKey} url={remoteEntryUrl} api={api} />
41
+ // </AddonRoute>
42
+ // ```
43
+ //
44
+ // ## Federation runtime caveat
45
+ //
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.
62
+ import { useMemo, useRef, useState } from 'react';
63
+ import { useManifestHotSwapSubscriber, } from './manifest-hotswap-subscriber';
64
+ /**
65
+ * Pure (testable) implementation of the swap handler. Decides the action
66
+ * given a message + config + deps, applies side effects via `deps`, and
67
+ * returns the action it took so callers can fire telemetry.
68
+ *
69
+ * Exported for unit tests; the React hook below composes it with React
70
+ * state. Hosts that want to drive the policy from a non-React context
71
+ * (e.g. a vanilla web component shell) can call this directly.
72
+ */
73
+ export async function applyHotSwapReload(message, config, deps) {
74
+ const strategy = config.strategy ?? 'rekey';
75
+ const addonKey = message.payload?.addonKey;
76
+ if (!addonKey)
77
+ return 'noop';
78
+ const shortHash = shortenHash(message.payload?.newHash);
79
+ if (strategy === 'manual') {
80
+ if (shortHash) {
81
+ deps.setVersionMap((m) => ({ ...m, [addonKey]: shortHash }));
82
+ }
83
+ config.onSwap?.(message, 'manual');
84
+ return 'manual';
85
+ }
86
+ if (config.onBeforeReload) {
87
+ const proceed = await Promise.resolve(config.onBeforeReload(message));
88
+ if (!proceed) {
89
+ config.onSwap?.(message, 'cancelled');
90
+ return 'cancelled';
91
+ }
92
+ }
93
+ if (strategy === 'page-reload') {
94
+ config.onSwap?.(message, 'page-reload');
95
+ const reload = deps.reload ??
96
+ (typeof window !== 'undefined'
97
+ ? () => window.location.reload()
98
+ : undefined);
99
+ if (reload) {
100
+ // Defer so any setState before us commits before we tear down.
101
+ queueMicrotask(reload);
102
+ }
103
+ return 'page-reload';
104
+ }
105
+ // strategy === "rekey"
106
+ if (shortHash) {
107
+ deps.setVersionMap((m) => {
108
+ if (m[addonKey] === shortHash)
109
+ return m;
110
+ return { ...m, [addonKey]: shortHash };
111
+ });
112
+ }
113
+ config.onSwap?.(message, 'rekey');
114
+ return 'rekey';
115
+ }
116
+ export function useHotSwapReload(client, config = {}) {
117
+ const [addonVersionMap, setAddonVersionMap] = useState({});
118
+ // Keep config behind a ref so changing callbacks between renders does
119
+ // not re-subscribe to the WebSocket / tear down listeners.
120
+ const configRef = useRef(config);
121
+ configRef.current = config;
122
+ // Stable handler for the underlying subscriber — reads the latest
123
+ // config out of the ref every time the WS emits.
124
+ const handleSwap = useMemo(() => (message) => {
125
+ void applyHotSwapReload(message, configRef.current, {
126
+ setVersionMap: setAddonVersionMap,
127
+ });
128
+ }, []);
129
+ useManifestHotSwapSubscriber(client, {
130
+ matcher: config.matcher,
131
+ onSwap: handleSwap,
132
+ });
133
+ return { addonVersionMap };
134
+ }
135
+ /**
136
+ * Append a `?v=<hash8>` query string to a `remoteEntry.js` URL so the
137
+ * browser treats it as a distinct resource and bypasses any HTTP / module
138
+ * cache. Idempotent — calling twice with the same hash returns the same
139
+ * URL. Preserves existing query params; replaces a previous `v=` entry if
140
+ * present so successive bumps don't accumulate stale parameters.
141
+ *
142
+ * Pure function (no `window` access) — safe to call in SSR.
143
+ *
144
+ * @example
145
+ * withVersionParam('/api/addons/pos/frontend/remoteEntry.js', 'abc123ef')
146
+ * // → '/api/addons/pos/frontend/remoteEntry.js?v=abc123ef'
147
+ *
148
+ * withVersionParam('/r.js?foo=1', 'abc123ef')
149
+ * // → '/r.js?foo=1&v=abc123ef'
150
+ *
151
+ * withVersionParam('/r.js?v=oldhash', 'abc123ef')
152
+ * // → '/r.js?v=abc123ef'
153
+ */
154
+ export function withVersionParam(url, hash) {
155
+ if (!hash)
156
+ return url;
157
+ const short = shortenHash(hash);
158
+ if (!short)
159
+ return url;
160
+ const hashIdx = url.indexOf('#');
161
+ const fragment = hashIdx >= 0 ? url.slice(hashIdx) : '';
162
+ const base = hashIdx >= 0 ? url.slice(0, hashIdx) : url;
163
+ const qIdx = base.indexOf('?');
164
+ if (qIdx < 0)
165
+ return `${base}?v=${short}${fragment}`;
166
+ const head = base.slice(0, qIdx);
167
+ const query = base.slice(qIdx + 1);
168
+ // Drop any previous v= entry and re-append.
169
+ const parts = query
170
+ .split('&')
171
+ .filter((p) => p.length > 0 && !p.startsWith('v='));
172
+ parts.push(`v=${short}`);
173
+ return `${head}?${parts.join('&')}${fragment}`;
174
+ }
175
+ /**
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.
184
+ *
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"`.
189
+ */
190
+ export function clearFederationContainer(scope) {
191
+ if (typeof window === 'undefined')
192
+ return false;
193
+ if (!(scope in window))
194
+ return false;
195
+ try {
196
+ delete window[scope];
197
+ return true;
198
+ }
199
+ 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
+ ;
204
+ window[scope] = undefined;
205
+ return true;
206
+ }
207
+ }
208
+ /**
209
+ * Normalise a manifest hash for cache-busting. Accepts the full kernel
210
+ * format (`sha256:abc...`), a bare hex digest, or `undefined`. Returns
211
+ * an 8-character lowercase prefix that's short enough to keep URLs
212
+ * readable while remaining collision-resistant across realistic addon
213
+ * versioning timelines.
214
+ *
215
+ * Exported for tests; hosts that want the full hash for their own
216
+ * telemetry should read `message.payload.newHash` directly.
217
+ */
218
+ export function shortenHash(hash) {
219
+ if (!hash)
220
+ return undefined;
221
+ const colonIdx = hash.indexOf(':');
222
+ const digest = colonIdx >= 0 ? hash.slice(colonIdx + 1) : hash;
223
+ const trimmed = digest.trim();
224
+ if (!trimmed)
225
+ return undefined;
226
+ return trimmed.slice(0, 8).toLowerCase();
227
+ }
package/dist/index.d.ts CHANGED
@@ -4,12 +4,15 @@ export * from './dynamic-table';
4
4
  export * from './dynamic-form';
5
5
  export { ActionModalDispatcher, type ActionModalProps, } from './action-modal-dispatcher';
6
6
  export * from './addon-loader';
7
+ export { AddonLayoutProvider, useAddonLayout, useAddonLayoutControl, useDeclareAddonLayout, type AddonLayout, type AddonLayoutProviderProps, } from './addon-layout-context';
7
8
  export * from './slot';
8
9
  export * from './capability-gate';
9
10
  export * from './navigation-builder';
10
11
  export * from './i18n-provider';
11
12
  export * from './api-context';
12
13
  export * from './metadata-cache';
14
+ export { ADDON_MANIFEST_CHANGED_TYPE, wireHotSwapInvalidation, useManifestHotSwapSubscriber, type AddonManifestChangedMessage, type ManifestHotSwapClient, type WireHotSwapInvalidationOptions, } from './manifest-hotswap-subscriber';
15
+ export { useHotSwapReload, applyHotSwapReload, withVersionParam, clearFederationContainer, shortenHash, type HotSwapReloadStrategy, type HotSwapReloadConfig, type HotSwapReloadAction, type HotSwapReloadDeps, type UseHotSwapReloadResult, } from './hotswap-reload-policy';
13
16
  export * from './dynamic-icon';
14
17
  export type { ColumnFilterConfig, FilterOption as DynamicColumnFilterOption, GetDynamicColumns, DynamicIconComponent, } from './dynamic-columns-shim';
15
18
  export { defaultGetDynamicColumns, makeDefaultGetDynamicColumns, type DynamicColumnsHelpers, } from './dynamic-columns';
@@ -20,4 +23,7 @@ export { DynamicCRUDPage, type DynamicCRUDPageProps, type DynamicCRUDPageStrings
20
23
  export { DynamicRelation, type DynamicRelationProps, type DynamicRelationStrings, type DynamicRelationKind, buildRelationFilterParams, buildCreatePayload, deriveRelationFormFields, relationRowKey, } from './dynamic-relation';
21
24
  export { registerModelExtension, getModelExtension, clearModelExtensions, type ModelExtension, type ModelExtensionProps, } from './model-extension-registry';
22
25
  export { isColumnVisibleInTable, getSearchableColumnKeys, } from './column-visibility';
26
+ export { useOptionsResolver, projectOption, type ResolvedOption, type OptionsMeta, type UseOptionsResolverArgs, type UseOptionsResolverResult, } from './use-options-resolver';
27
+ export { setOrgConfigBridge, getOrgConfigBridge, resolveValidatorToken, type OrgConfigBridge, } from './use-org-config-bridge';
28
+ export { registerValidator } from './dynamic-form-schema';
23
29
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAKA,cAAc,SAAS,CAAA;AACvB,cAAc,mBAAmB,CAAA;AACjC,cAAc,iBAAiB,CAAA;AAC/B,cAAc,gBAAgB,CAAA;AAC9B,OAAO,EACH,qBAAqB,EACrB,KAAK,gBAAgB,GACxB,MAAM,2BAA2B,CAAA;AAClC,cAAc,gBAAgB,CAAA;AAC9B,cAAc,QAAQ,CAAA;AACtB,cAAc,mBAAmB,CAAA;AACjC,cAAc,sBAAsB,CAAA;AACpC,cAAc,iBAAiB,CAAA;AAC/B,cAAc,eAAe,CAAA;AAC7B,cAAc,kBAAkB,CAAA;AAChC,cAAc,gBAAgB,CAAA;AAC9B,YAAY,EACR,kBAAkB,EAClB,YAAY,IAAI,yBAAyB,EACzC,iBAAiB,EACjB,oBAAoB,GACvB,MAAM,wBAAwB,CAAA;AAC/B,OAAO,EACH,wBAAwB,EACxB,4BAA4B,EAC5B,KAAK,qBAAqB,GAC7B,MAAM,mBAAmB,CAAA;AAC1B,OAAO,EAAE,mBAAmB,EAAE,MAAM,0BAA0B,CAAA;AAC9D,OAAO,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAA;AAC/C,OAAO,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAA;AAC/C,OAAO,EACH,eAAe,EACf,KAAK,oBAAoB,EACzB,KAAK,sBAAsB,EAC3B,KAAK,sBAAsB,GAC9B,MAAM,qBAAqB,CAAA;AAC5B,OAAO,EACH,eAAe,EACf,KAAK,oBAAoB,EACzB,KAAK,sBAAsB,EAC3B,KAAK,mBAAmB,EACxB,yBAAyB,EACzB,kBAAkB,EAClB,wBAAwB,EACxB,cAAc,GACjB,MAAM,oBAAoB,CAAA;AAC3B,OAAO,EACH,sBAAsB,EACtB,iBAAiB,EACjB,oBAAoB,EACpB,KAAK,cAAc,EACnB,KAAK,mBAAmB,GAC3B,MAAM,4BAA4B,CAAA;AACnC,OAAO,EACH,sBAAsB,EACtB,uBAAuB,GAC1B,MAAM,qBAAqB,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAKA,cAAc,SAAS,CAAA;AACvB,cAAc,mBAAmB,CAAA;AACjC,cAAc,iBAAiB,CAAA;AAC/B,cAAc,gBAAgB,CAAA;AAC9B,OAAO,EACH,qBAAqB,EACrB,KAAK,gBAAgB,GACxB,MAAM,2BAA2B,CAAA;AAClC,cAAc,gBAAgB,CAAA;AAC9B,OAAO,EACH,mBAAmB,EACnB,cAAc,EACd,qBAAqB,EACrB,qBAAqB,EACrB,KAAK,WAAW,EAChB,KAAK,wBAAwB,GAChC,MAAM,wBAAwB,CAAA;AAC/B,cAAc,QAAQ,CAAA;AACtB,cAAc,mBAAmB,CAAA;AACjC,cAAc,sBAAsB,CAAA;AACpC,cAAc,iBAAiB,CAAA;AAC/B,cAAc,eAAe,CAAA;AAC7B,cAAc,kBAAkB,CAAA;AAChC,OAAO,EACH,2BAA2B,EAC3B,uBAAuB,EACvB,4BAA4B,EAC5B,KAAK,2BAA2B,EAChC,KAAK,qBAAqB,EAC1B,KAAK,8BAA8B,GACtC,MAAM,+BAA+B,CAAA;AACtC,OAAO,EACH,gBAAgB,EAChB,kBAAkB,EAClB,gBAAgB,EAChB,wBAAwB,EACxB,WAAW,EACX,KAAK,qBAAqB,EAC1B,KAAK,mBAAmB,EACxB,KAAK,mBAAmB,EACxB,KAAK,iBAAiB,EACtB,KAAK,sBAAsB,GAC9B,MAAM,yBAAyB,CAAA;AAChC,cAAc,gBAAgB,CAAA;AAC9B,YAAY,EACR,kBAAkB,EAClB,YAAY,IAAI,yBAAyB,EACzC,iBAAiB,EACjB,oBAAoB,GACvB,MAAM,wBAAwB,CAAA;AAC/B,OAAO,EACH,wBAAwB,EACxB,4BAA4B,EAC5B,KAAK,qBAAqB,GAC7B,MAAM,mBAAmB,CAAA;AAC1B,OAAO,EAAE,mBAAmB,EAAE,MAAM,0BAA0B,CAAA;AAC9D,OAAO,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAA;AAC/C,OAAO,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAA;AAC/C,OAAO,EACH,eAAe,EACf,KAAK,oBAAoB,EACzB,KAAK,sBAAsB,EAC3B,KAAK,sBAAsB,GAC9B,MAAM,qBAAqB,CAAA;AAC5B,OAAO,EACH,eAAe,EACf,KAAK,oBAAoB,EACzB,KAAK,sBAAsB,EAC3B,KAAK,mBAAmB,EACxB,yBAAyB,EACzB,kBAAkB,EAClB,wBAAwB,EACxB,cAAc,GACjB,MAAM,oBAAoB,CAAA;AAC3B,OAAO,EACH,sBAAsB,EACtB,iBAAiB,EACjB,oBAAoB,EACpB,KAAK,cAAc,EACnB,KAAK,mBAAmB,GAC3B,MAAM,4BAA4B,CAAA;AACnC,OAAO,EACH,sBAAsB,EACtB,uBAAuB,GAC1B,MAAM,qBAAqB,CAAA;AAC5B,OAAO,EACH,kBAAkB,EAClB,aAAa,EACb,KAAK,cAAc,EACnB,KAAK,WAAW,EAChB,KAAK,sBAAsB,EAC3B,KAAK,wBAAwB,GAChC,MAAM,wBAAwB,CAAA;AAC/B,OAAO,EACH,kBAAkB,EAClB,kBAAkB,EAClB,qBAAqB,EACrB,KAAK,eAAe,GACvB,MAAM,yBAAyB,CAAA;AAChC,OAAO,EAAE,iBAAiB,EAAE,MAAM,uBAAuB,CAAA"}
package/dist/index.js CHANGED
@@ -9,12 +9,15 @@ export * from './dynamic-table';
9
9
  export * from './dynamic-form';
10
10
  export { ActionModalDispatcher, } from './action-modal-dispatcher';
11
11
  export * from './addon-loader';
12
+ export { AddonLayoutProvider, useAddonLayout, useAddonLayoutControl, useDeclareAddonLayout, } from './addon-layout-context';
12
13
  export * from './slot';
13
14
  export * from './capability-gate';
14
15
  export * from './navigation-builder';
15
16
  export * from './i18n-provider';
16
17
  export * from './api-context';
17
18
  export * from './metadata-cache';
19
+ export { ADDON_MANIFEST_CHANGED_TYPE, wireHotSwapInvalidation, useManifestHotSwapSubscriber, } from './manifest-hotswap-subscriber';
20
+ export { useHotSwapReload, applyHotSwapReload, withVersionParam, clearFederationContainer, shortenHash, } from './hotswap-reload-policy';
18
21
  export * from './dynamic-icon';
19
22
  export { defaultGetDynamicColumns, makeDefaultGetDynamicColumns, } from './dynamic-columns';
20
23
  export { DynamicRecordDialog } from './dialogs/dynamic-record';
@@ -24,3 +27,6 @@ export { DynamicCRUDPage, } from './dynamic-crud-page';
24
27
  export { DynamicRelation, buildRelationFilterParams, buildCreatePayload, deriveRelationFormFields, relationRowKey, } from './dynamic-relation';
25
28
  export { registerModelExtension, getModelExtension, clearModelExtensions, } from './model-extension-registry';
26
29
  export { isColumnVisibleInTable, getSearchableColumnKeys, } from './column-visibility';
30
+ export { useOptionsResolver, projectOption, } from './use-options-resolver';
31
+ export { setOrgConfigBridge, getOrgConfigBridge, resolveValidatorToken, } from './use-org-config-bridge';
32
+ export { registerValidator } from './dynamic-form-schema';