@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/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: () => import('./adapters/rest.js'),
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
- // --- Query cache ---
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
- const cacheStore = new Map()
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) => cacheStore.get(key) || null
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
- cacheStore.set(key, entry)
335
+ store.cache.set(key, entry)
258
336
 
259
337
  // Notify all subscribers of this query
260
- const subs = querySubscribers.get(key)
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 ? cacheStore.get(key) : null
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
- cacheStore.delete(key)
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
- if (!querySubscribers.has(key)) querySubscribers.set(key, new Set())
292
- querySubscribers.get(key).add(callback)
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 = querySubscribers.get(key)
373
+ const subs = subscribers.get(key)
295
374
  if (subs) {
296
375
  subs.delete(callback)
297
- if (subs.size === 0) querySubscribers.delete(key)
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. An invalidation refetches the ones whose
321
- // CURRENT key it selects (TanStack's "active" queries) instead of leaving them
322
- // on the old rows until their next mount or a window focus. The key is
323
- // computed when needed — the same resolution as the focus check — because
324
- // params, language and tenant scope can move after mount. A record whose
325
- // element was removed (`__ref` gone or replaced) is dropped whenever the
326
- // registry is walked: an invalidation, or the GC sweep.
327
- const mountedQueries = new Set()
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
- for (const [key, entry] of cacheStore) {
348
- const subs = querySubscribers.get(key)
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
- cacheStore.delete(key)
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 (!cacheStore.has(key) && !activeQueries.has(key)) invalidatedAt.delete(key)
439
+ if (!cache.has(key) && !active.has(key)) invalidatedAt.delete(key)
358
440
  }
359
- for (const record of mountedQueries) {
360
- if (!isMounted(record)) mountedQueries.delete(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-flight `activeQueries` promise) is served / dedup-collapsed for org B, so a
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 ? activeQueries.get(cacheKey) : null
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) activeQueries.set(cacheKey, fetchPromise)
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 && activeQueries.get(cacheKey) === fetchPromise) activeQueries.delete(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
- Promise.resolve().then(() => runner(config, element, context))
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. The first
1285
- // on a key sends the request; the others on that key share it through the
1286
- // dedup, which never shares a request that started before the key's
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 mountedQueries) {
1376
+ for (const record of store.mounted) {
1291
1377
  if (!isMounted(record)) {
1292
- mountedQueries.delete(record)
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 entry stale, and
1302
- // a first load still in flight outdated — then refetch the mounted queries on
1303
- // those keys; resolves once those refetches settle. The answers the pre-boot
1304
- // script asked for (early.js) are dropped as well: an invalidation means a
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 cacheStore) {
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 activeQueries) {
1314
- if (!cacheStore.has(key) && matches(key)) markInvalidated(key)
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 cacheStore) {
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 activeQueries) {
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 || []) mountedQueries.delete(record)
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). A re-run
1499
- // replaces its configs, so it replaces their records too.
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) mountedQueries.delete(record)
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
- Promise.resolve().then(() => {
1529
- bindEvent({ ...config, __runner: runner }, element, context)
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
- mountedQueries.add(record)
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
- const existing = getCacheEntry(key)
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: () => cacheStore,
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 cacheStore) {
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 queryClient.invalidateQueries(keyOrPattern) },
1689
- removeQueries (keyOrPattern) { return queryClient.removeQueries(keyOrPattern) },
1690
- getQueryData (keyOrConfig) { return queryClient.getQueryData(keyFor(keyOrConfig, this)) },
1691
- setQueryData (keyOrConfig, updater) { return queryClient.setQueryData(keyFor(keyOrConfig, this), 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) activeQueries.clear()
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