@asteby/metacore-runtime-react 36.0.1 → 37.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 +22 -0
- package/README.md +33 -0
- package/dist/dynamic-kanban.d.ts +7 -1
- package/dist/dynamic-kanban.d.ts.map +1 -1
- package/dist/dynamic-kanban.js +12 -2
- package/dist/dynamic-select-field.d.ts +13 -1
- package/dist/dynamic-select-field.d.ts.map +1 -1
- package/dist/dynamic-select-field.js +6 -1
- package/dist/dynamic-table.d.ts +19 -1
- package/dist/dynamic-table.d.ts.map +1 -1
- package/dist/dynamic-table.js +29 -7
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/realtime-context.d.ts +82 -0
- package/dist/realtime-context.d.ts.map +1 -0
- package/dist/realtime-context.js +182 -0
- package/package.json +5 -5
- package/src/__tests__/realtime-context.test.tsx +195 -0
- package/src/dynamic-kanban.tsx +18 -1
- package/src/dynamic-select-field.tsx +19 -0
- package/src/dynamic-table.tsx +48 -6
- package/src/index.ts +15 -0
- package/src/realtime-context.tsx +248 -0
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
// realtime-context — React surface over the host's RealtimeAPI
|
|
2
|
+
// (`@asteby/metacore-sdk`): a provider the host mounts once, `useRealtime`
|
|
3
|
+
// for handlers, `useRealtimeInvalidate` to refresh react-query caches, and
|
|
4
|
+
// `useRealtimeTick` — the opt-in refetch signal `DynamicTable` /
|
|
5
|
+
// `DynamicKanban` consume through their `realtime` prop.
|
|
6
|
+
//
|
|
7
|
+
// Module-federation note: a federated addon may run its OWN copy of this
|
|
8
|
+
// module, in which case React context from the host is invisible to it. Every
|
|
9
|
+
// hook therefore accepts an explicit `client` (pass `host.realtime` or
|
|
10
|
+
// `api.realtime`) that wins over the context — the plain object crosses the
|
|
11
|
+
// federation boundary, the context does not.
|
|
12
|
+
//
|
|
13
|
+
// react-query: `useRealtimeInvalidate` uses `useQueryClient()` so it always
|
|
14
|
+
// targets the QueryClient the host mounted (single instance per page).
|
|
15
|
+
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
|
|
16
|
+
import type { QueryKey } from '@tanstack/react-query'
|
|
17
|
+
import { useQueryClient } from '@tanstack/react-query'
|
|
18
|
+
import type {
|
|
19
|
+
DataEvent,
|
|
20
|
+
DataEventAction,
|
|
21
|
+
DataEventHandler,
|
|
22
|
+
RealtimeAPI,
|
|
23
|
+
RealtimeStatus,
|
|
24
|
+
RealtimeSubscribeOptions,
|
|
25
|
+
} from '@asteby/metacore-sdk'
|
|
26
|
+
|
|
27
|
+
export interface RealtimeContextValue {
|
|
28
|
+
/** Host-provided client, or null when the host has no realtime. */
|
|
29
|
+
client: RealtimeAPI | null
|
|
30
|
+
/**
|
|
31
|
+
* When true, `DynamicTable` / `DynamicKanban` refetch on data events
|
|
32
|
+
* unless a component passes `realtime={false}`. Default false (opt-in).
|
|
33
|
+
*/
|
|
34
|
+
defaultRealtime: boolean
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const RealtimeContext = createContext<RealtimeContextValue>({ client: null, defaultRealtime: false })
|
|
38
|
+
|
|
39
|
+
export interface RealtimeProviderProps {
|
|
40
|
+
client: RealtimeAPI | null | undefined
|
|
41
|
+
/** Turn realtime refetch on for every dynamic table/kanban below. Default false. */
|
|
42
|
+
defaultRealtime?: boolean
|
|
43
|
+
children: React.ReactNode
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Mount once, inside the host's WebSocket provider and QueryClientProvider.
|
|
48
|
+
* `client` may be null while the host is still connecting — hooks become
|
|
49
|
+
* no-ops and re-subscribe when a client appears.
|
|
50
|
+
*/
|
|
51
|
+
export function RealtimeProvider({ client, defaultRealtime = false, children }: RealtimeProviderProps) {
|
|
52
|
+
const value = useMemo<RealtimeContextValue>(
|
|
53
|
+
() => ({ client: client ?? null, defaultRealtime }),
|
|
54
|
+
[client, defaultRealtime],
|
|
55
|
+
)
|
|
56
|
+
return <RealtimeContext.Provider value={value}>{children}</RealtimeContext.Provider>
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The host realtime client from context (null when none is mounted). */
|
|
60
|
+
export function useRealtimeClient(explicit?: RealtimeAPI | null): RealtimeAPI | null {
|
|
61
|
+
const ctx = useContext(RealtimeContext)
|
|
62
|
+
return explicit ?? ctx.client
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Whether dynamic components should refetch on data events by default. */
|
|
66
|
+
export function useRealtimeDefault(): boolean {
|
|
67
|
+
return useContext(RealtimeContext).defaultRealtime
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface UseRealtimeOptions extends RealtimeSubscribeOptions {
|
|
71
|
+
/** Pass `host.realtime` / `api.realtime` to bypass the context. */
|
|
72
|
+
client?: RealtimeAPI | null
|
|
73
|
+
/** Set false to pause the subscription without unmounting. Default true. */
|
|
74
|
+
enabled?: boolean
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Subscribe to data events for the given models. The handler is kept in a
|
|
79
|
+
* ref so callers don't need to memoise it; the subscription is torn down on
|
|
80
|
+
* unmount and re-created when models/events/client change.
|
|
81
|
+
*
|
|
82
|
+
* useRealtime({ models: ['SalesOrder'], client: host.realtime }, (e) => {
|
|
83
|
+
* if (e.action === 'resync' || e.id === currentId) refetch()
|
|
84
|
+
* })
|
|
85
|
+
*/
|
|
86
|
+
export function useRealtime(options: UseRealtimeOptions, handler: DataEventHandler): void {
|
|
87
|
+
const client = useRealtimeClient(options.client)
|
|
88
|
+
const handlerRef = useRef(handler)
|
|
89
|
+
handlerRef.current = handler
|
|
90
|
+
const enabled = options.enabled ?? true
|
|
91
|
+
const modelsKey = (options.models ?? []).map((m) => m.trim().toLowerCase()).sort().join('|')
|
|
92
|
+
const eventsKey = (options.events ?? []).slice().sort().join('|')
|
|
93
|
+
|
|
94
|
+
useEffect(() => {
|
|
95
|
+
if (!client || !enabled || !modelsKey) return
|
|
96
|
+
const models = modelsKey.split('|')
|
|
97
|
+
const events = eventsKey ? (eventsKey.split('|') as DataEventAction[]) : undefined
|
|
98
|
+
let off: () => void = () => {}
|
|
99
|
+
try {
|
|
100
|
+
off = client.subscribe({ models, events }, (event) => handlerRef.current(event))
|
|
101
|
+
} catch {
|
|
102
|
+
/* a host client must not throw, but never take the page down */
|
|
103
|
+
}
|
|
104
|
+
return off
|
|
105
|
+
}, [client, enabled, modelsKey, eventsKey])
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Live status of the realtime client ('closed' when none). */
|
|
109
|
+
export function useRealtimeStatus(explicit?: RealtimeAPI | null): RealtimeStatus {
|
|
110
|
+
const client = useRealtimeClient(explicit)
|
|
111
|
+
const [status, setStatus] = useState<RealtimeStatus>(() => client?.status() ?? 'closed')
|
|
112
|
+
useEffect(() => {
|
|
113
|
+
if (!client) {
|
|
114
|
+
setStatus('closed')
|
|
115
|
+
return
|
|
116
|
+
}
|
|
117
|
+
setStatus(client.status())
|
|
118
|
+
if (!client.onStatus) return
|
|
119
|
+
return client.onStatus(setStatus)
|
|
120
|
+
}, [client])
|
|
121
|
+
return status
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Default matcher: any string segment of the query key (case-insensitive)
|
|
125
|
+
* equals the event's model, table or `addon.model`, or contains the model
|
|
126
|
+
* as a path segment (`/data/sales_orders`). */
|
|
127
|
+
export function queryKeyMatchesEvent(queryKey: QueryKey, event: DataEvent): boolean {
|
|
128
|
+
const needles = [event.model, event.table ?? '', `${event.addon}.${event.model}`]
|
|
129
|
+
.map((s) => s.toLowerCase())
|
|
130
|
+
.filter(Boolean)
|
|
131
|
+
const parts = flattenKey(queryKey)
|
|
132
|
+
for (const part of parts) {
|
|
133
|
+
const p = part.toLowerCase()
|
|
134
|
+
for (const n of needles) {
|
|
135
|
+
if (p === n) return true
|
|
136
|
+
// '/data/sales_orders?x=1' or 'pos/SalesOrder' style segments.
|
|
137
|
+
const segs = p.split(/[/?&=]/)
|
|
138
|
+
if (segs.includes(n)) return true
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return false
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function flattenKey(key: QueryKey, out: string[] = [], depth = 0): string[] {
|
|
145
|
+
if (depth > 4) return out
|
|
146
|
+
for (const item of key as unknown[]) {
|
|
147
|
+
if (typeof item === 'string') out.push(item)
|
|
148
|
+
else if (Array.isArray(item)) flattenKey(item as QueryKey, out, depth + 1)
|
|
149
|
+
else if (item && typeof item === 'object') {
|
|
150
|
+
for (const v of Object.values(item as Record<string, unknown>)) {
|
|
151
|
+
if (typeof v === 'string') out.push(v)
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return out
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export interface UseRealtimeInvalidateOptions extends UseRealtimeOptions {
|
|
159
|
+
/** Override which queries a given event invalidates. Default: `queryKeyMatchesEvent`. */
|
|
160
|
+
match?: (queryKey: QueryKey, event: DataEvent) => boolean
|
|
161
|
+
/** Coalesce bursts before invalidating (ms). Default 250. */
|
|
162
|
+
debounceMs?: number
|
|
163
|
+
/** Observe each event after it was scheduled for invalidation. */
|
|
164
|
+
onEvent?: DataEventHandler
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Invalidate every react-query query whose key mentions the event's model /
|
|
169
|
+
* table (see `queryKeyMatchesEvent`). Uses the host's QueryClient. Bursts
|
|
170
|
+
* are debounced so a coalesced frame storm becomes a single refetch round.
|
|
171
|
+
*
|
|
172
|
+
* useRealtimeInvalidate({ models: ['SalesOrder', 'sales_order_items'], client: host.realtime })
|
|
173
|
+
*/
|
|
174
|
+
export function useRealtimeInvalidate(options: UseRealtimeInvalidateOptions): void {
|
|
175
|
+
const queryClient = useQueryClient()
|
|
176
|
+
const { match = queryKeyMatchesEvent, debounceMs = 250, onEvent } = options
|
|
177
|
+
const pendingRef = useRef<DataEvent[]>([])
|
|
178
|
+
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
179
|
+
const matchRef = useRef(match)
|
|
180
|
+
matchRef.current = match
|
|
181
|
+
const onEventRef = useRef(onEvent)
|
|
182
|
+
onEventRef.current = onEvent
|
|
183
|
+
|
|
184
|
+
const flush = useCallback(() => {
|
|
185
|
+
timerRef.current = null
|
|
186
|
+
const batch = pendingRef.current
|
|
187
|
+
pendingRef.current = []
|
|
188
|
+
if (batch.length === 0) return
|
|
189
|
+
void queryClient.invalidateQueries({
|
|
190
|
+
predicate: (query) => batch.some((event) => matchRef.current(query.queryKey, event)),
|
|
191
|
+
})
|
|
192
|
+
}, [queryClient])
|
|
193
|
+
|
|
194
|
+
useRealtime(options, (event) => {
|
|
195
|
+
pendingRef.current.push(event)
|
|
196
|
+
onEventRef.current?.(event)
|
|
197
|
+
if (timerRef.current) return
|
|
198
|
+
timerRef.current = setTimeout(flush, debounceMs)
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
useEffect(
|
|
202
|
+
() => () => {
|
|
203
|
+
if (timerRef.current) clearTimeout(timerRef.current)
|
|
204
|
+
},
|
|
205
|
+
[],
|
|
206
|
+
)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export interface UseRealtimeTickOptions {
|
|
210
|
+
/** Models/tables to watch. */
|
|
211
|
+
models: string[]
|
|
212
|
+
/** Explicit client (federated addons). */
|
|
213
|
+
client?: RealtimeAPI | null
|
|
214
|
+
/** Master switch — the caller resolves prop vs provider default. */
|
|
215
|
+
enabled: boolean
|
|
216
|
+
/** Coalesce bursts before ticking (ms). Default 300. */
|
|
217
|
+
debounceMs?: number
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* A counter that increments (debounced) whenever a data event lands for one
|
|
222
|
+
* of `models`. Fold it into a refetch effect's dependencies — that is exactly
|
|
223
|
+
* what `DynamicTable` / `DynamicKanban` do behind their `realtime` prop.
|
|
224
|
+
*/
|
|
225
|
+
export function useRealtimeTick(options: UseRealtimeTickOptions): number {
|
|
226
|
+
const [tick, setTick] = useState(0)
|
|
227
|
+
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
228
|
+
const debounceMs = options.debounceMs ?? 300
|
|
229
|
+
|
|
230
|
+
useRealtime(
|
|
231
|
+
{ models: options.models, client: options.client, enabled: options.enabled },
|
|
232
|
+
() => {
|
|
233
|
+
if (timerRef.current) return
|
|
234
|
+
timerRef.current = setTimeout(() => {
|
|
235
|
+
timerRef.current = null
|
|
236
|
+
setTick((t) => t + 1)
|
|
237
|
+
}, debounceMs)
|
|
238
|
+
},
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
useEffect(
|
|
242
|
+
() => () => {
|
|
243
|
+
if (timerRef.current) clearTimeout(timerRef.current)
|
|
244
|
+
},
|
|
245
|
+
[],
|
|
246
|
+
)
|
|
247
|
+
return tick
|
|
248
|
+
}
|