@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.
- package/CHANGELOG.md +24 -0
- package/dist/action-modal-dispatcher.d.ts +20 -0
- package/dist/action-modal-dispatcher.d.ts.map +1 -1
- package/dist/action-modal-dispatcher.js +3 -3
- package/dist/addon-fiber.d.ts +57 -0
- package/dist/addon-fiber.d.ts.map +1 -0
- package/dist/addon-fiber.js +122 -0
- package/dist/addon-loader.d.ts +19 -3
- package/dist/addon-loader.d.ts.map +1 -1
- package/dist/addon-loader.js +36 -37
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/permissions-manager.d.ts.map +1 -1
- package/dist/permissions-manager.js +29 -4
- package/package.json +3 -3
- package/src/__tests__/addon-fiber.test.ts +111 -0
- package/src/__tests__/prefill-from-record.test.ts +146 -0
- package/src/action-modal-dispatcher.tsx +4 -4
- package/src/addon-fiber.ts +155 -0
- package/src/addon-loader.tsx +66 -47
- package/src/index.ts +11 -0
- package/src/permissions-manager.tsx +69 -2
|
@@ -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 }
|
package/src/addon-loader.tsx
CHANGED
|
@@ -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
|
-
//
|
|
7
|
-
// the
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
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
|
-
//
|
|
104
|
-
// the
|
|
105
|
-
//
|
|
106
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
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,
|