@symbo.ls/fetch 3.14.664 → 3.14.666
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 +29 -0
- package/README.md +25 -2
- package/adapters/rest.js +70 -7
- package/dist/cjs/adapters/rest.js +1 -1
- package/dist/cjs/early.js +1 -1
- package/dist/cjs/index.js +1 -1
- package/dist/cjs/inflight.js +1 -0
- package/dist/esm/adapters/rest.js +1 -1
- package/dist/esm/early.js +1 -1
- package/dist/esm/index.js +1 -1
- package/dist/esm/inflight.js +1 -0
- package/early.js +69 -23
- package/index.js +217 -110
- package/inflight.js +39 -0
- package/package.json +3 -3
package/index.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { isArray, isFunction, isObject, isString, exec } from '@symbo.ls/utils'
|
|
4
4
|
import { createEffect } from '@symbo.ls/signal'
|
|
5
5
|
import { dropEarly } from './early.js'
|
|
6
|
+
import * as restAdapter from './adapters/rest.js'
|
|
6
7
|
|
|
7
8
|
// --- Adapter resolution ---
|
|
8
9
|
|
|
@@ -32,8 +33,24 @@ const ADAPTER_METHODS = ['select', 'rpc', 'insert', 'update', 'upsert', 'delete'
|
|
|
32
33
|
// declaration silently dies (SMBLS-FETCH-RESOLVEDB-EMPTY-RESULT-1).
|
|
33
34
|
// Keeping these local is safe because every copy of this file ships
|
|
34
35
|
// its own adapters/ chunks; the imports always resolve in-bundle.
|
|
36
|
+
//
|
|
37
|
+
// The REST adapter ships WITH the plugin, not as a lazy file: an app with
|
|
38
|
+
// `fetch` reads its first data through it, and as a file of its own it was
|
|
39
|
+
// the third load in a row before that first read — in smbls/core's split
|
|
40
|
+
// build: the plugin (7.8 KB gzip), then the early/in-flight code the plugin
|
|
41
|
+
// shared with the adapter (1.5 KB), then the adapter (2.7 KB) on the first
|
|
42
|
+
// resolveDb. Static here, the three are one file.
|
|
43
|
+
//
|
|
44
|
+
// `local` stays a lazy file: an app that never configures it never loads it.
|
|
45
|
+
// That is also why every microtask in this module is queued with
|
|
46
|
+
// queueMicrotask, never `Promise.resolve().then(fn)`: parcel 2 (the bundler
|
|
47
|
+
// `smbls build` runs) rewrites the first such call after an `import()` in a
|
|
48
|
+
// file into `Promise.resolve().then(() => <that import>).then(fn)`, and this
|
|
49
|
+
// `import()` comes first in the file. The `on: 'stateChange'` run loaded the
|
|
50
|
+
// local adapter that way on XMA's first "Show more" click (xma.info,
|
|
51
|
+
// 2026-10-16). __tests__/localAdapterStaysLazy.test.js holds the rule.
|
|
35
52
|
const BUILTIN_ADAPTERS = {
|
|
36
|
-
rest: () =>
|
|
53
|
+
rest: () => Promise.resolve(restAdapter),
|
|
37
54
|
local: () => import('./adapters/local.js')
|
|
38
55
|
}
|
|
39
56
|
|
|
@@ -195,20 +212,81 @@ const parseDuration = (val) => {
|
|
|
195
212
|
return 0
|
|
196
213
|
}
|
|
197
214
|
|
|
198
|
-
// ---
|
|
215
|
+
// --- Server renders ---
|
|
216
|
+
//
|
|
217
|
+
// A context that sets `isolateFetchCache` is one server render:
|
|
218
|
+
// @symbo.ls/brender sets it on every render, and no browser host sets it.
|
|
219
|
+
// Such a context has a query store of its own (Query stores) and binds no
|
|
220
|
+
// browser-only refetch (executeFetch). Window focus, reconnect and
|
|
221
|
+
// `refetchInterval` keep a live page's data fresh; a server render is markup
|
|
222
|
+
// once it ends, and its tree is never disposed, so what a query bound there
|
|
223
|
+
// stayed for the life of the process: a focus / online callback per query
|
|
224
|
+
// element in the module-level listener sets (about 2.5 MB of tree per render
|
|
225
|
+
// kept alive), and an interval that kept firing — with
|
|
226
|
+
// `refetchIntervalInBackground` it asked the tenant's database again every
|
|
227
|
+
// period, and a request that landed during the NEXT render was recorded in
|
|
228
|
+
// that render's fetch seed (brender records the GETs made while it renders),
|
|
229
|
+
// so one tenant's page carried another's rows.
|
|
230
|
+
const isServerRender = (context) => !!context && !!context.isolateFetchCache
|
|
231
|
+
|
|
232
|
+
// --- Query stores ---
|
|
233
|
+
//
|
|
234
|
+
// A store is one query cache: its entries, the requests in flight (the
|
|
235
|
+
// dedup), the invalidation marks and the mounted queries. A browser page has
|
|
236
|
+
// one, the module's (`pageStore`), shared by every context that does not set
|
|
237
|
+
// `isolateFetchCache` — the cache works as it always has, and `queryClient`
|
|
238
|
+
// is its handle.
|
|
239
|
+
//
|
|
240
|
+
// A context that sets `isolateFetchCache` has a store of its own, made on its
|
|
241
|
+
// first use and dropped with the context. @symbo.ls/brender sets it on every
|
|
242
|
+
// server render. A server renders page after page, and project after
|
|
243
|
+
// project, through ONE module instance of this plugin (`smbls build`, a
|
|
244
|
+
// worker isolate, a host rendering several tenants), and a key carries no
|
|
245
|
+
// project identity (`posts:select:""`). With one store, a page answered from
|
|
246
|
+
// the rows an earlier page left made no request, so its fetch seed (recorded
|
|
247
|
+
// from requests) did not carry them (xma.info, 2026-10-16); and two renders
|
|
248
|
+
// of different projects read each other's rows — a render that overlapped
|
|
249
|
+
// another joined its request in flight (its own database never asked), a
|
|
250
|
+
// reply that landed after the other render's newer one took that render's
|
|
251
|
+
// rows, a finished render's late reply served the next render's later
|
|
252
|
+
// mounts, and one render's first query wiped the other's cache, mounted
|
|
253
|
+
// queries and project-code writes. 3.14.665 emptied the one store when a new
|
|
254
|
+
// render ran its first query, which only covered renders that never overlap.
|
|
255
|
+
// Keyed by the context object, a render's store is reached only through its
|
|
256
|
+
// own elements (runFetch, executeFetch, the project functions) and through
|
|
257
|
+
// prefetchQuery given its context: every render starts empty and makes its
|
|
258
|
+
// own requests, and nothing of one render is visible to another, at any
|
|
259
|
+
// timing. The flag travels on the context, so the copy of this plugin that
|
|
260
|
+
// runs the render — the one inside a server bundle — keeps the store; nothing
|
|
261
|
+
// from outside that copy has to reach it.
|
|
262
|
+
const createStore = () => ({
|
|
263
|
+
cache: new Map(), // key -> entry
|
|
264
|
+
subscribers: new Map(), // key -> Set of callbacks
|
|
265
|
+
active: new Map(), // key -> in-flight promise (dedup)
|
|
266
|
+
invalidatedAt: new Map(), // key -> seq of the key's last invalidation or removal
|
|
267
|
+
mounted: new Set() // the queries on screen (see Mounted queries)
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
const pageStore = createStore()
|
|
271
|
+
const renderStores = new WeakMap() // isolating context -> its store
|
|
272
|
+
|
|
273
|
+
const storeOf = (context) => {
|
|
274
|
+
if (!isServerRender(context)) return pageStore
|
|
275
|
+
let store = renderStores.get(context)
|
|
276
|
+
if (!store) renderStores.set(context, (store = createStore()))
|
|
277
|
+
return store
|
|
278
|
+
}
|
|
199
279
|
|
|
200
|
-
|
|
201
|
-
const querySubscribers = new Map() // key -> Set of elements
|
|
202
|
-
const activeQueries = new Map() // key -> Promise (deduplication)
|
|
280
|
+
// --- Query cache ---
|
|
203
281
|
|
|
204
282
|
// Request order. Every request a query sends, every cache write and every
|
|
205
283
|
// invalidation takes the next number. A reply then knows whether it is older
|
|
206
284
|
// than its key's last invalidation (its rows may predate the write: kept, but
|
|
207
285
|
// never served as fresh) and whether a request that started after it has
|
|
208
286
|
// already written its answer (that newer answer wins — a slow reply never
|
|
209
|
-
// puts old rows back over it).
|
|
287
|
+
// puts old rows back over it). One order for every store: a number is only
|
|
288
|
+
// ever compared with numbers of the same store's keys.
|
|
210
289
|
let fetchSeq = 0
|
|
211
|
-
const invalidatedAt = new Map() // key -> seq of the key's last invalidation or removal
|
|
212
290
|
const requestSeqs = new WeakMap() // in-flight promise -> seq of its request
|
|
213
291
|
|
|
214
292
|
// The cache (and dedup) key of a query. runFetch writes its entry under it and
|
|
@@ -241,12 +319,12 @@ const queryCacheKey = (config, params, scope, lang, page = config.page, cursor =
|
|
|
241
319
|
return `${from}:${method}:${scopeSeg}${JSON.stringify(params || '')}${infinite ? ':infinite' : ''}${pageSeg}${cursorSeg}${langSuffix}`
|
|
242
320
|
}
|
|
243
321
|
|
|
244
|
-
const getCacheEntry = (key) =>
|
|
322
|
+
const getCacheEntry = (store, key) => store.cache.get(key) || null
|
|
245
323
|
|
|
246
324
|
// `seq` is the request the rows came from (a direct write — setQueryData,
|
|
247
325
|
// initialData, a prefetch — is its own, current one); `stale` is set for rows
|
|
248
326
|
// whose request started before the key's last invalidation.
|
|
249
|
-
const setCacheEntry = (key, data, error, seq = ++fetchSeq, stale = false) => {
|
|
327
|
+
const setCacheEntry = (store, key, data, error, seq = ++fetchSeq, stale = false) => {
|
|
250
328
|
const entry = {
|
|
251
329
|
data,
|
|
252
330
|
error,
|
|
@@ -254,10 +332,10 @@ const setCacheEntry = (key, data, error, seq = ++fetchSeq, stale = false) => {
|
|
|
254
332
|
stale,
|
|
255
333
|
seq
|
|
256
334
|
}
|
|
257
|
-
|
|
335
|
+
store.cache.set(key, entry)
|
|
258
336
|
|
|
259
337
|
// Notify all subscribers of this query
|
|
260
|
-
const subs =
|
|
338
|
+
const subs = store.subscribers.get(key)
|
|
261
339
|
if (subs) {
|
|
262
340
|
for (const sub of subs) {
|
|
263
341
|
if (isFunction(sub)) sub(entry)
|
|
@@ -269,32 +347,33 @@ const setCacheEntry = (key, data, error, seq = ++fetchSeq, stale = false) => {
|
|
|
269
347
|
|
|
270
348
|
// The entry a request that started after `seq` wrote (an error aside): the
|
|
271
349
|
// newer answer, which an older reply must not replace.
|
|
272
|
-
const newerEntry = (key, seq) => {
|
|
273
|
-
const entry = key ?
|
|
350
|
+
const newerEntry = (store, key, seq) => {
|
|
351
|
+
const entry = key ? store.cache.get(key) : null
|
|
274
352
|
return entry && !entry.error && entry.seq > seq ? entry : null
|
|
275
353
|
}
|
|
276
354
|
|
|
277
355
|
// A request that started before its key's last invalidation (or removal).
|
|
278
|
-
const isOutdated = (key, seq) => (invalidatedAt.get(key) || 0) > seq
|
|
356
|
+
const isOutdated = (store, key, seq) => (store.invalidatedAt.get(key) || 0) > seq
|
|
279
357
|
|
|
280
358
|
// Every request on `key` that started before now is outdated from here on.
|
|
281
|
-
const markInvalidated = (key) => { invalidatedAt.set(key, ++fetchSeq) }
|
|
359
|
+
const markInvalidated = (store, key) => { store.invalidatedAt.set(key, ++fetchSeq) }
|
|
282
360
|
|
|
283
361
|
// Removing an entry also marks its key, so a reply still in flight writes
|
|
284
362
|
// its rows back as stale, never as fresh.
|
|
285
|
-
const removeCache = (key) => {
|
|
286
|
-
|
|
287
|
-
markInvalidated(key)
|
|
363
|
+
const removeCache = (store, key) => {
|
|
364
|
+
store.cache.delete(key)
|
|
365
|
+
markInvalidated(store, key)
|
|
288
366
|
}
|
|
289
367
|
|
|
290
|
-
const subscribeToCacheKey = (key, callback) => {
|
|
291
|
-
|
|
292
|
-
|
|
368
|
+
const subscribeToCacheKey = (store, key, callback) => {
|
|
369
|
+
const { subscribers } = store
|
|
370
|
+
if (!subscribers.has(key)) subscribers.set(key, new Set())
|
|
371
|
+
subscribers.get(key).add(callback)
|
|
293
372
|
return () => {
|
|
294
|
-
const subs =
|
|
373
|
+
const subs = subscribers.get(key)
|
|
295
374
|
if (subs) {
|
|
296
375
|
subs.delete(callback)
|
|
297
|
-
if (subs.size === 0)
|
|
376
|
+
if (subs.size === 0) subscribers.delete(key)
|
|
298
377
|
}
|
|
299
378
|
}
|
|
300
379
|
}
|
|
@@ -317,15 +396,14 @@ const parseCacheConfig = (cache) => {
|
|
|
317
396
|
// --- Mounted queries ---
|
|
318
397
|
//
|
|
319
398
|
// The queries on screen: one record per element and `on: 'create'` query
|
|
320
|
-
// config, registered by executeFetch
|
|
321
|
-
// CURRENT key it selects (TanStack's
|
|
322
|
-
// on the old rows until their next
|
|
323
|
-
//
|
|
324
|
-
//
|
|
325
|
-
// element was removed (`__ref` gone or
|
|
326
|
-
// registry is walked: an invalidation, or
|
|
327
|
-
|
|
328
|
-
|
|
399
|
+
// config, registered by executeFetch in its context's store (`mounted`). An
|
|
400
|
+
// invalidation refetches the ones whose CURRENT key it selects (TanStack's
|
|
401
|
+
// "active" queries) instead of leaving them on the old rows until their next
|
|
402
|
+
// mount or a window focus. The key is computed when needed — the same
|
|
403
|
+
// resolution as the focus check — because params, language and tenant scope
|
|
404
|
+
// can move after mount. A record whose element was removed (`__ref` gone or
|
|
405
|
+
// replaced) is dropped whenever the registry is walked: an invalidation, or
|
|
406
|
+
// the GC sweep.
|
|
329
407
|
const isMounted = (record) => !!record.ref && record.element.__ref === record.ref
|
|
330
408
|
|
|
331
409
|
const mountedKey = ({ config, element, context }) => {
|
|
@@ -337,6 +415,9 @@ const mountedKey = ({ config, element, context }) => {
|
|
|
337
415
|
}
|
|
338
416
|
|
|
339
417
|
// --- GC ---
|
|
418
|
+
//
|
|
419
|
+
// Sweeps the page store. A render's store is not swept: it goes with its
|
|
420
|
+
// context.
|
|
340
421
|
|
|
341
422
|
let gcTimer = null
|
|
342
423
|
|
|
@@ -344,20 +425,21 @@ const startGC = () => {
|
|
|
344
425
|
if (gcTimer) return
|
|
345
426
|
gcTimer = setInterval(() => {
|
|
346
427
|
const now = Date.now()
|
|
347
|
-
|
|
348
|
-
|
|
428
|
+
const { cache, subscribers, active, invalidatedAt, mounted } = pageStore
|
|
429
|
+
for (const [key, entry] of cache) {
|
|
430
|
+
const subs = subscribers.get(key)
|
|
349
431
|
const hasSubscribers = subs && subs.size > 0
|
|
350
432
|
if (!hasSubscribers && (now - entry.time) > (entry.gcTime || 300000)) {
|
|
351
|
-
|
|
433
|
+
cache.delete(key)
|
|
352
434
|
}
|
|
353
435
|
}
|
|
354
436
|
// What nothing can need any more: an invalidation mark matters while its
|
|
355
437
|
// key has rows or a request out, and a removed element is not mounted.
|
|
356
438
|
for (const [key] of invalidatedAt) {
|
|
357
|
-
if (!
|
|
439
|
+
if (!cache.has(key) && !active.has(key)) invalidatedAt.delete(key)
|
|
358
440
|
}
|
|
359
|
-
for (const record of
|
|
360
|
-
if (!isMounted(record))
|
|
441
|
+
for (const record of mounted) {
|
|
442
|
+
if (!isMounted(record)) mounted.delete(record)
|
|
361
443
|
}
|
|
362
444
|
}, 30000)
|
|
363
445
|
if (gcTimer.unref) gcTimer.unref()
|
|
@@ -493,10 +575,12 @@ const resolveLanguage = (element, context) => {
|
|
|
493
575
|
// state — `root.__fetchScope` — so the SAME (from, method, params) tuple
|
|
494
576
|
// yields DIFFERENT cache/dedup keys per tenant. Without it the cache is
|
|
495
577
|
// tenant-blind: org A's cached `workspaceProject.people:list:""` entry (AND its
|
|
496
|
-
// in
|
|
578
|
+
// request in flight, the dedup) is served / dedup-collapsed for org B, so a
|
|
497
579
|
// page mounted right after an org switch renders the previous tenant's rows.
|
|
498
580
|
// Read generically (same pattern as resolveLanguage) so the plugin stays
|
|
499
581
|
// app-agnostic — apps that never set it get byte-identical keys to before.
|
|
582
|
+
// (Tenants in ONE page share its store, so they need a scope; separate server
|
|
583
|
+
// renders have separate stores — see Query stores.)
|
|
500
584
|
const resolveScope = (element, context) => {
|
|
501
585
|
const root = element?.state?.root || context?.state?.root
|
|
502
586
|
const scope = root?.__fetchScope
|
|
@@ -793,13 +877,15 @@ const runFetch = async (config, element, context, opts = {}) => {
|
|
|
793
877
|
// tenant so an org/workspace switch never serves — or dedup-collapses into —
|
|
794
878
|
// the previous tenant's entry, while keeping `from:method` as the key prefix.
|
|
795
879
|
const cacheKey = cacheConfig ? queryCacheKey(config, params, scope, lang, pageValue, cursorValue) : null
|
|
880
|
+
// The store this run reads and writes: its context's (storeOf).
|
|
881
|
+
const store = storeOf(context)
|
|
796
882
|
|
|
797
883
|
// Apply initial/placeholder data on first mount
|
|
798
884
|
if (!ref.__fetchInitialized && cacheKey) {
|
|
799
885
|
ref.__fetchInitialized = true
|
|
800
886
|
if (initialData !== undefined) {
|
|
801
887
|
const initData = isFunction(initialData) ? initialData() : initialData
|
|
802
|
-
setCacheEntry(cacheKey, initData, null)
|
|
888
|
+
setCacheEntry(store, cacheKey, initData, null)
|
|
803
889
|
}
|
|
804
890
|
}
|
|
805
891
|
|
|
@@ -810,7 +896,7 @@ const runFetch = async (config, element, context, opts = {}) => {
|
|
|
810
896
|
// dedup, so while the entry was fresh every one of those answered from the
|
|
811
897
|
// cache and sent nothing — a poll shorter than the stale time never polled.
|
|
812
898
|
if ((method === 'select' || method === 'rpc') && cacheKey && cacheConfig && !opts.refresh && !opts.force) {
|
|
813
|
-
const cached = getCacheEntry(cacheKey)
|
|
899
|
+
const cached = getCacheEntry(store, cacheKey)
|
|
814
900
|
if (cached && !cached.error) {
|
|
815
901
|
const age = Date.now() - cached.time
|
|
816
902
|
const isStale = cached.stale || age > (cacheConfig.staleTime || 0)
|
|
@@ -842,8 +928,8 @@ const runFetch = async (config, element, context, opts = {}) => {
|
|
|
842
928
|
// Deduplication — if same query is already in-flight, share the promise (with
|
|
843
929
|
// timeout). Never one that started before the key's last invalidation: its
|
|
844
930
|
// reply may predate the write.
|
|
845
|
-
const existing = cacheKey && !opts.force ?
|
|
846
|
-
if (existing && !isOutdated(cacheKey, requestSeqs.get(existing) || 0)) {
|
|
931
|
+
const existing = cacheKey && !opts.force ? store.active.get(cacheKey) : null
|
|
932
|
+
if (existing && !isOutdated(store, cacheKey, requestSeqs.get(existing) || 0)) {
|
|
847
933
|
// The shared request failed: so did this query, with the same error. It
|
|
848
934
|
// used to read an error answer as null rows (onFetchComplete(null)) and a
|
|
849
935
|
// thrown one as nothing at all.
|
|
@@ -855,7 +941,7 @@ const runFetch = async (config, element, context, opts = {}) => {
|
|
|
855
941
|
} else if (result?.data !== undefined) {
|
|
856
942
|
// A request that started later (an invalidation's refetch) may have
|
|
857
943
|
// landed first: its rows are the newer answer.
|
|
858
|
-
const newer = !infinite && newerEntry(cacheKey, requestSeqs.get(existing) || 0)
|
|
944
|
+
const newer = !infinite && newerEntry(store, cacheKey, requestSeqs.get(existing) || 0)
|
|
859
945
|
const finalData = shape(newer ? newer.data : result.data)
|
|
860
946
|
if (!isCurrent()) return
|
|
861
947
|
updateElementState(element, finalData, stateKey)
|
|
@@ -872,7 +958,7 @@ const runFetch = async (config, element, context, opts = {}) => {
|
|
|
872
958
|
}
|
|
873
959
|
|
|
874
960
|
// Set loading status
|
|
875
|
-
const isFirstLoad = !getCacheEntry(cacheKey)?.data
|
|
961
|
+
const isFirstLoad = !getCacheEntry(store, cacheKey)?.data
|
|
876
962
|
setFetchStatus(element, {
|
|
877
963
|
isFetching: true,
|
|
878
964
|
isLoading: isFirstLoad,
|
|
@@ -998,14 +1084,14 @@ const runFetch = async (config, element, context, opts = {}) => {
|
|
|
998
1084
|
requestSeqs.set(fetchPromise, requestSeq)
|
|
999
1085
|
|
|
1000
1086
|
// Store for deduplication
|
|
1001
|
-
if (cacheKey)
|
|
1087
|
+
if (cacheKey) store.active.set(cacheKey, fetchPromise)
|
|
1002
1088
|
|
|
1003
1089
|
try {
|
|
1004
1090
|
const result = await fetchPromise
|
|
1005
1091
|
// An action's write happened whatever became of the element that fired
|
|
1006
1092
|
// it (a dialog that closed on submit): its `invalidates` runs on success.
|
|
1007
1093
|
if (action && config.invalidates && result && !result.error && !result.subscribed) {
|
|
1008
|
-
runInvalidates(config.invalidates, from)
|
|
1094
|
+
runInvalidates(store, config.invalidates, from)
|
|
1009
1095
|
}
|
|
1010
1096
|
if (!isCurrent()) return
|
|
1011
1097
|
if (result?.subscribed) return
|
|
@@ -1014,7 +1100,7 @@ const runFetch = async (config, element, context, opts = {}) => {
|
|
|
1014
1100
|
|
|
1015
1101
|
if (error) {
|
|
1016
1102
|
// An older request's failure never replaces a newer answer.
|
|
1017
|
-
if (cacheKey && !newerEntry(cacheKey, requestSeq)) setCacheEntry(cacheKey, null, error, requestSeq)
|
|
1103
|
+
if (cacheKey && !newerEntry(store, cacheKey, requestSeq)) setCacheEntry(store, cacheKey, null, error, requestSeq)
|
|
1018
1104
|
fail(error)
|
|
1019
1105
|
if (_emitNet) _emitNet({
|
|
1020
1106
|
phase: 'error', mode: 'query', from, method, cacheKey,
|
|
@@ -1031,12 +1117,12 @@ const runFetch = async (config, element, context, opts = {}) => {
|
|
|
1031
1117
|
// A request that started later has written its newer rows already:
|
|
1032
1118
|
// show those, leave the cache alone. (Not for an infinite query —
|
|
1033
1119
|
// each of its replies is one page, not the whole answer.)
|
|
1034
|
-
const newer = !infinite && newerEntry(cacheKey, requestSeq)
|
|
1120
|
+
const newer = !infinite && newerEntry(store, cacheKey, requestSeq)
|
|
1035
1121
|
if (newer) {
|
|
1036
1122
|
rows = newer.data
|
|
1037
1123
|
} else {
|
|
1038
1124
|
// Invalidated while this request was out: kept, never served as fresh.
|
|
1039
|
-
const entry = setCacheEntry(cacheKey, data, null, requestSeq, isOutdated(cacheKey, requestSeq))
|
|
1125
|
+
const entry = setCacheEntry(store, cacheKey, data, null, requestSeq, isOutdated(store, cacheKey, requestSeq))
|
|
1040
1126
|
entry.gcTime = cacheConfig?.gcTime || 300000
|
|
1041
1127
|
}
|
|
1042
1128
|
}
|
|
@@ -1089,7 +1175,7 @@ const runFetch = async (config, element, context, opts = {}) => {
|
|
|
1089
1175
|
})
|
|
1090
1176
|
} finally {
|
|
1091
1177
|
// A forced refetch may have replaced this key while the old request ran.
|
|
1092
|
-
if (cacheKey &&
|
|
1178
|
+
if (cacheKey && store.active.get(cacheKey) === fetchPromise) store.active.delete(cacheKey)
|
|
1093
1179
|
}
|
|
1094
1180
|
}
|
|
1095
1181
|
|
|
@@ -1157,8 +1243,8 @@ const bindStateChange = (config, element, context, runner) => {
|
|
|
1157
1243
|
if (inputs === last) return
|
|
1158
1244
|
last = inputs
|
|
1159
1245
|
// Outside the effect: the run reads state as well, and none of that may
|
|
1160
|
-
// become the effect's dependency.
|
|
1161
|
-
|
|
1246
|
+
// become the effect's dependency. (queueMicrotask: see BUILTIN_ADAPTERS.)
|
|
1247
|
+
queueMicrotask(() => runner(config, element, context))
|
|
1162
1248
|
})
|
|
1163
1249
|
if (Array.isArray(ref.__effects)) ref.__effects.push(effect)
|
|
1164
1250
|
else ref.__fetchListeners.push(() => effect.dispose())
|
|
@@ -1198,7 +1284,7 @@ const bindAutoRefetch = (config, element, context) => {
|
|
|
1198
1284
|
if (onFocusEnabled) {
|
|
1199
1285
|
const onFocus = () => {
|
|
1200
1286
|
const cacheKey = elementQueryKey(config, element, context)
|
|
1201
|
-
const entry = getCacheEntry(cacheKey)
|
|
1287
|
+
const entry = getCacheEntry(storeOf(context), cacheKey)
|
|
1202
1288
|
const cacheConfig = parseCacheConfig(config.cache)
|
|
1203
1289
|
if (!entry || entry.stale || (Date.now() - entry.time) > (cacheConfig?.staleTime || 0)) {
|
|
1204
1290
|
runFetch(config, element, context, { force: true })
|
|
@@ -1281,15 +1367,15 @@ const keyMatcher = (match) => {
|
|
|
1281
1367
|
return () => false
|
|
1282
1368
|
}
|
|
1283
1369
|
|
|
1284
|
-
// Refetch the mounted queries whose current key `matches` selects.
|
|
1285
|
-
// on a key sends the request; the others on that key share it
|
|
1286
|
-
// dedup, which never shares a request that started before the
|
|
1287
|
-
// invalidation — so no one is handed the pre-write rows.
|
|
1288
|
-
const refreshMounted = (matches) => {
|
|
1370
|
+
// Refetch the mounted queries of `store` whose current key `matches` selects.
|
|
1371
|
+
// The first on a key sends the request; the others on that key share it
|
|
1372
|
+
// through the dedup, which never shares a request that started before the
|
|
1373
|
+
// key's invalidation — so no one is handed the pre-write rows.
|
|
1374
|
+
const refreshMounted = (store, matches) => {
|
|
1289
1375
|
const runs = []
|
|
1290
|
-
for (const record of
|
|
1376
|
+
for (const record of store.mounted) {
|
|
1291
1377
|
if (!isMounted(record)) {
|
|
1292
|
-
|
|
1378
|
+
store.mounted.delete(record)
|
|
1293
1379
|
continue
|
|
1294
1380
|
}
|
|
1295
1381
|
const key = mountedKey(record)
|
|
@@ -1298,47 +1384,56 @@ const refreshMounted = (matches) => {
|
|
|
1298
1384
|
return Promise.allSettled(runs).then(() => {})
|
|
1299
1385
|
}
|
|
1300
1386
|
|
|
1301
|
-
// Mark every key `matches` selects as invalidated — its cache
|
|
1302
|
-
// a first load still in flight outdated — then refetch the
|
|
1303
|
-
// those keys; resolves once those refetches settle. The
|
|
1304
|
-
// script asked for (early.js) are dropped as well: an
|
|
1305
|
-
// write happened, and they may predate it.
|
|
1306
|
-
const invalidateMatching = (matches) => {
|
|
1387
|
+
// Mark every key of `store` that `matches` selects as invalidated — its cache
|
|
1388
|
+
// entry stale, and a first load still in flight outdated — then refetch the
|
|
1389
|
+
// mounted queries on those keys; resolves once those refetches settle. The
|
|
1390
|
+
// answers the pre-boot script asked for (early.js) are dropped as well: an
|
|
1391
|
+
// invalidation means a write happened, and they may predate it.
|
|
1392
|
+
const invalidateMatching = (store, matches) => {
|
|
1307
1393
|
dropEarly()
|
|
1308
|
-
for (const [key, entry] of
|
|
1394
|
+
for (const [key, entry] of store.cache) {
|
|
1309
1395
|
if (!matches(key)) continue
|
|
1310
1396
|
entry.stale = true
|
|
1311
|
-
markInvalidated(key)
|
|
1397
|
+
markInvalidated(store, key)
|
|
1312
1398
|
}
|
|
1313
|
-
for (const [key] of
|
|
1314
|
-
if (!
|
|
1399
|
+
for (const [key] of store.active) {
|
|
1400
|
+
if (!store.cache.has(key) && matches(key)) markInvalidated(store, key)
|
|
1315
1401
|
}
|
|
1316
|
-
return refreshMounted(matches)
|
|
1402
|
+
return refreshMounted(store, matches)
|
|
1317
1403
|
}
|
|
1318
1404
|
|
|
1319
1405
|
// A write's `invalidates`: `true` or '*' selects every query of the write's
|
|
1320
1406
|
// own `from`; anything else — or each item of a list — is a match as
|
|
1321
1407
|
// invalidateQueries takes it. (Whole keys only, before: the documented
|
|
1322
1408
|
// `invalidates: ['posts']` matched no real key.)
|
|
1323
|
-
const runInvalidates = (invalidates, from) => {
|
|
1409
|
+
const runInvalidates = (store, invalidates, from) => {
|
|
1324
1410
|
const matchers = (isArray(invalidates) ? invalidates : [invalidates]).map((match) => {
|
|
1325
1411
|
if (match === true || match === '*') return (key) => key.startsWith(from + ':')
|
|
1326
1412
|
return match ? keyMatcher(match) : () => false
|
|
1327
1413
|
})
|
|
1328
|
-
return invalidateMatching((key) => matchers.some((matches) => matches(key)))
|
|
1414
|
+
return invalidateMatching(store, (key) => matchers.some((matches) => matches(key)))
|
|
1329
1415
|
}
|
|
1330
1416
|
|
|
1331
|
-
const removeMatching = (matches) => {
|
|
1332
|
-
for (const [key] of
|
|
1333
|
-
if (matches(key)) removeCache(key)
|
|
1417
|
+
const removeMatching = (store, matches) => {
|
|
1418
|
+
for (const [key] of store.cache) {
|
|
1419
|
+
if (matches(key)) removeCache(store, key)
|
|
1334
1420
|
}
|
|
1335
|
-
for (const [key] of
|
|
1336
|
-
if (matches(key)) markInvalidated(key)
|
|
1421
|
+
for (const [key] of store.active) {
|
|
1422
|
+
if (matches(key)) markInvalidated(store, key)
|
|
1337
1423
|
}
|
|
1338
1424
|
}
|
|
1339
1425
|
|
|
1340
1426
|
const ALL_KEYS = () => true
|
|
1341
1427
|
|
|
1428
|
+
// What getQueryData / setQueryData do, on one store.
|
|
1429
|
+
const readQueryData = (store, key) => getCacheEntry(store, key)?.data ?? undefined
|
|
1430
|
+
|
|
1431
|
+
const writeQueryData = (store, key, updater) => {
|
|
1432
|
+
const existing = getCacheEntry(store, key)
|
|
1433
|
+
const data = isFunction(updater) ? updater(existing?.data) : updater
|
|
1434
|
+
setCacheEntry(store, key, data, null)
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1342
1437
|
// --- Mutation runner (insert/update/delete with optimistic + invalidation) ---
|
|
1343
1438
|
|
|
1344
1439
|
const runMutation = async (config, element, context) => {
|
|
@@ -1445,7 +1540,7 @@ const runMutation = async (config, element, context) => {
|
|
|
1445
1540
|
})
|
|
1446
1541
|
|
|
1447
1542
|
// Invalidate related queries — the mounted ones refetch now
|
|
1448
|
-
if (invalidates) runInvalidates(invalidates, from)
|
|
1543
|
+
if (invalidates) runInvalidates(storeOf(context), invalidates, from)
|
|
1449
1544
|
} catch (e) {
|
|
1450
1545
|
if (snapshot !== undefined) rollbackOptimistic(element, config)
|
|
1451
1546
|
setFetchStatus(element, errorStatus(e))
|
|
@@ -1476,7 +1571,7 @@ const releaseFetch = (ref) => {
|
|
|
1476
1571
|
for (const cleanup of ref.__fetchCleanups || []) release(cleanup)
|
|
1477
1572
|
for (const cleanup of ref.__fetchListeners || []) release(cleanup)
|
|
1478
1573
|
if (isFunction(ref.__unsubscribe)) release(ref.__unsubscribe)
|
|
1479
|
-
for (const record of ref.__fetchMounted || [])
|
|
1574
|
+
for (const record of ref.__fetchMounted || []) storeOf(record.context).mounted.delete(record)
|
|
1480
1575
|
ref.__fetchCleanups = []
|
|
1481
1576
|
ref.__fetchListeners = []
|
|
1482
1577
|
ref.__unsubscribe = null
|
|
@@ -1495,11 +1590,13 @@ export const executeFetch = (param, element, state, context) => {
|
|
|
1495
1590
|
? fetchProp.map(c => resolveFetchConfig(exec(c, element), element)).filter(Boolean)
|
|
1496
1591
|
: [resolveFetchConfig(fetchProp, element)].filter(Boolean)
|
|
1497
1592
|
|
|
1498
|
-
// This element's mounted queries (refetched by an invalidation)
|
|
1499
|
-
// replaces its configs, so it replaces their
|
|
1593
|
+
// This element's mounted queries (refetched by an invalidation), in its
|
|
1594
|
+
// context's store. A re-run replaces its configs, so it replaces their
|
|
1595
|
+
// records too.
|
|
1500
1596
|
const ref = element.__ref
|
|
1597
|
+
const store = storeOf(context)
|
|
1501
1598
|
if (ref.__fetchMounted) {
|
|
1502
|
-
for (const record of ref.__fetchMounted)
|
|
1599
|
+
for (const record of ref.__fetchMounted) store.mounted.delete(record)
|
|
1503
1600
|
}
|
|
1504
1601
|
ref.__fetchMounted = []
|
|
1505
1602
|
|
|
@@ -1514,6 +1611,10 @@ export const executeFetch = (param, element, state, context) => {
|
|
|
1514
1611
|
ref.__effects.push(ref.__fetchDisposer)
|
|
1515
1612
|
}
|
|
1516
1613
|
|
|
1614
|
+
// Window focus, reconnect and an interval refresh a live page; a server
|
|
1615
|
+
// render binds none of them (isServerRender).
|
|
1616
|
+
const browserRefetch = !isServerRender(context)
|
|
1617
|
+
|
|
1517
1618
|
for (const config of configs) {
|
|
1518
1619
|
const isMutation = isMutationMethod(config.method)
|
|
1519
1620
|
const runner = isMutation ? runMutation : runFetch
|
|
@@ -1525,20 +1626,19 @@ export const executeFetch = (param, element, state, context) => {
|
|
|
1525
1626
|
if (config.on === 'create') {
|
|
1526
1627
|
runner(config, element, context)
|
|
1527
1628
|
} else {
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
})
|
|
1629
|
+
// (queueMicrotask: see BUILTIN_ADAPTERS.)
|
|
1630
|
+
queueMicrotask(() => bindEvent({ ...config, __runner: runner }, element, context))
|
|
1531
1631
|
}
|
|
1532
1632
|
|
|
1533
1633
|
// Bind auto-refetch for queries
|
|
1534
1634
|
if (isQuery && config.on === 'create') {
|
|
1535
|
-
if (config.refetchInterval || config.refetchOnWindowFocus !== false || config.refetchOnReconnect !== false) {
|
|
1635
|
+
if (browserRefetch && (config.refetchInterval || config.refetchOnWindowFocus !== false || config.refetchOnReconnect !== false)) {
|
|
1536
1636
|
bindAutoRefetch(config, element, context)
|
|
1537
1637
|
}
|
|
1538
1638
|
// A single-use body has no cache key to invalidate.
|
|
1539
1639
|
if (!isSingleUse(config)) {
|
|
1540
1640
|
const record = { element, ref, config, context }
|
|
1541
|
-
|
|
1641
|
+
store.mounted.add(record)
|
|
1542
1642
|
ref.__fetchMounted.push(record)
|
|
1543
1643
|
}
|
|
1544
1644
|
}
|
|
@@ -1571,34 +1671,36 @@ export const executeFetch = (param, element, state, context) => {
|
|
|
1571
1671
|
}
|
|
1572
1672
|
|
|
1573
1673
|
// --- Query client (global cache management) ---
|
|
1674
|
+
//
|
|
1675
|
+
// The page store's handle (see Query stores): a browser page's cache. A
|
|
1676
|
+
// render context's store is reached through its own elements — the project
|
|
1677
|
+
// functions below, `el.call('invalidateQueries', …)` — and prefetchQuery,
|
|
1678
|
+
// which takes the context.
|
|
1574
1679
|
|
|
1575
1680
|
export const queryClient = {
|
|
1576
1681
|
// Marks every matching entry stale and refetches the mounted queries on
|
|
1577
1682
|
// those keys; resolves once those refetches settle. `keyOrPattern`: nothing
|
|
1578
1683
|
// (every key), a key prefix or substring, a list of key parts (joined with
|
|
1579
1684
|
// ':'), a RegExp, or a predicate `(key) => boolean`.
|
|
1580
|
-
invalidateQueries: (keyOrPattern) => invalidateMatching(keyMatcher(keyOrPattern)),
|
|
1685
|
+
invalidateQueries: (keyOrPattern) => invalidateMatching(pageStore, keyMatcher(keyOrPattern)),
|
|
1581
1686
|
|
|
1582
1687
|
// Drops every matching entry (same matches). A mounted query keeps the rows
|
|
1583
1688
|
// it shows; the next mount loads them afresh.
|
|
1584
1689
|
removeQueries: (keyOrPattern) => {
|
|
1585
|
-
removeMatching(keyOrPattern ? keyMatcher(keyOrPattern) : ALL_KEYS)
|
|
1690
|
+
removeMatching(pageStore, keyOrPattern ? keyMatcher(keyOrPattern) : ALL_KEYS)
|
|
1586
1691
|
},
|
|
1587
1692
|
|
|
1588
|
-
getQueryData: (key) =>
|
|
1589
|
-
const entry = getCacheEntry(key)
|
|
1590
|
-
return entry?.data ?? undefined
|
|
1591
|
-
},
|
|
1693
|
+
getQueryData: (key) => readQueryData(pageStore, key),
|
|
1592
1694
|
|
|
1593
1695
|
setQueryData: (key, updater) => {
|
|
1594
|
-
|
|
1595
|
-
const data = isFunction(updater) ? updater(existing?.data) : updater
|
|
1596
|
-
setCacheEntry(key, data, null)
|
|
1696
|
+
writeQueryData(pageStore, key, updater)
|
|
1597
1697
|
},
|
|
1598
1698
|
|
|
1699
|
+
// Into the store of `context` — a render context's own, else the page's.
|
|
1599
1700
|
prefetchQuery: async (config, context) => {
|
|
1600
1701
|
const db = context?.fetch
|
|
1601
1702
|
if (!db) return
|
|
1703
|
+
const store = storeOf(context)
|
|
1602
1704
|
|
|
1603
1705
|
const adapter = await resolveAdapter(db, context)
|
|
1604
1706
|
if (!adapter) return
|
|
@@ -1633,16 +1735,16 @@ export const queryClient = {
|
|
|
1633
1735
|
|
|
1634
1736
|
// Ordered like a query's reply: never over newer rows, stale when the key
|
|
1635
1737
|
// was invalidated while the prefetch was out.
|
|
1636
|
-
if (result?.data !== undefined && !result?.error && !isSingleUse(config) && !newerEntry(cacheKey, requestSeq)) {
|
|
1637
|
-
const entry = setCacheEntry(cacheKey, result.data, null, requestSeq, isOutdated(cacheKey, requestSeq))
|
|
1738
|
+
if (result?.data !== undefined && !result?.error && !isSingleUse(config) && !newerEntry(store, cacheKey, requestSeq)) {
|
|
1739
|
+
const entry = setCacheEntry(store, cacheKey, result.data, null, requestSeq, isOutdated(store, cacheKey, requestSeq))
|
|
1638
1740
|
entry.gcTime = parseCacheConfig(config.cache)?.gcTime || 300000
|
|
1639
1741
|
}
|
|
1640
1742
|
|
|
1641
1743
|
return result
|
|
1642
1744
|
},
|
|
1643
1745
|
|
|
1644
|
-
getCache: () =>
|
|
1645
|
-
clear: () => removeMatching(ALL_KEYS),
|
|
1746
|
+
getCache: () => pageStore.cache,
|
|
1747
|
+
clear: () => removeMatching(pageStore, ALL_KEYS),
|
|
1646
1748
|
|
|
1647
1749
|
// Drop every cache entry whose lang suffix isn't `keepLang`.
|
|
1648
1750
|
// Cache keys are of the form `${from}:${method}:${params}[:lang]` — entries
|
|
@@ -1657,14 +1759,14 @@ export const queryClient = {
|
|
|
1657
1759
|
dropNonLang: (keepLang) => {
|
|
1658
1760
|
if (!keepLang) return
|
|
1659
1761
|
const keepSuffix = `:${keepLang}`
|
|
1660
|
-
for (const [key] of
|
|
1762
|
+
for (const [key] of pageStore.cache) {
|
|
1661
1763
|
// Entry without any lang suffix: presence of `:` in middle but not
|
|
1662
1764
|
// matching any 2-3 letter ISO suffix at the end. Conservative: keep
|
|
1663
1765
|
// entries whose tail doesn't look like `:xx` or `:xxx`.
|
|
1664
1766
|
const tailMatch = key.match(/:([a-z]{2,3})$/i)
|
|
1665
1767
|
if (!tailMatch) continue // no lang suffix → keep
|
|
1666
1768
|
if (key.endsWith(keepSuffix)) continue // matches keepLang → keep
|
|
1667
|
-
removeCache(key)
|
|
1769
|
+
removeCache(pageStore, key)
|
|
1668
1770
|
}
|
|
1669
1771
|
}
|
|
1670
1772
|
}
|
|
@@ -1684,11 +1786,15 @@ const keyFor = (keyOrConfig, element) => {
|
|
|
1684
1786
|
return elementQueryKey({ method: 'select', ...keyOrConfig }, element, element?.context)
|
|
1685
1787
|
}
|
|
1686
1788
|
|
|
1789
|
+
// Each works on the caller's store (storeOf its context), as a query does:
|
|
1790
|
+
// in a server render, the render's own — what its project code writes,
|
|
1791
|
+
// removes or invalidates stays in that render; in a browser page, the page
|
|
1792
|
+
// store, as queryClient.
|
|
1687
1793
|
export const fetchFunctions = {
|
|
1688
|
-
invalidateQueries (keyOrPattern) { return
|
|
1689
|
-
removeQueries (keyOrPattern) {
|
|
1690
|
-
getQueryData (keyOrConfig) { return
|
|
1691
|
-
setQueryData (keyOrConfig, updater) {
|
|
1794
|
+
invalidateQueries (keyOrPattern) { return invalidateMatching(storeOf(this?.context), keyMatcher(keyOrPattern)) },
|
|
1795
|
+
removeQueries (keyOrPattern) { removeMatching(storeOf(this?.context), keyOrPattern ? keyMatcher(keyOrPattern) : ALL_KEYS) },
|
|
1796
|
+
getQueryData (keyOrConfig) { return readQueryData(storeOf(this?.context), keyFor(keyOrConfig, this)) },
|
|
1797
|
+
setQueryData (keyOrConfig, updater) { writeQueryData(storeOf(this?.context), keyFor(keyOrConfig, this), updater) }
|
|
1692
1798
|
}
|
|
1693
1799
|
|
|
1694
1800
|
// --- Plugin interface ---
|
|
@@ -1714,11 +1820,12 @@ export const fetchPlugin = {
|
|
|
1714
1820
|
const fetchProp = element.fetch
|
|
1715
1821
|
if (!fetchProp) return
|
|
1716
1822
|
// Clear stale in-flight queries on root element creation
|
|
1717
|
-
if (!element.parent)
|
|
1823
|
+
if (!element.parent) storeOf(element.context).active.clear()
|
|
1718
1824
|
executeFetch(fetchProp, element, element.state, element.context)
|
|
1719
1825
|
}
|
|
1720
1826
|
}
|
|
1721
1827
|
|
|
1722
1828
|
export { parseDuration }
|
|
1723
|
-
export { earlyKey, EARLY_KEY, createEarlySeedScript } from './early.js'
|
|
1829
|
+
export { earlyKey, EARLY_KEY, createEarlySeedScript, createEarlySeedBlock, SEED_BLOCK } from './early.js'
|
|
1830
|
+
export { requestsInFlight, whenRequestsIdle } from './inflight.js'
|
|
1724
1831
|
export default executeFetch
|