@symbo.ls/fetch 3.14.663 → 3.14.665

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
 
@@ -277,17 +294,14 @@ const newerEntry = (key, seq) => {
277
294
  // A request that started before its key's last invalidation (or removal).
278
295
  const isOutdated = (key, seq) => (invalidatedAt.get(key) || 0) > seq
279
296
 
297
+ // Every request on `key` that started before now is outdated from here on.
298
+ const markInvalidated = (key) => { invalidatedAt.set(key, ++fetchSeq) }
299
+
280
300
  // Removing an entry also marks its key, so a reply still in flight writes
281
301
  // its rows back as stale, never as fresh.
282
302
  const removeCache = (key) => {
283
- if (key) {
284
- cacheStore.delete(key)
285
- invalidatedAt.set(key, ++fetchSeq)
286
- return
287
- }
288
- for (const [cacheKey] of cacheStore) invalidatedAt.set(cacheKey, ++fetchSeq)
289
- for (const [inFlightKey] of activeQueries) invalidatedAt.set(inFlightKey, ++fetchSeq)
290
- cacheStore.clear()
303
+ cacheStore.delete(key)
304
+ markInvalidated(key)
291
305
  }
292
306
 
293
307
  const subscribeToCacheKey = (key, callback) => {
@@ -671,6 +685,10 @@ const updateElementState = (element, data, stateKey, options = {}) => {
671
685
  }
672
686
  }
673
687
 
688
+ // The settled statuses (setFetchStatus copies, so one object serves every write).
689
+ const SUCCESS = Object.freeze({ isFetching: false, isLoading: false, isStale: false, isSuccess: true, error: null, status: 'success', fetchStatus: 'idle' })
690
+ const errorStatus = (error) => ({ isFetching: false, isLoading: false, isStale: false, isSuccess: false, error, status: 'error', fetchStatus: 'idle' })
691
+
674
692
  const setFetchStatus = (element, status) => {
675
693
  // Element may have been torn down (route change, modal close, parent
676
694
  // unmount) between the runFetch entry guard and this call site —
@@ -754,6 +772,20 @@ const runFetch = async (config, element, context, opts = {}) => {
754
772
  keepPreviousData
755
773
  } = config
756
774
 
775
+ // The rows as the element gets them: `select` (a function) then `transform`.
776
+ const shape = (data) => {
777
+ if (isFunction(selectTransform)) data = selectTransform(data, element, element.state)
778
+ if (transform) data = transform(data, element, element.state)
779
+ return data
780
+ }
781
+ // This query failed: its status, then the element's and the config's
782
+ // onFetchError.
783
+ const fail = (error) => {
784
+ setFetchStatus(element, errorStatus(error))
785
+ triggerCallback(element, 'onFetchError', error)
786
+ triggerConfigCallback(config, element, 'onFetchError', error)
787
+ }
788
+
757
789
  let select = isString(selectTransform) ? selectTransform : undefined
758
790
  if (query && isFunction(element.getQuery)) {
759
791
  const q = element.getQuery(adapter.name || 'paths')
@@ -805,14 +837,12 @@ const runFetch = async (config, element, context, opts = {}) => {
805
837
  ref.__pages = [cached.data]
806
838
  setPageCursors(ref, config, cached.data)
807
839
  }
808
- let data = cached.data
809
- if (isFunction(selectTransform)) data = selectTransform(data, element, element.state)
810
- if (transform) data = transform(data, element, element.state)
840
+ const data = shape(cached.data)
811
841
  if (!isCurrent()) return
812
842
  // The status first: the state update re-renders, and a render that
813
843
  // reads `__fetchStatus` must see the answer it renders (report #25).
814
844
  if (!isStale) {
815
- setFetchStatus(element, { isFetching: false, isLoading: false, isStale: false, isSuccess: true, error: null, status: 'success', fetchStatus: 'idle' })
845
+ setFetchStatus(element, SUCCESS)
816
846
  updateElementState(element, data, stateKey)
817
847
  return
818
848
  }
@@ -834,11 +864,6 @@ const runFetch = async (config, element, context, opts = {}) => {
834
864
  // The shared request failed: so did this query, with the same error. It
835
865
  // used to read an error answer as null rows (onFetchComplete(null)) and a
836
866
  // 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
- }
842
867
  try {
843
868
  const result = await Promise.race([existing, new Promise((_, r) => setTimeout(() => r('DEDUP_TIMEOUT'), 15000))])
844
869
  if (!isCurrent()) return
@@ -848,9 +873,7 @@ const runFetch = async (config, element, context, opts = {}) => {
848
873
  // A request that started later (an invalidation's refetch) may have
849
874
  // landed first: its rows are the newer answer.
850
875
  const newer = !infinite && newerEntry(cacheKey, requestSeqs.get(existing) || 0)
851
- let finalData = newer ? newer.data : result.data
852
- if (isFunction(selectTransform)) finalData = selectTransform(finalData, element, element.state)
853
- if (transform) finalData = transform(finalData, element, element.state)
876
+ const finalData = shape(newer ? newer.data : result.data)
854
877
  if (!isCurrent()) return
855
878
  updateElementState(element, finalData, stateKey)
856
879
  triggerCallback(element, 'onFetchComplete', finalData)
@@ -891,9 +914,7 @@ const runFetch = async (config, element, context, opts = {}) => {
891
914
  if (!isCurrent()) return
892
915
  if (auth === true && !session) {
893
916
  const err = { message: 'Not authenticated' }
894
- setFetchStatus(element, { isFetching: false, isLoading: false, isStale: false, isSuccess: false, error: err, status: 'error', fetchStatus: 'idle' })
895
- triggerCallback(element, 'onFetchError', err)
896
- triggerConfigCallback(config, element, 'onFetchError', err)
917
+ fail(err)
897
918
  return { data: null, error: err }
898
919
  }
899
920
  }
@@ -1011,9 +1032,7 @@ const runFetch = async (config, element, context, opts = {}) => {
1011
1032
  if (error) {
1012
1033
  // An older request's failure never replaces a newer answer.
1013
1034
  if (cacheKey && !newerEntry(cacheKey, requestSeq)) setCacheEntry(cacheKey, null, error, requestSeq)
1014
- setFetchStatus(element, { isFetching: false, isLoading: false, isStale: false, isSuccess: false, error, status: 'error', fetchStatus: 'idle' })
1015
- triggerCallback(element, 'onFetchError', error)
1016
- triggerConfigCallback(config, element, 'onFetchError', error)
1035
+ fail(error)
1017
1036
  if (_emitNet) _emitNet({
1018
1037
  phase: 'error', mode: 'query', from, method, cacheKey,
1019
1038
  error: error.message || error,
@@ -1057,14 +1076,13 @@ const runFetch = async (config, element, context, opts = {}) => {
1057
1076
  setPageCursors(ref, config, data)
1058
1077
  }
1059
1078
 
1060
- if (isFunction(selectTransform)) finalData = selectTransform(finalData, element, element.state)
1061
- if (transform) finalData = transform(finalData, element, element.state)
1079
+ finalData = shape(finalData)
1062
1080
  if (!isCurrent()) return
1063
1081
 
1064
1082
  // The status first: the state update re-renders, and a render that
1065
1083
  // reads `__fetchStatus` saw the request still pending beside its own
1066
1084
  // rows (report #25).
1067
- setFetchStatus(element, { isFetching: false, isLoading: false, isStale: false, isSuccess: true, error: null, status: 'success', fetchStatus: 'idle' })
1085
+ setFetchStatus(element, SUCCESS)
1068
1086
 
1069
1087
  // keepPreviousData — don't clear state while fetching new page
1070
1088
  if (!keepPreviousData || finalData !== undefined) {
@@ -1080,9 +1098,7 @@ const runFetch = async (config, element, context, opts = {}) => {
1080
1098
  }
1081
1099
  } catch (e) {
1082
1100
  if (!isCurrent()) return
1083
- setFetchStatus(element, { isFetching: false, isLoading: false, isStale: false, isSuccess: false, error: e, status: 'error', fetchStatus: 'idle' })
1084
- triggerCallback(element, 'onFetchError', e)
1085
- triggerConfigCallback(config, element, 'onFetchError', e)
1101
+ fail(e)
1086
1102
  if (_emitNet) _emitNet({
1087
1103
  phase: 'error', mode: 'query', from, method, cacheKey,
1088
1104
  error: e.message || String(e),
@@ -1158,8 +1174,8 @@ const bindStateChange = (config, element, context, runner) => {
1158
1174
  if (inputs === last) return
1159
1175
  last = inputs
1160
1176
  // 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))
1177
+ // become the effect's dependency. (queueMicrotask: see BUILTIN_ADAPTERS.)
1178
+ queueMicrotask(() => runner(config, element, context))
1163
1179
  })
1164
1180
  if (Array.isArray(ref.__effects)) ref.__effects.push(effect)
1165
1181
  else ref.__fetchListeners.push(() => effect.dispose())
@@ -1309,10 +1325,10 @@ const invalidateMatching = (matches) => {
1309
1325
  for (const [key, entry] of cacheStore) {
1310
1326
  if (!matches(key)) continue
1311
1327
  entry.stale = true
1312
- invalidatedAt.set(key, ++fetchSeq)
1328
+ markInvalidated(key)
1313
1329
  }
1314
1330
  for (const [key] of activeQueries) {
1315
- if (!cacheStore.has(key) && matches(key)) invalidatedAt.set(key, ++fetchSeq)
1331
+ if (!cacheStore.has(key) && matches(key)) markInvalidated(key)
1316
1332
  }
1317
1333
  return refreshMounted(matches)
1318
1334
  }
@@ -1334,10 +1350,34 @@ const removeMatching = (matches) => {
1334
1350
  if (matches(key)) removeCache(key)
1335
1351
  }
1336
1352
  for (const [key] of activeQueries) {
1337
- if (matches(key)) invalidatedAt.set(key, ++fetchSeq)
1353
+ if (matches(key)) markInvalidated(key)
1338
1354
  }
1339
1355
  }
1340
1356
 
1357
+ const ALL_KEYS = () => true
1358
+
1359
+ // --- One query cache per server render ---
1360
+ //
1361
+ // The cache lives as long as this module: in a browser, the page. A server
1362
+ // renderer (@symbo.ls/brender) renders page after page in ONE module
1363
+ // instance — every route of `smbls build`, every request of a worker isolate
1364
+ // — and a page answered from the rows an earlier page left made no request:
1365
+ // nothing for that page's fetch seed (the browser asked again right after
1366
+ // boot; xma.info, 2026-10-16), and in an isolate serving several projects,
1367
+ // another project's rows under the same key. A context that sets
1368
+ // `isolateFetchCache` (brender sets it on every render) starts from an empty
1369
+ // cache: the first query it runs drops every entry an earlier context left,
1370
+ // marks the requests still out as outdated (never shared with it, never
1371
+ // written back as fresh) and forgets the queries mounted before it (an
1372
+ // invalidation it makes refetches its own). A browser context never sets it.
1373
+ const isolatedContexts = new WeakSet()
1374
+ const enterContext = (context) => {
1375
+ if (!context || !context.isolateFetchCache || isolatedContexts.has(context)) return
1376
+ isolatedContexts.add(context)
1377
+ removeMatching(ALL_KEYS)
1378
+ mountedQueries.clear()
1379
+ }
1380
+
1341
1381
  // --- Mutation runner (insert/update/delete with optimistic + invalidation) ---
1342
1382
 
1343
1383
  const runMutation = async (config, element, context) => {
@@ -1372,7 +1412,6 @@ const runMutation = async (config, element, context) => {
1372
1412
  // Apply optimistic update
1373
1413
  const snapshot = optimistic ? applyOptimisticUpdate(element, config, mutationData) : null
1374
1414
 
1375
- ref.__fetching = true
1376
1415
  setFetchStatus(element, { isFetching: true, isLoading: false, isStale: false, isSuccess: false, error: null, status: 'pending', fetchStatus: 'fetching' })
1377
1416
  triggerCallback(element, 'onFetchStart')
1378
1417
  triggerConfigCallback(config, element, 'onFetchStart')
@@ -1412,7 +1451,7 @@ const runMutation = async (config, element, context) => {
1412
1451
 
1413
1452
  if (error) {
1414
1453
  if (snapshot !== undefined) rollbackOptimistic(element, config)
1415
- setFetchStatus(element, { isFetching: false, isLoading: false, isStale: false, isSuccess: false, error, status: 'error', fetchStatus: 'idle' })
1454
+ setFetchStatus(element, errorStatus(error))
1416
1455
  triggerCallback(element, 'onFetchError', error)
1417
1456
  if (isFunction(onError)) onError(error, mutationData, element)
1418
1457
  if (isFunction(onSettled)) onSettled(null, error, mutationData, element)
@@ -1427,7 +1466,7 @@ const runMutation = async (config, element, context) => {
1427
1466
  delete ref.__optimisticSnapshot
1428
1467
 
1429
1468
  // 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' })
1469
+ setFetchStatus(element, SUCCESS)
1431
1470
 
1432
1471
  // A body value (Blob, stream, …) lands in state only under `as`.
1433
1472
  if (data !== undefined && (stateKey || !isBodyValue(data))) {
@@ -1448,7 +1487,7 @@ const runMutation = async (config, element, context) => {
1448
1487
  if (invalidates) runInvalidates(invalidates, from)
1449
1488
  } catch (e) {
1450
1489
  if (snapshot !== undefined) rollbackOptimistic(element, config)
1451
- setFetchStatus(element, { isFetching: false, isLoading: false, isStale: false, isSuccess: false, error: e, status: 'error', fetchStatus: 'idle' })
1490
+ setFetchStatus(element, errorStatus(e))
1452
1491
  triggerCallback(element, 'onFetchError', e)
1453
1492
  triggerConfigCallback(config, element, 'onFetchError', e)
1454
1493
  if (isFunction(onError)) onError(e, mutationData, element)
@@ -1487,6 +1526,7 @@ export const executeFetch = (param, element, state, context) => {
1487
1526
  if (!param) return
1488
1527
  const db = context?.fetch
1489
1528
  if (!db) return
1529
+ enterContext(context)
1490
1530
 
1491
1531
  const fetchProp = exec(param, element)
1492
1532
  if (!fetchProp) return
@@ -1525,9 +1565,8 @@ export const executeFetch = (param, element, state, context) => {
1525
1565
  if (config.on === 'create') {
1526
1566
  runner(config, element, context)
1527
1567
  } else {
1528
- Promise.resolve().then(() => {
1529
- bindEvent({ ...config, __runner: runner }, element, context)
1530
- })
1568
+ // (queueMicrotask: see BUILTIN_ADAPTERS.)
1569
+ queueMicrotask(() => bindEvent({ ...config, __runner: runner }, element, context))
1531
1570
  }
1532
1571
 
1533
1572
  // Bind auto-refetch for queries
@@ -1582,11 +1621,7 @@ export const queryClient = {
1582
1621
  // Drops every matching entry (same matches). A mounted query keeps the rows
1583
1622
  // it shows; the next mount loads them afresh.
1584
1623
  removeQueries: (keyOrPattern) => {
1585
- if (!keyOrPattern) {
1586
- removeCache()
1587
- return
1588
- }
1589
- removeMatching(keyMatcher(keyOrPattern))
1624
+ removeMatching(keyOrPattern ? keyMatcher(keyOrPattern) : ALL_KEYS)
1590
1625
  },
1591
1626
 
1592
1627
  getQueryData: (key) => {
@@ -1603,6 +1638,7 @@ export const queryClient = {
1603
1638
  prefetchQuery: async (config, context) => {
1604
1639
  const db = context?.fetch
1605
1640
  if (!db) return
1641
+ enterContext(context)
1606
1642
 
1607
1643
  const adapter = await resolveAdapter(db, context)
1608
1644
  if (!adapter) return
@@ -1646,7 +1682,7 @@ export const queryClient = {
1646
1682
  },
1647
1683
 
1648
1684
  getCache: () => cacheStore,
1649
- clear: () => removeCache(),
1685
+ clear: () => removeMatching(ALL_KEYS),
1650
1686
 
1651
1687
  // Drop every cache entry whose lang suffix isn't `keepLang`.
1652
1688
  // Cache keys are of the form `${from}:${method}:${params}[:lang]` — entries
@@ -1688,11 +1724,13 @@ const keyFor = (keyOrConfig, element) => {
1688
1724
  return elementQueryKey({ method: 'select', ...keyOrConfig }, element, element?.context)
1689
1725
  }
1690
1726
 
1727
+ // Each enters the caller's context first, as a query does (enterContext): rows
1728
+ // a server render's project code writes before its first query stay its own.
1691
1729
  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) }
1730
+ invalidateQueries (keyOrPattern) { enterContext(this?.context); return queryClient.invalidateQueries(keyOrPattern) },
1731
+ removeQueries (keyOrPattern) { enterContext(this?.context); return queryClient.removeQueries(keyOrPattern) },
1732
+ getQueryData (keyOrConfig) { enterContext(this?.context); return queryClient.getQueryData(keyFor(keyOrConfig, this)) },
1733
+ setQueryData (keyOrConfig, updater) { enterContext(this?.context); return queryClient.setQueryData(keyFor(keyOrConfig, this), updater) }
1696
1734
  }
1697
1735
 
1698
1736
  // --- Plugin interface ---
@@ -1724,5 +1762,6 @@ export const fetchPlugin = {
1724
1762
  }
1725
1763
 
1726
1764
  export { parseDuration }
1727
- export { earlyKey, EARLY_KEY, createEarlySeedScript } from './early.js'
1765
+ export { earlyKey, EARLY_KEY, createEarlySeedScript, createEarlySeedBlock, SEED_BLOCK } from './early.js'
1766
+ export { requestsInFlight, whenRequestsIdle } from './inflight.js'
1728
1767
  export default executeFetch
package/inflight.js ADDED
@@ -0,0 +1,39 @@
1
+ 'use strict'
2
+
3
+ // --- Requests in flight ---
4
+ //
5
+ // Every request a REST adapter sends counts from its call until its body is
6
+ // read. smbls holds the hydration window over a pre-rendered page open until
7
+ // the first view's requests are done (smbls prerender.js), so a list that
8
+ // renders with its first data still adopts the server's rows.
9
+ //
10
+ // The count lives on globalThis under a Symbol.for slot, like the adapter
11
+ // registry (index.js CUSTOM_ADAPTERS): the copy of this package that smbls
12
+ // asks and the copy an app bundle sends through may be two module instances.
13
+
14
+ const KEY = Symbol.for('@symbo.ls/fetch:IN_FLIGHT')
15
+
16
+ const state = () => globalThis[KEY] || (globalThis[KEY] = { count: 0, waiters: [] })
17
+
18
+ export const requestStarted = () => {
19
+ state().count++
20
+ }
21
+
22
+ export const requestFinished = () => {
23
+ const s = state()
24
+ s.count = s.count > 0 ? s.count - 1 : 0
25
+ if (s.count === 0 && s.waiters.length) {
26
+ const waiters = s.waiters
27
+ s.waiters = []
28
+ for (const resolve of waiters) resolve()
29
+ }
30
+ }
31
+
32
+ /** How many REST adapter requests are in flight on this page. */
33
+ export const requestsInFlight = () => state().count
34
+
35
+ /** Resolves once no REST adapter request is in flight (at once when none is). */
36
+ export const whenRequestsIdle = () => {
37
+ const s = state()
38
+ return s.count === 0 ? Promise.resolve() : new Promise((resolve) => { s.waiters.push(resolve) })
39
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@symbo.ls/fetch",
3
- "version": "3.14.663",
3
+ "version": "3.14.665",
4
4
  "license": "CC-BY-NC-4.0",
5
5
  "type": "module",
6
6
  "module": "./dist/esm/index.js",
@@ -41,8 +41,8 @@
41
41
  "adapters"
42
42
  ],
43
43
  "dependencies": {
44
- "@symbo.ls/signal": "^3.14.786",
45
- "@symbo.ls/utils": "^3.14.784"
44
+ "@symbo.ls/signal": "^3.14.789",
45
+ "@symbo.ls/utils": "^3.14.787"
46
46
  },
47
47
  "sideEffects": false,
48
48
  "publishConfig": {