@asteby/metacore-runtime-react 39.2.5 → 39.2.7

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.
@@ -12,6 +12,7 @@
12
12
  // free; legacy callers that still ship `searchEndpoint` keep working.
13
13
  import { useEffect, useMemo, useRef, useState } from 'react'
14
14
  import { useApi } from './api-context'
15
+ import { loadQueryPart, optionsBatchToken, optionsModelFromUrl } from './query-batch'
15
16
 
16
17
  export interface ResolvedOption {
17
18
  /** Canonical id (server-side primary key). */
@@ -100,11 +101,58 @@ export interface UseOptionsResolverResult {
100
101
  * `/api/options/:model?field=…` endpoint. Returns the v0.9.0 envelope
101
102
  * `{ data, meta: { type, count } }` projected into a stable shape.
102
103
  *
103
- * The hook is intentionally minimal: it does NOT debounce `query`
104
- * (callers should hold the controlled value and pass it post-debounce)
105
- * and does NOT cache across hook instances (apps that need shared state
106
- * compose this with TanStack Query in their own layer).
104
+ * The hook does NOT debounce `query` (callers should pass it post-debounce).
105
+ * Identical lookups share one in-flight request and a 30s cache, so a column
106
+ * of reference cells does not fan out one request per row. The key includes
107
+ * the org and branch from the host session.
107
108
  */
109
+ const OPTIONS_TTL_MS = 30_000
110
+
111
+ type OptionsPayload = { options: ResolvedOption[]; meta: OptionsMeta }
112
+
113
+ const optionsCache = new Map<string, { payload: OptionsPayload; at: number }>()
114
+ const optionsInflight = new Map<string, Promise<OptionsPayload>>()
115
+ const optionsEpoch = new Map<string, number>()
116
+
117
+ export function optionsRequestKey(
118
+ scope: string,
119
+ url: string,
120
+ field: string,
121
+ query: string,
122
+ limit: number | undefined,
123
+ filter: string | undefined,
124
+ ): string {
125
+ return [scope, url, field, query, String(limit ?? ''), filter ?? ''].join('\n')
126
+ }
127
+
128
+ function optionsScope(): string {
129
+ if (typeof localStorage === 'undefined') return ''
130
+ let org = ''
131
+ try {
132
+ const raw = localStorage.getItem('auth_user')
133
+ if (raw) org = String((JSON.parse(raw) as { organization_id?: string }).organization_id ?? '')
134
+ } catch {
135
+ org = ''
136
+ }
137
+ const branch = localStorage.getItem('active_branch_id') ?? ''
138
+ return `${org}|${branch}`
139
+ }
140
+
141
+ function readOptionsEnvelope(body: any): OptionsPayload {
142
+ const rawOptions: any[] = Array.isArray(body?.data) ? body.data : []
143
+ const metaPayload =
144
+ body?.meta && typeof body.meta === 'object'
145
+ ? body.meta
146
+ : { type: body?.type, count: rawOptions.length }
147
+ return {
148
+ options: rawOptions.map(projectOption),
149
+ meta: {
150
+ type: metaPayload?.type ?? 'dynamic',
151
+ count: typeof metaPayload?.count === 'number' ? metaPayload.count : rawOptions.length,
152
+ },
153
+ }
154
+ }
155
+
108
156
  export function useOptionsResolver(args: UseOptionsResolverArgs): UseOptionsResolverResult {
109
157
  const {
110
158
  modelKey,
@@ -146,9 +194,7 @@ export function useOptionsResolver(args: UseOptionsResolverArgs): UseOptionsReso
146
194
  return ''
147
195
  }, [fieldKey, ref])
148
196
 
149
- // Track the in-flight controller so a new fetch can abort the
150
- // previous one — matters for typeahead callers passing changing `query`.
151
- const abortRef = useRef<AbortController | null>(null)
197
+ const seenRefresh = useRef(0)
152
198
 
153
199
  useEffect(() => {
154
200
  if (!enabled || !url || !effectiveField) {
@@ -158,61 +204,101 @@ export function useOptionsResolver(args: UseOptionsResolverArgs): UseOptionsReso
158
204
  setError(null)
159
205
  return
160
206
  }
161
- // Cancel any pending request before issuing a new one.
162
- abortRef.current?.abort()
163
- const controller = new AbortController()
164
- abortRef.current = controller
207
+
208
+ const key = optionsRequestKey(
209
+ optionsScope(),
210
+ url,
211
+ effectiveField,
212
+ query ?? '',
213
+ limit,
214
+ filterValue,
215
+ )
216
+ if (refreshKey !== seenRefresh.current) {
217
+ seenRefresh.current = refreshKey
218
+ optionsEpoch.set(key, (optionsEpoch.get(key) ?? 0) + 1)
219
+ optionsCache.delete(key)
220
+ optionsInflight.delete(key)
221
+ }
222
+ const epoch = optionsEpoch.get(key) ?? 0
223
+ const cached = optionsCache.get(key)
224
+ if (cached && Date.now() - cached.at < OPTIONS_TTL_MS) {
225
+ setOptions(cached.payload.options)
226
+ setMeta(cached.payload.meta)
227
+ setLoading(false)
228
+ setError(null)
229
+ return
230
+ }
165
231
 
166
232
  setLoading(true)
167
233
  setError(null)
168
234
 
169
- const params: Record<string, string | number> = { field: effectiveField }
170
- if (query) params.q = query
171
- if (typeof limit === 'number' && limit > 0) params.limit = limit
172
- // Cascade scope: a dependent picker passes the value of the field it
173
- // `dependsOn`. Skip empty strings so a cleared parent omits the param
174
- // (no scope) rather than querying for the empty-string filter_value.
175
- if (filterValue) params.filter_value = filterValue
176
-
177
- api.get(url, { params, signal: controller.signal })
178
- .then((res) => {
179
- if (controller.signal.aborted) return
235
+ let pending = optionsInflight.get(key)
236
+ if (!pending) {
237
+ const params: Record<string, string | number> = { field: effectiveField }
238
+ if (query) params.q = query
239
+ if (typeof limit === 'number' && limit > 0) params.limit = limit
240
+ if (filterValue) params.filter_value = filterValue
241
+ const model = optionsModelFromUrl(url)
242
+ pending = (async () => {
243
+ if (model) {
244
+ try {
245
+ const part = await loadQueryPart(
246
+ api,
247
+ optionsBatchToken(model, effectiveField, query ?? '', limit, filterValue),
248
+ )
249
+ if (!part.success) {
250
+ throw new Error(part.message || 'options resolver: unsuccessful response')
251
+ }
252
+ const payload = readOptionsEnvelope({
253
+ success: true,
254
+ data: part.data,
255
+ meta: part.meta,
256
+ })
257
+ if ((optionsEpoch.get(key) ?? 0) === epoch) {
258
+ optionsCache.set(key, { payload, at: Date.now() })
259
+ }
260
+ return payload
261
+ } catch (err) {
262
+ const msg = err instanceof Error ? err.message : ''
263
+ const batchMiss = msg === 'batch unavailable' || msg === 'batch part missing'
264
+ if (!batchMiss) throw err
265
+ }
266
+ }
267
+ const res = await api.get(url, { params })
180
268
  const body = (res as { data: any }).data
181
269
  if (!body || body.success !== true) {
182
270
  throw new Error(body?.message || 'options resolver: unsuccessful response')
183
271
  }
184
- const rawOptions: any[] = Array.isArray(body.data) ? body.data : []
185
- const projected = rawOptions.map(projectOption)
186
- setOptions(projected)
187
- // v0.9.0 envelope: meta.type / meta.count. We tolerate
188
- // older deployments that still emit a root-level `type`
189
- // by reading either spot — the projection prefers the
190
- // canonical location so the SDK guides apps to the new
191
- // shape without breaking grace-period upgrades.
192
- const metaPayload =
193
- body.meta && typeof body.meta === 'object'
194
- ? body.meta
195
- : { type: body.type, count: rawOptions.length }
196
- setMeta({
197
- type: metaPayload?.type ?? 'dynamic',
198
- count:
199
- typeof metaPayload?.count === 'number'
200
- ? metaPayload.count
201
- : rawOptions.length,
202
- })
272
+ const payload = readOptionsEnvelope(body)
273
+ if ((optionsEpoch.get(key) ?? 0) === epoch) {
274
+ optionsCache.set(key, { payload, at: Date.now() })
275
+ }
276
+ return payload
277
+ })().finally(() => {
278
+ if (optionsInflight.get(key) === pending) optionsInflight.delete(key)
279
+ })
280
+ optionsInflight.set(key, pending)
281
+ }
282
+
283
+ let cancelled = false
284
+ pending
285
+ .then((payload) => {
286
+ if (cancelled) return
287
+ setOptions(payload.options)
288
+ setMeta(payload.meta)
203
289
  })
204
290
  .catch((err: any) => {
205
- if (controller.signal.aborted) return
291
+ if (cancelled) return
206
292
  setError(err instanceof Error ? err : new Error(String(err)))
207
293
  setOptions([])
208
294
  setMeta(null)
209
295
  })
210
296
  .finally(() => {
211
- if (!controller.signal.aborted) setLoading(false)
297
+ if (!cancelled) setLoading(false)
212
298
  })
213
299
 
214
300
  return () => {
215
- controller.abort()
301
+ cancelled = true
216
302
  }
217
303
  }, [api, url, effectiveField, query, limit, enabled, filterValue, refreshKey])
218
304