@symbo.ls/fetch 3.14.661 → 3.14.662
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 +36 -0
- package/README.md +221 -35
- package/adapters/rest.js +165 -26
- package/dist/cjs/adapters/rest.js +1 -1
- package/dist/cjs/early.js +1 -0
- package/dist/cjs/earlyScript.js +53 -0
- package/dist/cjs/index.js +1 -1
- package/dist/esm/adapters/rest.js +1 -1
- package/dist/esm/early.js +1 -0
- package/dist/esm/earlyScript.js +53 -0
- package/dist/esm/index.js +1 -1
- package/early.js +160 -0
- package/earlyScript.js +204 -0
- package/index.js +490 -119
- package/package.json +9 -2
package/index.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
3
|
import { isArray, isFunction, isObject, isString, exec } from '@symbo.ls/utils'
|
|
4
|
+
import { createEffect } from '@symbo.ls/signal'
|
|
5
|
+
import { dropEarly } from './early.js'
|
|
4
6
|
|
|
5
7
|
// --- Adapter resolution ---
|
|
6
8
|
|
|
@@ -122,6 +124,16 @@ export const registerAdapter = (name, loader) => {
|
|
|
122
124
|
}
|
|
123
125
|
}
|
|
124
126
|
|
|
127
|
+
// Whether `name` resolves without waiting: a built-in, or a custom adapter
|
|
128
|
+
// that has already been registered (in any module copy — CUSTOM_ADAPTERS is
|
|
129
|
+
// the shared globalThis slot). resolveDb() waits up to
|
|
130
|
+
// _ADAPTER_REGISTRATION_TIMEOUT for an unknown name, which is right for a
|
|
131
|
+
// browser boot race and wrong for a server render, where no page runtime will
|
|
132
|
+
// ever register the platform's 'sdk' adapter — @symbo.ls/brender asks first
|
|
133
|
+
// and skips server prefetch instead of stalling every route for 5 s.
|
|
134
|
+
export const hasAdapter = (name) =>
|
|
135
|
+
typeof name === 'string' && !!(CUSTOM_ADAPTERS[name] || BUILTIN_ADAPTERS[name])
|
|
136
|
+
|
|
125
137
|
export const createAdapter = (config) => {
|
|
126
138
|
const adapter = {}
|
|
127
139
|
for (const method of ADAPTER_METHODS) {
|
|
@@ -189,31 +201,58 @@ const cacheStore = new Map()
|
|
|
189
201
|
const querySubscribers = new Map() // key -> Set of elements
|
|
190
202
|
const activeQueries = new Map() // key -> Promise (deduplication)
|
|
191
203
|
|
|
204
|
+
// Request order. Every request a query sends, every cache write and every
|
|
205
|
+
// invalidation takes the next number. A reply then knows whether it is older
|
|
206
|
+
// than its key's last invalidation (its rows may predate the write: kept, but
|
|
207
|
+
// never served as fresh) and whether a request that started after it has
|
|
208
|
+
// already written its answer (that newer answer wins — a slow reply never
|
|
209
|
+
// puts old rows back over it).
|
|
210
|
+
let fetchSeq = 0
|
|
211
|
+
const invalidatedAt = new Map() // key -> seq of the key's last invalidation or removal
|
|
212
|
+
const requestSeqs = new WeakMap() // in-flight promise -> seq of its request
|
|
213
|
+
|
|
214
|
+
// The cache (and dedup) key of a query. runFetch writes its entry under it and
|
|
215
|
+
// the window-focus check reads it back, so both build it here, from the params
|
|
216
|
+
// RESOLVED with the element (resolveParams: params written as a function, or
|
|
217
|
+
// with function values) — a check that read them unresolved (a function
|
|
218
|
+
// serialises to nothing) never found a fresh entry and ran the query again on
|
|
219
|
+
// every focus, writing the cached rows back into state each time.
|
|
192
220
|
// Cache key includes lang when provided so different-locale responses don't
|
|
193
221
|
// collide. The fetch plugin injects Accept-Language on requests, so the same
|
|
194
222
|
// (from, method, params) tuple yields different bodies per lang. queryClient.
|
|
195
223
|
// dropNonLang(keepLang) leans on this suffix to evict stale-language entries
|
|
196
224
|
// on a setLang() event without touching the kept lang's cache.
|
|
197
|
-
|
|
225
|
+
//
|
|
226
|
+
// The page a request asks for is part of its answer, so `page` (offset pages)
|
|
227
|
+
// and `cursor` (an infinite query's next / previous page) are in the key too,
|
|
228
|
+
// RESOLVED like params (`page: (el, s) => s.page`). The cursor was missing:
|
|
229
|
+
// fetchNextPage asked under page 1's key, so with a cache the next page came
|
|
230
|
+
// back as page 1, and page 2's rows were written over page 1's entry.
|
|
231
|
+
const queryCacheKey = (config, params, scope, lang, page = config.page, cursor = config.cursor) => {
|
|
198
232
|
if (config.cache?.key) return config.cache.key
|
|
199
|
-
const
|
|
233
|
+
const { from, method, infinite } = config
|
|
200
234
|
// Scope segment sits AFTER `${from}:${method}:` so `from`/`from:method`
|
|
201
235
|
// prefix matching (mutation `invalidates`, queryClient string patterns)
|
|
202
236
|
// and the trailing lang suffix (dropNonLang) are both preserved.
|
|
203
237
|
const scopeSeg = scope ? `${scope}:` : ''
|
|
204
|
-
const
|
|
205
|
-
|
|
238
|
+
const langSuffix = lang ? `:${lang}` : ''
|
|
239
|
+
const pageSeg = page ? ':p' + JSON.stringify(page) : ''
|
|
240
|
+
const cursorSeg = cursor !== undefined && cursor !== null ? ':c' + JSON.stringify(cursor) : ''
|
|
241
|
+
return `${from}:${method}:${scopeSeg}${JSON.stringify(params || '')}${infinite ? ':infinite' : ''}${pageSeg}${cursorSeg}${langSuffix}`
|
|
206
242
|
}
|
|
207
243
|
|
|
208
244
|
const getCacheEntry = (key) => cacheStore.get(key) || null
|
|
209
245
|
|
|
210
|
-
|
|
211
|
-
|
|
246
|
+
// `seq` is the request the rows came from (a direct write — setQueryData,
|
|
247
|
+
// initialData, a prefetch — is its own, current one); `stale` is set for rows
|
|
248
|
+
// whose request started before the key's last invalidation.
|
|
249
|
+
const setCacheEntry = (key, data, error, seq = ++fetchSeq, stale = false) => {
|
|
212
250
|
const entry = {
|
|
213
251
|
data,
|
|
214
252
|
error,
|
|
215
253
|
time: Date.now(),
|
|
216
|
-
stale
|
|
254
|
+
stale,
|
|
255
|
+
seq
|
|
217
256
|
}
|
|
218
257
|
cacheStore.set(key, entry)
|
|
219
258
|
|
|
@@ -228,23 +267,26 @@ const setCacheEntry = (key, data, error) => {
|
|
|
228
267
|
return entry
|
|
229
268
|
}
|
|
230
269
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
}
|
|
237
|
-
// Invalidate all
|
|
238
|
-
for (const [, entry] of cacheStore) {
|
|
239
|
-
entry.stale = true
|
|
240
|
-
}
|
|
270
|
+
// The entry a request that started after `seq` wrote (an error aside): the
|
|
271
|
+
// newer answer, which an older reply must not replace.
|
|
272
|
+
const newerEntry = (key, seq) => {
|
|
273
|
+
const entry = key ? cacheStore.get(key) : null
|
|
274
|
+
return entry && !entry.error && entry.seq > seq ? entry : null
|
|
241
275
|
}
|
|
242
276
|
|
|
277
|
+
// A request that started before its key's last invalidation (or removal).
|
|
278
|
+
const isOutdated = (key, seq) => (invalidatedAt.get(key) || 0) > seq
|
|
279
|
+
|
|
280
|
+
// Removing an entry also marks its key, so a reply still in flight writes
|
|
281
|
+
// its rows back as stale, never as fresh.
|
|
243
282
|
const removeCache = (key) => {
|
|
244
283
|
if (key) {
|
|
245
284
|
cacheStore.delete(key)
|
|
285
|
+
invalidatedAt.set(key, ++fetchSeq)
|
|
246
286
|
return
|
|
247
287
|
}
|
|
288
|
+
for (const [cacheKey] of cacheStore) invalidatedAt.set(cacheKey, ++fetchSeq)
|
|
289
|
+
for (const [inFlightKey] of activeQueries) invalidatedAt.set(inFlightKey, ++fetchSeq)
|
|
248
290
|
cacheStore.clear()
|
|
249
291
|
}
|
|
250
292
|
|
|
@@ -275,6 +317,28 @@ const parseCacheConfig = (cache) => {
|
|
|
275
317
|
}
|
|
276
318
|
}
|
|
277
319
|
|
|
320
|
+
// --- Mounted queries ---
|
|
321
|
+
//
|
|
322
|
+
// The queries on screen: one record per element and `on: 'create'` query
|
|
323
|
+
// config, registered by executeFetch. An invalidation refetches the ones whose
|
|
324
|
+
// CURRENT key it selects (TanStack's "active" queries) instead of leaving them
|
|
325
|
+
// on the old rows until their next mount or a window focus. The key is
|
|
326
|
+
// computed when needed — the same resolution as the focus check — because
|
|
327
|
+
// params, language and tenant scope can move after mount. A record whose
|
|
328
|
+
// element was removed (`__ref` gone or replaced) is dropped whenever the
|
|
329
|
+
// registry is walked: an invalidation, or the GC sweep.
|
|
330
|
+
const mountedQueries = new Set()
|
|
331
|
+
|
|
332
|
+
const isMounted = (record) => !!record.ref && record.element.__ref === record.ref
|
|
333
|
+
|
|
334
|
+
const mountedKey = ({ config, element, context }) => {
|
|
335
|
+
try {
|
|
336
|
+
return elementQueryKey(config, element, context)
|
|
337
|
+
} catch (e) {
|
|
338
|
+
return null // a params function that throws names no key
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
278
342
|
// --- GC ---
|
|
279
343
|
|
|
280
344
|
let gcTimer = null
|
|
@@ -290,6 +354,14 @@ const startGC = () => {
|
|
|
290
354
|
cacheStore.delete(key)
|
|
291
355
|
}
|
|
292
356
|
}
|
|
357
|
+
// What nothing can need any more: an invalidation mark matters while its
|
|
358
|
+
// key has rows or a request out, and a removed element is not mounted.
|
|
359
|
+
for (const [key] of invalidatedAt) {
|
|
360
|
+
if (!cacheStore.has(key) && !activeQueries.has(key)) invalidatedAt.delete(key)
|
|
361
|
+
}
|
|
362
|
+
for (const record of mountedQueries) {
|
|
363
|
+
if (!isMounted(record)) mountedQueries.delete(record)
|
|
364
|
+
}
|
|
293
365
|
}, 30000)
|
|
294
366
|
if (gcTimer.unref) gcTimer.unref()
|
|
295
367
|
}
|
|
@@ -301,8 +373,13 @@ startGC()
|
|
|
301
373
|
const DEFAULT_RETRY = 3
|
|
302
374
|
const DEFAULT_RETRY_DELAY = (attempt) => Math.min(1000 * 2 ** attempt, 30000)
|
|
303
375
|
|
|
304
|
-
|
|
376
|
+
// `write`: the config changes data (insert / update / upsert / delete, an rpc
|
|
377
|
+
// action). A write is not sent again unless `retry` asks for it: one that
|
|
378
|
+
// failed by timing out may have been applied, and a retry applies it twice.
|
|
379
|
+
// Reads keep the default retries.
|
|
380
|
+
const resolveRetryConfig = (config, write) => {
|
|
305
381
|
const retry = config.retry
|
|
382
|
+
if (retry === undefined && write) return { count: 0 }
|
|
306
383
|
if (retry === false) return { count: 0 }
|
|
307
384
|
if (retry === true || retry === undefined) return { count: DEFAULT_RETRY, delay: DEFAULT_RETRY_DELAY }
|
|
308
385
|
if (typeof retry === 'number') return { count: retry, delay: DEFAULT_RETRY_DELAY }
|
|
@@ -389,9 +466,23 @@ const resolveFetchConfig = (fetchProp, element) => {
|
|
|
389
466
|
return null
|
|
390
467
|
}
|
|
391
468
|
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
469
|
+
// --- Writes and actions ---
|
|
470
|
+
//
|
|
471
|
+
// insert / update / upsert / delete are writes (runMutation). An `rpc` is a
|
|
472
|
+
// call that may write, so it is an ACTION — sent on every trigger, never
|
|
473
|
+
// answered from or written to the query cache, never re-sent by a refetch or
|
|
474
|
+
// an invalidation, its `invalidates` run on success — when a user event fires
|
|
475
|
+
// it (`on: 'submit'`, `on: 'click'`), when it declares `invalidates`, or with
|
|
476
|
+
// `mutation: true` (which makes any method an action). `mutation: false`
|
|
477
|
+
// keeps an rpc a cached query. An rpc read on create stays what it was: a
|
|
478
|
+
// cached, deduplicated view of data.
|
|
479
|
+
const MUTATION_METHODS = ['insert', 'update', 'upsert', 'delete']
|
|
480
|
+
const isMutationMethod = (method) => MUTATION_METHODS.includes(method)
|
|
481
|
+
|
|
482
|
+
const isAction = (config) => {
|
|
483
|
+
if (config.mutation === true) return true
|
|
484
|
+
if (config.mutation === false || config.method !== 'rpc') return false
|
|
485
|
+
return config.on === 'submit' || config.on === 'click' || config.invalidates != null
|
|
395
486
|
}
|
|
396
487
|
|
|
397
488
|
const resolveLanguage = (element, context) => {
|
|
@@ -431,6 +522,22 @@ const resolveParams = (params, element, context) => {
|
|
|
431
522
|
return resolved
|
|
432
523
|
}
|
|
433
524
|
|
|
525
|
+
// A paging value (`page`, `cursor`) written as a function resolves with the
|
|
526
|
+
// element, as params do. The README's `page: (el, s) => s.page` was never
|
|
527
|
+
// called: it sent no offset, and its key carried no page.
|
|
528
|
+
const resolveValue = (value, element) => (isFunction(value) ? value(element, element.state) : value)
|
|
529
|
+
|
|
530
|
+
// The key a query of `config` has for `element` right now: the one runFetch
|
|
531
|
+
// writes, the focus check reads, an invalidation matches and keyFor returns.
|
|
532
|
+
const elementQueryKey = (config, element, context) => queryCacheKey(
|
|
533
|
+
config,
|
|
534
|
+
resolveParams(config.params, element, context),
|
|
535
|
+
resolveScope(element, context),
|
|
536
|
+
resolveLanguage(element, context),
|
|
537
|
+
resolveValue(config.page, element),
|
|
538
|
+
resolveValue(config.cursor, element)
|
|
539
|
+
)
|
|
540
|
+
|
|
434
541
|
export const initAdapterAuth = async (adapter, context) => {
|
|
435
542
|
if (!adapter || adapter.__authInitialized) return
|
|
436
543
|
adapter.__authInitialized = true
|
|
@@ -572,11 +679,31 @@ const setFetchStatus = (element, status) => {
|
|
|
572
679
|
// `TypeError: Cannot set properties of null` from every status write.
|
|
573
680
|
const ref = element?.__ref
|
|
574
681
|
if (!ref) return
|
|
575
|
-
|
|
682
|
+
// `isError` is part of the documented status shape; no call site set it.
|
|
683
|
+
ref.__fetchStatus = { ...status, isError: !!status.error }
|
|
576
684
|
ref.__fetching = status.isFetching
|
|
577
685
|
ref.__fetchError = status.error
|
|
578
686
|
}
|
|
579
687
|
|
|
688
|
+
// --- Infinite queries ---
|
|
689
|
+
|
|
690
|
+
// The next / previous cursor of an infinite query, as documented:
|
|
691
|
+
// getNextPageParam(lastPage, allPages), getPreviousPageParam(firstPage,
|
|
692
|
+
// allPages). Both used to receive the page just fetched, so a previous page
|
|
693
|
+
// moved the next cursor. Called on every reply and when the first page is
|
|
694
|
+
// served from the cache.
|
|
695
|
+
const setPageCursors = (ref, config, fetched) => {
|
|
696
|
+
const pages = ref.__pages && ref.__pages.length ? ref.__pages : [fetched]
|
|
697
|
+
if (isFunction(config.getNextPageParam)) {
|
|
698
|
+
ref.__nextPageParam = config.getNextPageParam(pages[pages.length - 1], pages)
|
|
699
|
+
ref.__hasNextPage = ref.__nextPageParam != null
|
|
700
|
+
}
|
|
701
|
+
if (isFunction(config.getPreviousPageParam)) {
|
|
702
|
+
ref.__prevPageParam = config.getPreviousPageParam(pages[0], pages)
|
|
703
|
+
ref.__hasPreviousPage = ref.__prevPageParam != null
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
|
|
580
707
|
// --- Core fetch runner ---
|
|
581
708
|
|
|
582
709
|
const runFetch = async (config, element, context, opts = {}) => {
|
|
@@ -621,7 +748,7 @@ const runFetch = async (config, element, context, opts = {}) => {
|
|
|
621
748
|
single, auth, fields, as: stateKey, limit, offset, order,
|
|
622
749
|
headers, baseUrl, responseType,
|
|
623
750
|
// Pagination
|
|
624
|
-
page, cursor,
|
|
751
|
+
page, cursor, infinite,
|
|
625
752
|
// TanStack-style options
|
|
626
753
|
placeholderData, initialData, select: selectTransform,
|
|
627
754
|
keepPreviousData
|
|
@@ -634,9 +761,15 @@ const runFetch = async (config, element, context, opts = {}) => {
|
|
|
634
761
|
}
|
|
635
762
|
|
|
636
763
|
const params = resolveParams(rawParams, element, context)
|
|
637
|
-
|
|
638
|
-
const
|
|
639
|
-
|
|
764
|
+
const pageValue = resolveValue(page, element)
|
|
765
|
+
const cursorValue = resolveValue(cursor, element)
|
|
766
|
+
// An action (isAction) is a call, not a view of data: each trigger sends
|
|
767
|
+
// exactly one request — a second submit inside the stale time used to be
|
|
768
|
+
// answered from the cache, unsent.
|
|
769
|
+
const action = isAction(config)
|
|
770
|
+
// No cache key for a single-use body or an action: no cache entry, no dedup sharing.
|
|
771
|
+
const cacheConfig = isSingleUse(config) || action ? null : parseCacheConfig(cacheRaw)
|
|
772
|
+
const retryConfig = resolveRetryConfig(config, action || isMutationMethod(method))
|
|
640
773
|
const lang = resolveLanguage(element, context)
|
|
641
774
|
|
|
642
775
|
// Build cache key — lang suffix lets queryClient.dropNonLang(keepLang)
|
|
@@ -644,11 +777,7 @@ const runFetch = async (config, element, context, opts = {}) => {
|
|
|
644
777
|
// scopeSeg (after `${from}:${method}:`) isolates the cache + dedup keys per
|
|
645
778
|
// tenant so an org/workspace switch never serves — or dedup-collapses into —
|
|
646
779
|
// the previous tenant's entry, while keeping `from:method` as the key prefix.
|
|
647
|
-
const
|
|
648
|
-
const scopeSeg = scope ? `${scope}:` : ''
|
|
649
|
-
const cacheKey = cacheConfig
|
|
650
|
-
? (cacheRaw?.key || `${from}:${method}:${scopeSeg}${JSON.stringify(params || '')}${infinite ? ':infinite' : ''}${page ? ':p' + JSON.stringify(page) : ''}${langSuffix}`)
|
|
651
|
-
: null
|
|
780
|
+
const cacheKey = cacheConfig ? queryCacheKey(config, params, scope, lang, pageValue, cursorValue) : null
|
|
652
781
|
|
|
653
782
|
// Apply initial/placeholder data on first mount
|
|
654
783
|
if (!ref.__fetchInitialized && cacheKey) {
|
|
@@ -659,21 +788,35 @@ const runFetch = async (config, element, context, opts = {}) => {
|
|
|
659
788
|
}
|
|
660
789
|
}
|
|
661
790
|
|
|
662
|
-
// Check cache (stale-while-revalidate)
|
|
663
|
-
|
|
791
|
+
// Check cache (stale-while-revalidate). A run that must reach the network
|
|
792
|
+
// skips it: a refresh (an invalidation refetching a mounted query — the
|
|
793
|
+
// element shows these rows already) and a forced run (`el.__ref.refetch()`,
|
|
794
|
+
// refetchInterval, reconnect, fetchNextPage). `force` used to skip only the
|
|
795
|
+
// dedup, so while the entry was fresh every one of those answered from the
|
|
796
|
+
// cache and sent nothing — a poll shorter than the stale time never polled.
|
|
797
|
+
if ((method === 'select' || method === 'rpc') && cacheKey && cacheConfig && !opts.refresh && !opts.force) {
|
|
664
798
|
const cached = getCacheEntry(cacheKey)
|
|
665
799
|
if (cached && !cached.error) {
|
|
666
800
|
const age = Date.now() - cached.time
|
|
667
801
|
const isStale = cached.stale || age > (cacheConfig.staleTime || 0)
|
|
802
|
+
// An infinite query served from the cache starts its page list there,
|
|
803
|
+
// with its next / previous cursor, so fetchNextPage can go on from it.
|
|
804
|
+
if (infinite && isArray(cached.data) && !ref.__pages) {
|
|
805
|
+
ref.__pages = [cached.data]
|
|
806
|
+
setPageCursors(ref, config, cached.data)
|
|
807
|
+
}
|
|
668
808
|
let data = cached.data
|
|
669
809
|
if (isFunction(selectTransform)) data = selectTransform(data, element, element.state)
|
|
670
810
|
if (transform) data = transform(data, element, element.state)
|
|
671
811
|
if (!isCurrent()) return
|
|
672
|
-
|
|
812
|
+
// The status first: the state update re-renders, and a render that
|
|
813
|
+
// reads `__fetchStatus` must see the answer it renders (report #25).
|
|
673
814
|
if (!isStale) {
|
|
674
815
|
setFetchStatus(element, { isFetching: false, isLoading: false, isStale: false, isSuccess: true, error: null, status: 'success', fetchStatus: 'idle' })
|
|
816
|
+
updateElementState(element, data, stateKey)
|
|
675
817
|
return
|
|
676
818
|
}
|
|
819
|
+
updateElementState(element, data, stateKey)
|
|
677
820
|
// Stale — continue to background refetch
|
|
678
821
|
} else if (placeholderData !== undefined) {
|
|
679
822
|
const phData = isFunction(placeholderData) ? placeholderData(element, element.state) : placeholderData
|
|
@@ -683,14 +826,29 @@ const runFetch = async (config, element, context, opts = {}) => {
|
|
|
683
826
|
}
|
|
684
827
|
}
|
|
685
828
|
|
|
686
|
-
// Deduplication — if same query is already in-flight, share the promise (with
|
|
687
|
-
|
|
688
|
-
|
|
829
|
+
// Deduplication — if same query is already in-flight, share the promise (with
|
|
830
|
+
// timeout). Never one that started before the key's last invalidation: its
|
|
831
|
+
// reply may predate the write.
|
|
832
|
+
const existing = cacheKey && !opts.force ? activeQueries.get(cacheKey) : null
|
|
833
|
+
if (existing && !isOutdated(cacheKey, requestSeqs.get(existing) || 0)) {
|
|
834
|
+
// The shared request failed: so did this query, with the same error. It
|
|
835
|
+
// used to read an error answer as null rows (onFetchComplete(null)) and a
|
|
836
|
+
// thrown one as nothing at all.
|
|
837
|
+
const fail = (error) => {
|
|
838
|
+
setFetchStatus(element, { isFetching: false, isLoading: false, isStale: false, isSuccess: false, error, status: 'error', fetchStatus: 'idle' })
|
|
839
|
+
triggerCallback(element, 'onFetchError', error)
|
|
840
|
+
triggerConfigCallback(config, element, 'onFetchError', error)
|
|
841
|
+
}
|
|
689
842
|
try {
|
|
690
843
|
const result = await Promise.race([existing, new Promise((_, r) => setTimeout(() => r('DEDUP_TIMEOUT'), 15000))])
|
|
691
844
|
if (!isCurrent()) return
|
|
692
|
-
if (result?.
|
|
693
|
-
|
|
845
|
+
if (result?.error) {
|
|
846
|
+
fail(result.error)
|
|
847
|
+
} else if (result?.data !== undefined) {
|
|
848
|
+
// A request that started later (an invalidation's refetch) may have
|
|
849
|
+
// landed first: its rows are the newer answer.
|
|
850
|
+
const newer = !infinite && newerEntry(cacheKey, requestSeqs.get(existing) || 0)
|
|
851
|
+
let finalData = newer ? newer.data : result.data
|
|
694
852
|
if (isFunction(selectTransform)) finalData = selectTransform(finalData, element, element.state)
|
|
695
853
|
if (transform) finalData = transform(finalData, element, element.state)
|
|
696
854
|
if (!isCurrent()) return
|
|
@@ -700,7 +858,9 @@ const runFetch = async (config, element, context, opts = {}) => {
|
|
|
700
858
|
}
|
|
701
859
|
} catch (e) {
|
|
702
860
|
if (!isCurrent()) return
|
|
703
|
-
//
|
|
861
|
+
// A dedup timeout is no error of the request: this query only stopped
|
|
862
|
+
// waiting for it.
|
|
863
|
+
if (e !== 'DEDUP_TIMEOUT') fail(e)
|
|
704
864
|
}
|
|
705
865
|
return
|
|
706
866
|
}
|
|
@@ -750,21 +910,21 @@ const runFetch = async (config, element, context, opts = {}) => {
|
|
|
750
910
|
request.headers = { ...request.headers, 'Accept-Language': lang }
|
|
751
911
|
}
|
|
752
912
|
|
|
753
|
-
// Pagination support
|
|
754
|
-
if (
|
|
755
|
-
if (isObject(
|
|
756
|
-
if (
|
|
757
|
-
if (
|
|
758
|
-
if (
|
|
759
|
-
} else if (typeof
|
|
913
|
+
// Pagination support (the values resolved with the element, as the key is)
|
|
914
|
+
if (pageValue !== undefined) {
|
|
915
|
+
if (isObject(pageValue)) {
|
|
916
|
+
if (pageValue.offset !== undefined) request.offset = pageValue.offset
|
|
917
|
+
if (pageValue.limit !== undefined) request.limit = pageValue.limit
|
|
918
|
+
if (pageValue.cursor !== undefined) request.params = { ...request.params, cursor: pageValue.cursor }
|
|
919
|
+
} else if (typeof pageValue === 'number') {
|
|
760
920
|
const pageSize = config.pageSize || limit || 20
|
|
761
|
-
request.offset = (
|
|
921
|
+
request.offset = (pageValue - 1) * pageSize
|
|
762
922
|
request.limit = pageSize
|
|
763
923
|
}
|
|
764
924
|
}
|
|
765
925
|
|
|
766
|
-
if (
|
|
767
|
-
request.params = { ...request.params, cursor }
|
|
926
|
+
if (cursorValue !== undefined) {
|
|
927
|
+
request.params = { ...request.params, cursor: cursorValue }
|
|
768
928
|
}
|
|
769
929
|
|
|
770
930
|
if (method === 'insert' || method === 'update' || method === 'upsert') {
|
|
@@ -794,9 +954,20 @@ const runFetch = async (config, element, context, opts = {}) => {
|
|
|
794
954
|
: formData
|
|
795
955
|
}
|
|
796
956
|
request.params = rpcParams
|
|
957
|
+
// A cached rpc query is a read; an action may write. The REST adapter
|
|
958
|
+
// sends a `read` rpc as a GET when its `reads.rpc` says the backend
|
|
959
|
+
// serves those (no CORS preflight); `read: false` keeps one a POST.
|
|
960
|
+
request.read = !action && config.read !== false
|
|
797
961
|
}
|
|
798
962
|
|
|
799
963
|
if (method === 'subscribe') {
|
|
964
|
+
// One subscription per element: a re-run (`el.__ref.refetch()`) closes
|
|
965
|
+
// the one it replaces instead of opening a second beside it.
|
|
966
|
+
if (isFunction(ref.__unsubscribe)) {
|
|
967
|
+
const close = ref.__unsubscribe
|
|
968
|
+
ref.__unsubscribe = null
|
|
969
|
+
try { close() } catch (e) { /* the old stream is gone either way */ }
|
|
970
|
+
}
|
|
800
971
|
const unsubscribe = adapter.subscribe(
|
|
801
972
|
{ from, params, on: config.subscribeOn },
|
|
802
973
|
(newData, oldData, payload) => {
|
|
@@ -818,20 +989,28 @@ const runFetch = async (config, element, context, opts = {}) => {
|
|
|
818
989
|
}
|
|
819
990
|
|
|
820
991
|
// Execute with retry
|
|
992
|
+
const requestSeq = ++fetchSeq
|
|
821
993
|
const fetchPromise = withRetry(doFetch, retryConfig)
|
|
994
|
+
requestSeqs.set(fetchPromise, requestSeq)
|
|
822
995
|
|
|
823
996
|
// Store for deduplication
|
|
824
997
|
if (cacheKey) activeQueries.set(cacheKey, fetchPromise)
|
|
825
998
|
|
|
826
999
|
try {
|
|
827
1000
|
const result = await fetchPromise
|
|
1001
|
+
// An action's write happened whatever became of the element that fired
|
|
1002
|
+
// it (a dialog that closed on submit): its `invalidates` runs on success.
|
|
1003
|
+
if (action && config.invalidates && result && !result.error && !result.subscribed) {
|
|
1004
|
+
runInvalidates(config.invalidates, from)
|
|
1005
|
+
}
|
|
828
1006
|
if (!isCurrent()) return
|
|
829
1007
|
if (result?.subscribed) return
|
|
830
1008
|
|
|
831
1009
|
const { data, error } = result || {}
|
|
832
1010
|
|
|
833
1011
|
if (error) {
|
|
834
|
-
|
|
1012
|
+
// An older request's failure never replaces a newer answer.
|
|
1013
|
+
if (cacheKey && !newerEntry(cacheKey, requestSeq)) setCacheEntry(cacheKey, null, error, requestSeq)
|
|
835
1014
|
setFetchStatus(element, { isFetching: false, isLoading: false, isStale: false, isSuccess: false, error, status: 'error', fetchStatus: 'idle' })
|
|
836
1015
|
triggerCallback(element, 'onFetchError', error)
|
|
837
1016
|
triggerConfigCallback(config, element, 'onFetchError', error)
|
|
@@ -844,45 +1023,54 @@ const runFetch = async (config, element, context, opts = {}) => {
|
|
|
844
1023
|
}
|
|
845
1024
|
|
|
846
1025
|
if (data !== undefined) {
|
|
1026
|
+
let rows = data
|
|
847
1027
|
// Update cache
|
|
848
1028
|
if ((method === 'select' || method === 'rpc') && cacheKey) {
|
|
849
|
-
|
|
850
|
-
|
|
1029
|
+
// A request that started later has written its newer rows already:
|
|
1030
|
+
// show those, leave the cache alone. (Not for an infinite query —
|
|
1031
|
+
// each of its replies is one page, not the whole answer.)
|
|
1032
|
+
const newer = !infinite && newerEntry(cacheKey, requestSeq)
|
|
1033
|
+
if (newer) {
|
|
1034
|
+
rows = newer.data
|
|
1035
|
+
} else {
|
|
1036
|
+
// Invalidated while this request was out: kept, never served as fresh.
|
|
1037
|
+
const entry = setCacheEntry(cacheKey, data, null, requestSeq, isOutdated(cacheKey, requestSeq))
|
|
1038
|
+
entry.gcTime = cacheConfig?.gcTime || 300000
|
|
1039
|
+
}
|
|
851
1040
|
}
|
|
852
1041
|
|
|
853
|
-
let finalData =
|
|
1042
|
+
let finalData = rows
|
|
854
1043
|
|
|
855
1044
|
// Infinite query — append pages
|
|
856
1045
|
if (infinite && isArray(data)) {
|
|
857
|
-
|
|
1046
|
+
// Any run that is not a next / previous step — a refetch, an
|
|
1047
|
+
// invalidation's refresh, a stale cache's revalidation — asks for the
|
|
1048
|
+
// first page and starts the list again. Appending it to the loaded
|
|
1049
|
+
// pages listed it twice.
|
|
1050
|
+
if (!ref.__pages || !opts.direction) ref.__pages = []
|
|
858
1051
|
if (opts.direction === 'previous') {
|
|
859
1052
|
ref.__pages.unshift(data)
|
|
860
1053
|
} else {
|
|
861
1054
|
ref.__pages.push(data)
|
|
862
1055
|
}
|
|
863
1056
|
finalData = ref.__pages.flat()
|
|
864
|
-
|
|
865
|
-
// Resolve pagination cursors
|
|
866
|
-
if (getNextPageParam) {
|
|
867
|
-
ref.__nextPageParam = getNextPageParam(data, ref.__pages)
|
|
868
|
-
ref.__hasNextPage = ref.__nextPageParam != null
|
|
869
|
-
}
|
|
870
|
-
if (getPreviousPageParam) {
|
|
871
|
-
ref.__prevPageParam = getPreviousPageParam(data, ref.__pages)
|
|
872
|
-
ref.__hasPreviousPage = ref.__prevPageParam != null
|
|
873
|
-
}
|
|
1057
|
+
setPageCursors(ref, config, data)
|
|
874
1058
|
}
|
|
875
1059
|
|
|
876
1060
|
if (isFunction(selectTransform)) finalData = selectTransform(finalData, element, element.state)
|
|
877
1061
|
if (transform) finalData = transform(finalData, element, element.state)
|
|
878
1062
|
if (!isCurrent()) return
|
|
879
1063
|
|
|
1064
|
+
// The status first: the state update re-renders, and a render that
|
|
1065
|
+
// reads `__fetchStatus` saw the request still pending beside its own
|
|
1066
|
+
// rows (report #25).
|
|
1067
|
+
setFetchStatus(element, { isFetching: false, isLoading: false, isStale: false, isSuccess: true, error: null, status: 'success', fetchStatus: 'idle' })
|
|
1068
|
+
|
|
880
1069
|
// keepPreviousData — don't clear state while fetching new page
|
|
881
1070
|
if (!keepPreviousData || finalData !== undefined) {
|
|
882
1071
|
updateElementState(element, finalData, stateKey)
|
|
883
1072
|
}
|
|
884
1073
|
|
|
885
|
-
setFetchStatus(element, { isFetching: false, isLoading: false, isStale: false, isSuccess: true, error: null, status: 'success', fetchStatus: 'idle' })
|
|
886
1074
|
triggerCallback(element, 'onFetchComplete', finalData)
|
|
887
1075
|
triggerConfigCallback(config, element, 'onFetchComplete', finalData)
|
|
888
1076
|
if (_emitNet) _emitNet({
|
|
@@ -911,6 +1099,8 @@ const runFetch = async (config, element, context, opts = {}) => {
|
|
|
911
1099
|
const bindEvent = (config, element, context) => {
|
|
912
1100
|
const on = config.on
|
|
913
1101
|
const ref = element.__ref
|
|
1102
|
+
// Bound a microtask after create: the element may have been removed since.
|
|
1103
|
+
if (!ref) return
|
|
914
1104
|
if (!ref.__fetchListeners) ref.__fetchListeners = []
|
|
915
1105
|
const runner = config.__runner || runFetch
|
|
916
1106
|
|
|
@@ -932,10 +1122,49 @@ const bindEvent = (config, element, context) => {
|
|
|
932
1122
|
ref.__fetchListeners.push(() => node.removeEventListener('click', handler))
|
|
933
1123
|
}
|
|
934
1124
|
} else if (on === 'stateChange') {
|
|
935
|
-
|
|
1125
|
+
bindStateChange(config, element, context, runner)
|
|
936
1126
|
}
|
|
937
1127
|
}
|
|
938
1128
|
|
|
1129
|
+
// `on: 'stateChange'` — the query runs when the state it reads changes
|
|
1130
|
+
// (search as you type). It never fired: the pre-v4 runtime called
|
|
1131
|
+
// `ref.__fetchOnStateChange()` from the element's update(), the signal rewrite
|
|
1132
|
+
// dropped that call, and a signal-only state change fires no update hook.
|
|
1133
|
+
// Its inputs — params, `enabled`, `skip`, `page`, resolved as a request would
|
|
1134
|
+
// — are read inside a signal effect, which subscribes to exactly the state
|
|
1135
|
+
// they read (helmet's metadata effect works the same way); a change of their
|
|
1136
|
+
// values runs the query. Never on create (that is `on: 'create'`) and never
|
|
1137
|
+
// for its own rows: they land in state while its inputs stay as they were.
|
|
1138
|
+
// The effect is one of the element's `__ref.__effects`, so dispose() stops it.
|
|
1139
|
+
const bindStateChange = (config, element, context, runner) => {
|
|
1140
|
+
const ref = element.__ref
|
|
1141
|
+
let last
|
|
1142
|
+
const effect = createEffect(() => {
|
|
1143
|
+
let inputs
|
|
1144
|
+
try {
|
|
1145
|
+
inputs = JSON.stringify([
|
|
1146
|
+
resolveParams(config.params, element, context) ?? null,
|
|
1147
|
+
isFunction(config.enabled) ? !!config.enabled(element, element.state) : config.enabled !== false,
|
|
1148
|
+
isFunction(config.skip) ? !!config.skip(element, element.state) : config.skip === true,
|
|
1149
|
+
resolveValue(config.page, element) ?? null
|
|
1150
|
+
])
|
|
1151
|
+
} catch (e) {
|
|
1152
|
+
return // an input that throws names no request
|
|
1153
|
+
}
|
|
1154
|
+
if (last === undefined) {
|
|
1155
|
+
last = inputs
|
|
1156
|
+
return
|
|
1157
|
+
}
|
|
1158
|
+
if (inputs === last) return
|
|
1159
|
+
last = inputs
|
|
1160
|
+
// Outside the effect: the run reads state as well, and none of that may
|
|
1161
|
+
// become the effect's dependency.
|
|
1162
|
+
Promise.resolve().then(() => runner(config, element, context))
|
|
1163
|
+
})
|
|
1164
|
+
if (Array.isArray(ref.__effects)) ref.__effects.push(effect)
|
|
1165
|
+
else ref.__fetchListeners.push(() => effect.dispose())
|
|
1166
|
+
}
|
|
1167
|
+
|
|
939
1168
|
// --- Refetch interval / focus / reconnect binding ---
|
|
940
1169
|
|
|
941
1170
|
const bindAutoRefetch = (config, element, context) => {
|
|
@@ -969,9 +1198,7 @@ const bindAutoRefetch = (config, element, context) => {
|
|
|
969
1198
|
// refetchOnWindowFocus
|
|
970
1199
|
if (onFocusEnabled) {
|
|
971
1200
|
const onFocus = () => {
|
|
972
|
-
const
|
|
973
|
-
const scope = resolveScope(element, context)
|
|
974
|
-
const cacheKey = buildCacheKey(config, lang, scope)
|
|
1201
|
+
const cacheKey = elementQueryKey(config, element, context)
|
|
975
1202
|
const entry = getCacheEntry(cacheKey)
|
|
976
1203
|
const cacheConfig = parseCacheConfig(config.cache)
|
|
977
1204
|
if (!entry || entry.stale || (Date.now() - entry.time) > (cacheConfig?.staleTime || 0)) {
|
|
@@ -1012,8 +1239,13 @@ const applyOptimisticUpdate = (element, config, mutationData) => {
|
|
|
1012
1239
|
return snapshot
|
|
1013
1240
|
}
|
|
1014
1241
|
|
|
1242
|
+
// Rolls back only what `optimistic` applied, and never on a removed element
|
|
1243
|
+
// (a dialog that closed while its write was out): the write failing used to
|
|
1244
|
+
// call this for every mutation, and read `__ref` of a removed one — a
|
|
1245
|
+
// TypeError in the failure path.
|
|
1015
1246
|
const rollbackOptimistic = (element, config) => {
|
|
1016
1247
|
const ref = element.__ref
|
|
1248
|
+
if (!ref || !config.optimistic) return
|
|
1017
1249
|
const snapshot = ref.__optimisticSnapshot
|
|
1018
1250
|
if (snapshot !== undefined) {
|
|
1019
1251
|
updateElementState(element, snapshot, config.as)
|
|
@@ -1021,6 +1253,91 @@ const rollbackOptimistic = (element, config) => {
|
|
|
1021
1253
|
}
|
|
1022
1254
|
}
|
|
1023
1255
|
|
|
1256
|
+
// --- Invalidation ---
|
|
1257
|
+
|
|
1258
|
+
// Which keys a match selects — one rule for queryClient, the project
|
|
1259
|
+
// functions and a write's `invalidates`: nothing (every key), a key prefix or
|
|
1260
|
+
// substring, a list of key parts (joined with ':'), a RegExp, or a predicate
|
|
1261
|
+
// `(key) => boolean`.
|
|
1262
|
+
const keyMatcher = (match) => {
|
|
1263
|
+
if (!match) return () => true
|
|
1264
|
+
if (isString(match)) return (key) => key.includes(match)
|
|
1265
|
+
if (isArray(match)) {
|
|
1266
|
+
const parts = match.join(':')
|
|
1267
|
+
return (key) => key.includes(parts)
|
|
1268
|
+
}
|
|
1269
|
+
if (match instanceof RegExp) {
|
|
1270
|
+
return (key) => {
|
|
1271
|
+
match.lastIndex = 0 // a /g pattern keeps its position between tests
|
|
1272
|
+
return match.test(key)
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
if (isFunction(match)) {
|
|
1276
|
+
// A predicate that throws selects nothing: it must not turn the write
|
|
1277
|
+
// whose `invalidates` it is into a failed one.
|
|
1278
|
+
return (key) => {
|
|
1279
|
+
try { return !!match(key) } catch (e) { return false }
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
return () => false
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
// Refetch the mounted queries whose current key `matches` selects. The first
|
|
1286
|
+
// on a key sends the request; the others on that key share it through the
|
|
1287
|
+
// dedup, which never shares a request that started before the key's
|
|
1288
|
+
// invalidation — so no one is handed the pre-write rows.
|
|
1289
|
+
const refreshMounted = (matches) => {
|
|
1290
|
+
const runs = []
|
|
1291
|
+
for (const record of mountedQueries) {
|
|
1292
|
+
if (!isMounted(record)) {
|
|
1293
|
+
mountedQueries.delete(record)
|
|
1294
|
+
continue
|
|
1295
|
+
}
|
|
1296
|
+
const key = mountedKey(record)
|
|
1297
|
+
if (key && matches(key)) runs.push(runFetch(record.config, record.element, record.context, { refresh: true }))
|
|
1298
|
+
}
|
|
1299
|
+
return Promise.allSettled(runs).then(() => {})
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
// Mark every key `matches` selects as invalidated — its cache entry stale, and
|
|
1303
|
+
// a first load still in flight outdated — then refetch the mounted queries on
|
|
1304
|
+
// those keys; resolves once those refetches settle. The answers the pre-boot
|
|
1305
|
+
// script asked for (early.js) are dropped as well: an invalidation means a
|
|
1306
|
+
// write happened, and they may predate it.
|
|
1307
|
+
const invalidateMatching = (matches) => {
|
|
1308
|
+
dropEarly()
|
|
1309
|
+
for (const [key, entry] of cacheStore) {
|
|
1310
|
+
if (!matches(key)) continue
|
|
1311
|
+
entry.stale = true
|
|
1312
|
+
invalidatedAt.set(key, ++fetchSeq)
|
|
1313
|
+
}
|
|
1314
|
+
for (const [key] of activeQueries) {
|
|
1315
|
+
if (!cacheStore.has(key) && matches(key)) invalidatedAt.set(key, ++fetchSeq)
|
|
1316
|
+
}
|
|
1317
|
+
return refreshMounted(matches)
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
// A write's `invalidates`: `true` or '*' selects every query of the write's
|
|
1321
|
+
// own `from`; anything else — or each item of a list — is a match as
|
|
1322
|
+
// invalidateQueries takes it. (Whole keys only, before: the documented
|
|
1323
|
+
// `invalidates: ['posts']` matched no real key.)
|
|
1324
|
+
const runInvalidates = (invalidates, from) => {
|
|
1325
|
+
const matchers = (isArray(invalidates) ? invalidates : [invalidates]).map((match) => {
|
|
1326
|
+
if (match === true || match === '*') return (key) => key.startsWith(from + ':')
|
|
1327
|
+
return match ? keyMatcher(match) : () => false
|
|
1328
|
+
})
|
|
1329
|
+
return invalidateMatching((key) => matchers.some((matches) => matches(key)))
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
const removeMatching = (matches) => {
|
|
1333
|
+
for (const [key] of cacheStore) {
|
|
1334
|
+
if (matches(key)) removeCache(key)
|
|
1335
|
+
}
|
|
1336
|
+
for (const [key] of activeQueries) {
|
|
1337
|
+
if (matches(key)) invalidatedAt.set(key, ++fetchSeq)
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1024
1341
|
// --- Mutation runner (insert/update/delete with optimistic + invalidation) ---
|
|
1025
1342
|
|
|
1026
1343
|
const runMutation = async (config, element, context) => {
|
|
@@ -1089,7 +1406,7 @@ const runMutation = async (config, element, context) => {
|
|
|
1089
1406
|
request.headers = { ...request.headers, 'Accept-Language': lang }
|
|
1090
1407
|
}
|
|
1091
1408
|
|
|
1092
|
-
const retryConfig = resolveRetryConfig(config)
|
|
1409
|
+
const retryConfig = resolveRetryConfig(config, true)
|
|
1093
1410
|
const result = await withRetry(() => fn(request), retryConfig)
|
|
1094
1411
|
const { data, error } = result || {}
|
|
1095
1412
|
|
|
@@ -1109,13 +1426,15 @@ const runMutation = async (config, element, context) => {
|
|
|
1109
1426
|
|
|
1110
1427
|
delete ref.__optimisticSnapshot
|
|
1111
1428
|
|
|
1429
|
+
// The status first, as for a query: the state update re-renders.
|
|
1430
|
+
setFetchStatus(element, { isFetching: false, isLoading: false, isStale: false, isSuccess: true, error: null, status: 'success', fetchStatus: 'idle' })
|
|
1431
|
+
|
|
1112
1432
|
// A body value (Blob, stream, …) lands in state only under `as`.
|
|
1113
1433
|
if (data !== undefined && (stateKey || !isBodyValue(data))) {
|
|
1114
1434
|
const finalData = stateKey ? { [stateKey]: data } : data
|
|
1115
1435
|
if (element.state?.update) element.state.update(finalData, { preventFetch: true })
|
|
1116
1436
|
}
|
|
1117
1437
|
|
|
1118
|
-
setFetchStatus(element, { isFetching: false, isLoading: false, isStale: false, isSuccess: true, error: null, status: 'success', fetchStatus: 'idle' })
|
|
1119
1438
|
triggerCallback(element, 'onFetchComplete', data)
|
|
1120
1439
|
triggerConfigCallback(config, element, 'onFetchComplete', data)
|
|
1121
1440
|
if (isFunction(onSuccess)) onSuccess(data, mutationData, element)
|
|
@@ -1125,20 +1444,8 @@ const runMutation = async (config, element, context) => {
|
|
|
1125
1444
|
durationMs: (typeof performance !== 'undefined' ? performance.now() : Date.now()) - __analyzeStart
|
|
1126
1445
|
})
|
|
1127
1446
|
|
|
1128
|
-
// Invalidate related queries
|
|
1129
|
-
if (invalidates)
|
|
1130
|
-
const keys = isArray(invalidates) ? invalidates : [invalidates]
|
|
1131
|
-
for (const k of keys) {
|
|
1132
|
-
if (k === true || k === '*') {
|
|
1133
|
-
// Invalidate all queries matching this `from`
|
|
1134
|
-
for (const [ck] of cacheStore) {
|
|
1135
|
-
if (ck.startsWith(from + ':')) invalidateCache(ck)
|
|
1136
|
-
}
|
|
1137
|
-
} else {
|
|
1138
|
-
invalidateCache(k)
|
|
1139
|
-
}
|
|
1140
|
-
}
|
|
1141
|
-
}
|
|
1447
|
+
// Invalidate related queries — the mounted ones refetch now
|
|
1448
|
+
if (invalidates) runInvalidates(invalidates, from)
|
|
1142
1449
|
} catch (e) {
|
|
1143
1450
|
if (snapshot !== undefined) rollbackOptimistic(element, config)
|
|
1144
1451
|
setFetchStatus(element, { isFetching: false, isLoading: false, isStale: false, isSuccess: false, error: e, status: 'error', fetchStatus: 'idle' })
|
|
@@ -1158,6 +1465,24 @@ const runMutation = async (config, element, context) => {
|
|
|
1158
1465
|
|
|
1159
1466
|
// --- Public API ---
|
|
1160
1467
|
|
|
1468
|
+
// What the plugin bound for an element: the auto-refetch interval and the
|
|
1469
|
+
// window focus / online listeners (bindAutoRefetch), the submit / click
|
|
1470
|
+
// listeners (bindEvent), the realtime subscription and the mounted-query
|
|
1471
|
+
// records. dispose() calls this unguarded, so nothing here may throw.
|
|
1472
|
+
const releaseFetch = (ref) => {
|
|
1473
|
+
const release = (cleanup) => {
|
|
1474
|
+
try { cleanup() } catch (e) { /* one cleanup never stops the rest */ }
|
|
1475
|
+
}
|
|
1476
|
+
for (const cleanup of ref.__fetchCleanups || []) release(cleanup)
|
|
1477
|
+
for (const cleanup of ref.__fetchListeners || []) release(cleanup)
|
|
1478
|
+
if (isFunction(ref.__unsubscribe)) release(ref.__unsubscribe)
|
|
1479
|
+
for (const record of ref.__fetchMounted || []) mountedQueries.delete(record)
|
|
1480
|
+
ref.__fetchCleanups = []
|
|
1481
|
+
ref.__fetchListeners = []
|
|
1482
|
+
ref.__unsubscribe = null
|
|
1483
|
+
ref.__fetchMounted = []
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1161
1486
|
export const executeFetch = (param, element, state, context) => {
|
|
1162
1487
|
if (!param) return
|
|
1163
1488
|
const db = context?.fetch
|
|
@@ -1170,9 +1495,32 @@ export const executeFetch = (param, element, state, context) => {
|
|
|
1170
1495
|
? fetchProp.map(c => resolveFetchConfig(exec(c, element), element)).filter(Boolean)
|
|
1171
1496
|
: [resolveFetchConfig(fetchProp, element)].filter(Boolean)
|
|
1172
1497
|
|
|
1498
|
+
// This element's mounted queries (refetched by an invalidation). A re-run
|
|
1499
|
+
// replaces its configs, so it replaces their records too.
|
|
1500
|
+
const ref = element.__ref
|
|
1501
|
+
if (ref.__fetchMounted) {
|
|
1502
|
+
for (const record of ref.__fetchMounted) mountedQueries.delete(record)
|
|
1503
|
+
}
|
|
1504
|
+
ref.__fetchMounted = []
|
|
1505
|
+
|
|
1506
|
+
// Released with the element. dispose() — the one way an element leaves —
|
|
1507
|
+
// calls every `__ref.__effects` entry (helmet's head layers use the same
|
|
1508
|
+
// path; the element package sends plugins no remove hook). The interval,
|
|
1509
|
+
// the window focus / online listeners and a realtime subscription used to
|
|
1510
|
+
// outlive their element. An entry without `_execute` is skipped by the
|
|
1511
|
+
// update levers that re-run effects.
|
|
1512
|
+
if (!ref.__fetchDisposer && isArray(ref.__effects)) {
|
|
1513
|
+
ref.__fetchDisposer = { dispose: () => releaseFetch(ref) }
|
|
1514
|
+
ref.__effects.push(ref.__fetchDisposer)
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1173
1517
|
for (const config of configs) {
|
|
1174
|
-
const isMutation = config.method
|
|
1518
|
+
const isMutation = isMutationMethod(config.method)
|
|
1175
1519
|
const runner = isMutation ? runMutation : runFetch
|
|
1520
|
+
// An action never re-sends itself: no refetch on focus, reconnect or an
|
|
1521
|
+
// interval, and an invalidation does not fire it again. Nor does a live
|
|
1522
|
+
// subscription — each re-run opened a second one beside the first.
|
|
1523
|
+
const isQuery = !isMutation && !isAction(config) && config.method !== 'subscribe'
|
|
1176
1524
|
|
|
1177
1525
|
if (config.on === 'create') {
|
|
1178
1526
|
runner(config, element, context)
|
|
@@ -1183,15 +1531,20 @@ export const executeFetch = (param, element, state, context) => {
|
|
|
1183
1531
|
}
|
|
1184
1532
|
|
|
1185
1533
|
// Bind auto-refetch for queries
|
|
1186
|
-
if (
|
|
1534
|
+
if (isQuery && config.on === 'create') {
|
|
1187
1535
|
if (config.refetchInterval || config.refetchOnWindowFocus !== false || config.refetchOnReconnect !== false) {
|
|
1188
1536
|
bindAutoRefetch(config, element, context)
|
|
1189
1537
|
}
|
|
1538
|
+
// A single-use body has no cache key to invalidate.
|
|
1539
|
+
if (!isSingleUse(config)) {
|
|
1540
|
+
const record = { element, ref, config, context }
|
|
1541
|
+
mountedQueries.add(record)
|
|
1542
|
+
ref.__fetchMounted.push(record)
|
|
1543
|
+
}
|
|
1190
1544
|
}
|
|
1191
1545
|
}
|
|
1192
1546
|
|
|
1193
1547
|
// Expose imperative methods on element
|
|
1194
|
-
const ref = element.__ref
|
|
1195
1548
|
ref.refetch = (opts) => {
|
|
1196
1549
|
for (const config of configs) {
|
|
1197
1550
|
runFetch(config, element, context, { force: true, ...opts })
|
|
@@ -1220,37 +1573,20 @@ export const executeFetch = (param, element, state, context) => {
|
|
|
1220
1573
|
// --- Query client (global cache management) ---
|
|
1221
1574
|
|
|
1222
1575
|
export const queryClient = {
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
invalidateCache(key)
|
|
1232
|
-
}
|
|
1233
|
-
}
|
|
1234
|
-
} else if (isArray(keyOrPattern)) {
|
|
1235
|
-
const pattern = keyOrPattern.join(':')
|
|
1236
|
-
for (const [key] of cacheStore) {
|
|
1237
|
-
if (key.includes(pattern)) invalidateCache(key)
|
|
1238
|
-
}
|
|
1239
|
-
}
|
|
1240
|
-
},
|
|
1241
|
-
|
|
1576
|
+
// Marks every matching entry stale and refetches the mounted queries on
|
|
1577
|
+
// those keys; resolves once those refetches settle. `keyOrPattern`: nothing
|
|
1578
|
+
// (every key), a key prefix or substring, a list of key parts (joined with
|
|
1579
|
+
// ':'), a RegExp, or a predicate `(key) => boolean`.
|
|
1580
|
+
invalidateQueries: (keyOrPattern) => invalidateMatching(keyMatcher(keyOrPattern)),
|
|
1581
|
+
|
|
1582
|
+
// Drops every matching entry (same matches). A mounted query keeps the rows
|
|
1583
|
+
// it shows; the next mount loads them afresh.
|
|
1242
1584
|
removeQueries: (keyOrPattern) => {
|
|
1243
1585
|
if (!keyOrPattern) {
|
|
1244
1586
|
removeCache()
|
|
1245
1587
|
return
|
|
1246
1588
|
}
|
|
1247
|
-
|
|
1248
|
-
for (const [key] of cacheStore) {
|
|
1249
|
-
if (key.startsWith(keyOrPattern) || key.includes(keyOrPattern)) {
|
|
1250
|
-
removeCache(key)
|
|
1251
|
-
}
|
|
1252
|
-
}
|
|
1253
|
-
}
|
|
1589
|
+
removeMatching(keyMatcher(keyOrPattern))
|
|
1254
1590
|
},
|
|
1255
1591
|
|
|
1256
1592
|
getQueryData: (key) => {
|
|
@@ -1271,8 +1607,15 @@ export const queryClient = {
|
|
|
1271
1607
|
const adapter = await resolveAdapter(db, context)
|
|
1272
1608
|
if (!adapter) return
|
|
1273
1609
|
|
|
1274
|
-
|
|
1275
|
-
|
|
1610
|
+
// The key — and the request — a declarative query of this config builds
|
|
1611
|
+
// in this context: its tenant scope and language (queryCacheKey, as
|
|
1612
|
+
// runFetch does). The key used to carry neither, so an element with a
|
|
1613
|
+
// language never read what was prefetched for it.
|
|
1614
|
+
const method = config.method || 'select'
|
|
1615
|
+
const scope = resolveScope(null, context)
|
|
1616
|
+
const lang = resolveLanguage(null, context)
|
|
1617
|
+
const cacheKey = queryCacheKey({ ...config, method }, config.params, scope, lang)
|
|
1618
|
+
const fn = adapter[method]
|
|
1276
1619
|
if (!isFunction(fn)) return
|
|
1277
1620
|
|
|
1278
1621
|
const request = {
|
|
@@ -1285,11 +1628,17 @@ export const queryClient = {
|
|
|
1285
1628
|
headers: config.headers,
|
|
1286
1629
|
baseUrl: config.baseUrl
|
|
1287
1630
|
}
|
|
1631
|
+
if (lang) request.headers = { ...request.headers, 'Accept-Language': lang }
|
|
1288
1632
|
if (config.responseType !== undefined) request.responseType = config.responseType
|
|
1633
|
+
// A prefetched rpc is the query a mount reads: a read, as in runFetch.
|
|
1634
|
+
if (method === 'rpc') request.read = !isAction(config) && config.read !== false
|
|
1635
|
+
const requestSeq = ++fetchSeq
|
|
1289
1636
|
const result = await fn(request)
|
|
1290
1637
|
|
|
1291
|
-
|
|
1292
|
-
|
|
1638
|
+
// Ordered like a query's reply: never over newer rows, stale when the key
|
|
1639
|
+
// was invalidated while the prefetch was out.
|
|
1640
|
+
if (result?.data !== undefined && !result?.error && !isSingleUse(config) && !newerEntry(cacheKey, requestSeq)) {
|
|
1641
|
+
const entry = setCacheEntry(cacheKey, result.data, null, requestSeq, isOutdated(cacheKey, requestSeq))
|
|
1293
1642
|
entry.gcTime = parseCacheConfig(config.cache)?.gcTime || 300000
|
|
1294
1643
|
}
|
|
1295
1644
|
|
|
@@ -1324,6 +1673,28 @@ export const queryClient = {
|
|
|
1324
1673
|
}
|
|
1325
1674
|
}
|
|
1326
1675
|
|
|
1676
|
+
// --- Project functions ---
|
|
1677
|
+
//
|
|
1678
|
+
// The query cache's controls for project code. smbls registers these into
|
|
1679
|
+
// `context.functions` whenever `context.fetch` is set (createDomql), so a
|
|
1680
|
+
// component reaches them as `el.call('invalidateQueries', 'articles')`:
|
|
1681
|
+
// project code cannot import `queryClient` — frank serializes an import of
|
|
1682
|
+
// @symbo.ls/fetch to a stub. Same names and matches as queryClient. `this` is
|
|
1683
|
+
// the calling element: given a fetch config instead of a key, getQueryData /
|
|
1684
|
+
// setQueryData use the key that element would build for it (its params
|
|
1685
|
+
// resolved, its language, its tenant scope).
|
|
1686
|
+
const keyFor = (keyOrConfig, element) => {
|
|
1687
|
+
if (!isObject(keyOrConfig)) return keyOrConfig
|
|
1688
|
+
return elementQueryKey({ method: 'select', ...keyOrConfig }, element, element?.context)
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1691
|
+
export const fetchFunctions = {
|
|
1692
|
+
invalidateQueries (keyOrPattern) { return queryClient.invalidateQueries(keyOrPattern) },
|
|
1693
|
+
removeQueries (keyOrPattern) { return queryClient.removeQueries(keyOrPattern) },
|
|
1694
|
+
getQueryData (keyOrConfig) { return queryClient.getQueryData(keyFor(keyOrConfig, this)) },
|
|
1695
|
+
setQueryData (keyOrConfig, updater) { return queryClient.setQueryData(keyFor(keyOrConfig, this), updater) }
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1327
1698
|
// --- Plugin interface ---
|
|
1328
1699
|
|
|
1329
1700
|
/**
|