@asteby/metacore-runtime-react 9.2.0 → 11.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,137 @@
1
+ // AddonLayoutContext — broadcast the active addon entry's layout selection
2
+ // (`shell` vs `immersive`) up to the host so it can hide/show its chrome
3
+ // (Sidebar, Topbar, breadcrumbs) when an immersive addon is mounted.
4
+ //
5
+ // Why a context rather than a prop on the host shell:
6
+ //
7
+ // 1. The host shell is rendered ABOVE the addon route in the tree, but the
8
+ // decision about what layout the addon wants comes from the addon itself
9
+ // (manifest.frontend.layout) which the AddonLoader knows about at mount
10
+ // time. A bottom-up signal via context inverts the dependency cleanly.
11
+ //
12
+ // 2. Addon entries can swap layouts at runtime (think a kiosk-mode toggle
13
+ // inside a POS). A context value reactively updates the host without
14
+ // asking each route to wire props.
15
+ //
16
+ // 3. When the user navigates AWAY from an immersive addon, the AddonLoader
17
+ // unmounts, its layout context updater fires `setLayout("shell")` from
18
+ // a cleanup effect, and the chrome restores automatically.
19
+ //
20
+ // Host integration (starter-core, ops, …):
21
+ //
22
+ // function AppShell({ children }) {
23
+ // const layout = useAddonLayout()
24
+ // const chrome = layout !== "immersive"
25
+ // return (
26
+ // <div className={chrome ? "grid grid-cols-[280px_1fr]" : "h-dvh w-dvw"}>
27
+ // {chrome && <Sidebar />}
28
+ // <main>{chrome && <Topbar />}{children}</main>
29
+ // </div>
30
+ // )
31
+ // }
32
+ //
33
+ // The context defaults to `"shell"`, so apps that never mount an
34
+ // `<AddonLayoutProvider>` keep the legacy behaviour.
35
+
36
+ import {
37
+ createContext,
38
+ useCallback,
39
+ useContext,
40
+ useEffect,
41
+ useMemo,
42
+ useState,
43
+ } from 'react'
44
+ import type { AddonLayout } from '@asteby/metacore-sdk'
45
+
46
+ export type { AddonLayout }
47
+
48
+ interface AddonLayoutState {
49
+ /** Active layout. `"shell"` (default) or `"immersive"`. */
50
+ layout: AddonLayout
51
+ /**
52
+ * Imperative setter for the host or an addon-loader to mutate the active
53
+ * layout. Exposed for advanced use; most callers should use
54
+ * `useDeclareAddonLayout(layout)` from a route component, which scopes the
55
+ * change to the route's mount lifetime.
56
+ */
57
+ setLayout: (layout: AddonLayout) => void
58
+ }
59
+
60
+ const defaultState: AddonLayoutState = {
61
+ layout: 'shell',
62
+ setLayout: () => {
63
+ /* noop — provider missing; consumers degrade to legacy "shell" */
64
+ },
65
+ }
66
+
67
+ const AddonLayoutContext = createContext<AddonLayoutState>(defaultState)
68
+
69
+ export interface AddonLayoutProviderProps {
70
+ /** Initial layout — usually `"shell"`. */
71
+ initial?: AddonLayout
72
+ children: React.ReactNode
73
+ }
74
+
75
+ /**
76
+ * Wrap the host app once, above the router outlet. The provider keeps the
77
+ * currently-active layout in state; addon-loader and `useDeclareAddonLayout`
78
+ * mutate it from below.
79
+ */
80
+ export function AddonLayoutProvider({
81
+ initial = 'shell',
82
+ children,
83
+ }: AddonLayoutProviderProps) {
84
+ const [layout, setLayout] = useState<AddonLayout>(initial)
85
+ const value = useMemo<AddonLayoutState>(
86
+ () => ({ layout, setLayout }),
87
+ [layout],
88
+ )
89
+ return (
90
+ <AddonLayoutContext.Provider value={value}>
91
+ {children}
92
+ </AddonLayoutContext.Provider>
93
+ )
94
+ }
95
+
96
+ /**
97
+ * Read the currently-active layout. The host shell calls this and decides
98
+ * whether to render its chrome. Returns `"shell"` when no provider is
99
+ * mounted, so apps that have not adopted immersive addons keep working.
100
+ */
101
+ export function useAddonLayout(): AddonLayout {
102
+ return useContext(AddonLayoutContext).layout
103
+ }
104
+
105
+ /**
106
+ * Imperative API — the value returned mirrors `useAddonLayout()` but also
107
+ * exposes the setter for hosts that need to flip the layout outside of a
108
+ * route lifecycle (e.g. a hotkey forcing kiosk mode). Most addon entries do
109
+ * NOT need this; prefer `useDeclareAddonLayout`.
110
+ */
111
+ export function useAddonLayoutControl(): AddonLayoutState {
112
+ return useContext(AddonLayoutContext)
113
+ }
114
+
115
+ /**
116
+ * Declare the layout from the addon side. Mounts the value, restores
117
+ * `"shell"` on unmount. Skip when `layout` is undefined so route components
118
+ * can pass `manifest.frontend?.layout` directly without branching.
119
+ *
120
+ * function PosEntry({ manifest }: { manifest: Manifest }) {
121
+ * useDeclareAddonLayout(manifest.frontend?.layout)
122
+ * return <PosScreen />
123
+ * }
124
+ */
125
+ export function useDeclareAddonLayout(layout: AddonLayout | undefined): void {
126
+ const { setLayout } = useAddonLayoutControl()
127
+ // useCallback so the effect only re-runs on a real layout change, not on
128
+ // every render of the consumer that happens to forward an inline literal.
129
+ const apply = useCallback(setLayout, [setLayout])
130
+ useEffect(() => {
131
+ if (!layout || layout === 'shell') return
132
+ apply(layout)
133
+ return () => {
134
+ apply('shell')
135
+ }
136
+ }, [layout, apply])
137
+ }
@@ -2,7 +2,8 @@
2
2
  // waits for the `window[scope]` container to initialize, then calls the
3
3
  // addon's `register(api)` export with the AddonAPI injected by the host.
4
4
  import { useEffect, useRef, useState } from 'react'
5
- import type { AddonAPI } from '@asteby/metacore-sdk'
5
+ import type { AddonAPI, AddonLayout } from '@asteby/metacore-sdk'
6
+ import { useDeclareAddonLayout } from './addon-layout-context'
6
7
 
7
8
  declare global {
8
9
  interface Window {
@@ -27,6 +28,18 @@ export interface AddonLoaderProps {
27
28
  onReady?: () => void
28
29
  /** Called if loading fails. */
29
30
  onError?: (err: Error) => void
31
+ /**
32
+ * Layout the host shell should render the addon under, mirroring
33
+ * `manifest.frontend.layout`. Default (undefined / `"shell"`) keeps the
34
+ * legacy chrome (Sidebar, Topbar, breadcrumbs). `"immersive"` flips the
35
+ * shared {@link useAddonLayout} context so the host shell hides chrome
36
+ * while the addon is mounted and restores it on unmount.
37
+ *
38
+ * Hosts that consume the context (see `useAddonLayout` /
39
+ * `<AddonLayoutProvider>`) do NOT need to branch on this prop themselves
40
+ * — the loader sets the context value via {@link useDeclareAddonLayout}.
41
+ */
42
+ layout?: AddonLayout
30
43
  children?: React.ReactNode
31
44
  }
32
45
 
@@ -75,12 +88,19 @@ export function AddonLoader({
75
88
  fallback = null,
76
89
  onReady,
77
90
  onError,
91
+ layout,
78
92
  children,
79
93
  }: AddonLoaderProps) {
80
94
  const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading')
81
95
  const [error, setError] = useState<Error | null>(null)
82
96
  const didRegister = useRef(false)
83
97
 
98
+ // Propagate the addon's preferred layout to the host shell via context.
99
+ // No-op when `layout` is undefined or `"shell"` (legacy default). Cleanup
100
+ // restores `"shell"` automatically when the loader unmounts, so chrome
101
+ // returns as soon as the user navigates away from an immersive addon.
102
+ useDeclareAddonLayout(layout)
103
+
84
104
  useEffect(() => {
85
105
  let cancelled = false
86
106
  ;(async () => {
@@ -0,0 +1,360 @@
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
+
63
+ import { useMemo, useRef, useState } from 'react'
64
+ import {
65
+ useManifestHotSwapSubscriber,
66
+ type AddonManifestChangedMessage,
67
+ type ManifestHotSwapClient,
68
+ type WireHotSwapInvalidationOptions,
69
+ } from './manifest-hotswap-subscriber'
70
+
71
+ /**
72
+ * One of three strategies for reacting to an `ADDON_MANIFEST_CHANGED` event:
73
+ *
74
+ * * `"rekey"` — re-mount the addon route by flipping the key. Default.
75
+ * * `"page-reload"` — `window.location.reload()`. Opt-in.
76
+ * * `"manual"` — no automatic action; the host handles it via `onSwap`.
77
+ */
78
+ export type HotSwapReloadStrategy = 'rekey' | 'page-reload' | 'manual'
79
+
80
+ /**
81
+ * Config for {@link useHotSwapReload}. `strategy` is the only required field
82
+ * — pass `{ strategy: "rekey" }` for the default behaviour or omit the
83
+ * config entirely.
84
+ */
85
+ export interface HotSwapReloadConfig {
86
+ /** Reload policy. See {@link HotSwapReloadStrategy}. */
87
+ strategy?: HotSwapReloadStrategy
88
+ /**
89
+ * Optional gate invoked **before** the reload action fires. Return
90
+ * `false` (or a Promise resolving to `false`) to cancel — useful for
91
+ * "unsaved changes" prompts on immersive addons. Receives the original
92
+ * `ADDON_MANIFEST_CHANGED` message so the prompt can name the addon.
93
+ *
94
+ * Runs for `"page-reload"` (cancels the `window.location.reload()`)
95
+ * and `"rekey"` (cancels the version bump, leaving the addon mounted
96
+ * with the old code — the host can re-trigger the swap later by
97
+ * re-calling the hook output's `reload()` method).
98
+ *
99
+ * Ignored for `"manual"` — the host owns the reload there.
100
+ */
101
+ onBeforeReload?: (
102
+ event: AddonManifestChangedMessage,
103
+ ) => boolean | Promise<boolean>
104
+ /**
105
+ * Side-effect hook invoked after the policy has run (or after
106
+ * `onBeforeReload` returned `false`). Receives the message and the
107
+ * effective action that was taken: `"rekey"`, `"page-reload"`,
108
+ * `"cancelled"` or `"manual"`. Hosts wire telemetry / toasts here.
109
+ */
110
+ onSwap?: (
111
+ event: AddonManifestChangedMessage,
112
+ action: 'rekey' | 'page-reload' | 'cancelled' | 'manual',
113
+ ) => void
114
+ /**
115
+ * Optional matcher forwarded to the underlying
116
+ * {@link useManifestHotSwapSubscriber} for cache invalidation.
117
+ */
118
+ matcher?: WireHotSwapInvalidationOptions['matcher']
119
+ }
120
+
121
+ export interface UseHotSwapReloadResult {
122
+ /**
123
+ * Reactive map `addonKey → hashShort`. Stable identity per render
124
+ * (only changes when a swap lands). Wire it into
125
+ * `<AddonRoute version={addonVersionMap[addonKey]} ... />` so React
126
+ * re-keys the subtree on hash change.
127
+ *
128
+ * Missing entries return `undefined`; the AddonRoute treats that as
129
+ * "no version pinned yet" and keeps a stable key.
130
+ */
131
+ addonVersionMap: Record<string, string>
132
+ }
133
+
134
+ /**
135
+ * Subscribe to manifest hot-swap events and apply a reload policy.
136
+ *
137
+ * **Strategy = `"rekey"` (default):**
138
+ * maintains `addonVersionMap` so `<AddonRoute version=...>` re-keys
139
+ * the subtree on every swap. The federation loader picks the new hash
140
+ * up via {@link withVersionParam}, fetches a fresh `remoteEntry.js`,
141
+ * and registers a new container.
142
+ *
143
+ * **Strategy = `"page-reload"` (opt-in):**
144
+ * calls `onBeforeReload` (if supplied); if it resolves truthy,
145
+ * `window.location.reload()` fires. The `addonVersionMap` is still
146
+ * updated for callers that want to mirror it elsewhere.
147
+ *
148
+ * **Strategy = `"manual"`:**
149
+ * no automatic action. The `onSwap` callback fires with `"manual"`;
150
+ * the host decides what to do. `addonVersionMap` is updated so a
151
+ * later opt-in remount picks up the right hash.
152
+ *
153
+ * @example
154
+ * const ws = useWebSocket()
155
+ * useManifestHotSwapSubscriber(ws) // invalidates metadata cache
156
+ * const { addonVersionMap } = useHotSwapReload({ strategy: 'rekey' })
157
+ * // …in your router:
158
+ * <AddonRoute version={addonVersionMap[addonKey]}>
159
+ * <AddonLoader scope={addonKey} url={url} api={api} />
160
+ * </AddonRoute>
161
+ */
162
+ /**
163
+ * Effect that {@link applyHotSwapReload} can take. Useful as a discriminator
164
+ * for tests and telemetry callbacks. `"noop"` is emitted when a malformed
165
+ * message is ignored (e.g. missing `addonKey`).
166
+ */
167
+ export type HotSwapReloadAction =
168
+ | 'rekey'
169
+ | 'page-reload'
170
+ | 'cancelled'
171
+ | 'manual'
172
+ | 'noop'
173
+
174
+ export interface HotSwapReloadDeps {
175
+ /** Hash → versionMap setter. Receives an updater fn, à la React state. */
176
+ setVersionMap: (
177
+ updater: (prev: Record<string, string>) => Record<string, string>,
178
+ ) => void
179
+ /** Defaults to `window.location.reload`. Overridable for tests / SSR. */
180
+ reload?: () => void
181
+ }
182
+
183
+ /**
184
+ * Pure (testable) implementation of the swap handler. Decides the action
185
+ * given a message + config + deps, applies side effects via `deps`, and
186
+ * returns the action it took so callers can fire telemetry.
187
+ *
188
+ * Exported for unit tests; the React hook below composes it with React
189
+ * state. Hosts that want to drive the policy from a non-React context
190
+ * (e.g. a vanilla web component shell) can call this directly.
191
+ */
192
+ export async function applyHotSwapReload(
193
+ message: AddonManifestChangedMessage,
194
+ config: HotSwapReloadConfig,
195
+ deps: HotSwapReloadDeps,
196
+ ): Promise<HotSwapReloadAction> {
197
+ const strategy: HotSwapReloadStrategy = config.strategy ?? 'rekey'
198
+ const addonKey = message.payload?.addonKey
199
+ if (!addonKey) return 'noop'
200
+ const shortHash = shortenHash(message.payload?.newHash)
201
+
202
+ if (strategy === 'manual') {
203
+ if (shortHash) {
204
+ deps.setVersionMap((m) => ({ ...m, [addonKey]: shortHash }))
205
+ }
206
+ config.onSwap?.(message, 'manual')
207
+ return 'manual'
208
+ }
209
+
210
+ if (config.onBeforeReload) {
211
+ const proceed = await Promise.resolve(config.onBeforeReload(message))
212
+ if (!proceed) {
213
+ config.onSwap?.(message, 'cancelled')
214
+ return 'cancelled'
215
+ }
216
+ }
217
+
218
+ if (strategy === 'page-reload') {
219
+ config.onSwap?.(message, 'page-reload')
220
+ const reload =
221
+ deps.reload ??
222
+ (typeof window !== 'undefined'
223
+ ? () => window.location.reload()
224
+ : undefined)
225
+ if (reload) {
226
+ // Defer so any setState before us commits before we tear down.
227
+ queueMicrotask(reload)
228
+ }
229
+ return 'page-reload'
230
+ }
231
+
232
+ // strategy === "rekey"
233
+ if (shortHash) {
234
+ deps.setVersionMap((m) => {
235
+ if (m[addonKey] === shortHash) return m
236
+ return { ...m, [addonKey]: shortHash }
237
+ })
238
+ }
239
+ config.onSwap?.(message, 'rekey')
240
+ return 'rekey'
241
+ }
242
+
243
+ export function useHotSwapReload(
244
+ client: ManifestHotSwapClient | undefined | null,
245
+ config: HotSwapReloadConfig = {},
246
+ ): UseHotSwapReloadResult {
247
+ const [addonVersionMap, setAddonVersionMap] = useState<
248
+ Record<string, string>
249
+ >({})
250
+
251
+ // Keep config behind a ref so changing callbacks between renders does
252
+ // not re-subscribe to the WebSocket / tear down listeners.
253
+ const configRef = useRef(config)
254
+ configRef.current = config
255
+
256
+ // Stable handler for the underlying subscriber — reads the latest
257
+ // config out of the ref every time the WS emits.
258
+ const handleSwap = useMemo(
259
+ () => (message: AddonManifestChangedMessage) => {
260
+ void applyHotSwapReload(message, configRef.current, {
261
+ setVersionMap: setAddonVersionMap,
262
+ })
263
+ },
264
+ [],
265
+ )
266
+
267
+ useManifestHotSwapSubscriber(client, {
268
+ matcher: config.matcher,
269
+ onSwap: handleSwap,
270
+ })
271
+
272
+ return { addonVersionMap }
273
+ }
274
+
275
+ /**
276
+ * Append a `?v=<hash8>` query string to a `remoteEntry.js` URL so the
277
+ * browser treats it as a distinct resource and bypasses any HTTP / module
278
+ * cache. Idempotent — calling twice with the same hash returns the same
279
+ * URL. Preserves existing query params; replaces a previous `v=` entry if
280
+ * present so successive bumps don't accumulate stale parameters.
281
+ *
282
+ * Pure function (no `window` access) — safe to call in SSR.
283
+ *
284
+ * @example
285
+ * withVersionParam('/api/addons/pos/frontend/remoteEntry.js', 'abc123ef')
286
+ * // → '/api/addons/pos/frontend/remoteEntry.js?v=abc123ef'
287
+ *
288
+ * withVersionParam('/r.js?foo=1', 'abc123ef')
289
+ * // → '/r.js?foo=1&v=abc123ef'
290
+ *
291
+ * withVersionParam('/r.js?v=oldhash', 'abc123ef')
292
+ * // → '/r.js?v=abc123ef'
293
+ */
294
+ export function withVersionParam(url: string, hash: string | undefined): string {
295
+ if (!hash) return url
296
+ const short = shortenHash(hash)
297
+ if (!short) return url
298
+ const hashIdx = url.indexOf('#')
299
+ const fragment = hashIdx >= 0 ? url.slice(hashIdx) : ''
300
+ const base = hashIdx >= 0 ? url.slice(0, hashIdx) : url
301
+ const qIdx = base.indexOf('?')
302
+ if (qIdx < 0) return `${base}?v=${short}${fragment}`
303
+ const head = base.slice(0, qIdx)
304
+ const query = base.slice(qIdx + 1)
305
+ // Drop any previous v= entry and re-append.
306
+ const parts = query
307
+ .split('&')
308
+ .filter((p) => p.length > 0 && !p.startsWith('v='))
309
+ parts.push(`v=${short}`)
310
+ return `${head}?${parts.join('&')}${fragment}`
311
+ }
312
+
313
+ /**
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.
322
+ *
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"`.
327
+ */
328
+ export function clearFederationContainer(scope: string): boolean {
329
+ if (typeof window === 'undefined') return false
330
+ if (!(scope in window)) return false
331
+ try {
332
+ delete (window as Record<string, unknown>)[scope]
333
+ return true
334
+ } 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
+ ;(window as Record<string, unknown>)[scope] = undefined
339
+ return true
340
+ }
341
+ }
342
+
343
+ /**
344
+ * Normalise a manifest hash for cache-busting. Accepts the full kernel
345
+ * format (`sha256:abc...`), a bare hex digest, or `undefined`. Returns
346
+ * an 8-character lowercase prefix that's short enough to keep URLs
347
+ * readable while remaining collision-resistant across realistic addon
348
+ * versioning timelines.
349
+ *
350
+ * Exported for tests; hosts that want the full hash for their own
351
+ * telemetry should read `message.payload.newHash` directly.
352
+ */
353
+ export function shortenHash(hash: string | undefined): string | undefined {
354
+ if (!hash) return undefined
355
+ const colonIdx = hash.indexOf(':')
356
+ const digest = colonIdx >= 0 ? hash.slice(colonIdx + 1) : hash
357
+ const trimmed = digest.trim()
358
+ if (!trimmed) return undefined
359
+ return trimmed.slice(0, 8).toLowerCase()
360
+ }
package/src/index.ts CHANGED
@@ -12,12 +12,40 @@ export {
12
12
  type ActionModalProps,
13
13
  } from './action-modal-dispatcher'
14
14
  export * from './addon-loader'
15
+ export {
16
+ AddonLayoutProvider,
17
+ useAddonLayout,
18
+ useAddonLayoutControl,
19
+ useDeclareAddonLayout,
20
+ type AddonLayout,
21
+ type AddonLayoutProviderProps,
22
+ } from './addon-layout-context'
15
23
  export * from './slot'
16
24
  export * from './capability-gate'
17
25
  export * from './navigation-builder'
18
26
  export * from './i18n-provider'
19
27
  export * from './api-context'
20
28
  export * from './metadata-cache'
29
+ export {
30
+ ADDON_MANIFEST_CHANGED_TYPE,
31
+ wireHotSwapInvalidation,
32
+ useManifestHotSwapSubscriber,
33
+ type AddonManifestChangedMessage,
34
+ type ManifestHotSwapClient,
35
+ type WireHotSwapInvalidationOptions,
36
+ } from './manifest-hotswap-subscriber'
37
+ export {
38
+ useHotSwapReload,
39
+ applyHotSwapReload,
40
+ withVersionParam,
41
+ clearFederationContainer,
42
+ shortenHash,
43
+ type HotSwapReloadStrategy,
44
+ type HotSwapReloadConfig,
45
+ type HotSwapReloadAction,
46
+ type HotSwapReloadDeps,
47
+ type UseHotSwapReloadResult,
48
+ } from './hotswap-reload-policy'
21
49
  export * from './dynamic-icon'
22
50
  export type {
23
51
  ColumnFilterConfig,