@abide/abide 0.54.0 → 0.56.0

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.
Files changed (98) hide show
  1. package/AGENTS.md +330 -498
  2. package/CHANGELOG.md +63 -0
  3. package/README.md +166 -146
  4. package/package.json +2 -3
  5. package/src/lib/server/rpc/defineRpc.ts +2 -1
  6. package/src/lib/server/rpc/types/RpcHelper.ts +29 -22
  7. package/src/lib/server/runtime/SSR_SWAP_SCRIPT.ts +3 -2
  8. package/src/lib/server/runtime/createUiPageRenderer.ts +34 -26
  9. package/src/lib/shared/SEEDS.ts +33 -0
  10. package/src/lib/shared/SOCKET_SEED.ts +4 -5
  11. package/src/lib/shared/cache.ts +169 -75
  12. package/src/lib/shared/createRemoteFunction.ts +4 -1
  13. package/src/lib/shared/encodeSeedValue.ts +24 -0
  14. package/src/lib/shared/jsonSchemaForType.ts +3 -16
  15. package/src/lib/shared/peek.ts +3 -6
  16. package/src/lib/ui/amendReaderHook.ts +1 -1
  17. package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +1 -6
  18. package/src/lib/ui/compile/analyzeComponent.ts +0 -2
  19. package/src/lib/ui/compile/awaitSubjectExpr.ts +23 -0
  20. package/src/lib/ui/compile/cachedShadowProgram.ts +2 -1
  21. package/src/lib/ui/compile/compileComponent.ts +1 -10
  22. package/src/lib/ui/compile/compileShadow.ts +113 -8
  23. package/src/lib/ui/compile/componentOrdinals.ts +39 -0
  24. package/src/lib/ui/compile/desugarSignals.ts +195 -44
  25. package/src/lib/ui/compile/generateBuild.ts +25 -24
  26. package/src/lib/ui/compile/generateSSR.ts +37 -24
  27. package/src/lib/ui/compile/hoistableAwaits.ts +1 -11
  28. package/src/lib/ui/compile/isSpuriousAsyncReadDiagnostic.ts +46 -5
  29. package/src/lib/ui/compile/liftScriptSeed.ts +47 -0
  30. package/src/lib/ui/compile/lowerContext.ts +26 -6
  31. package/src/lib/ui/compile/lowerScript.ts +1 -11
  32. package/src/lib/ui/compile/renameSignalRefs.ts +7 -21
  33. package/src/lib/ui/compile/seedTypeClassifierForRoot.ts +1 -1
  34. package/src/lib/ui/compile/shadowInterpolationClassifier.ts +1 -1
  35. package/src/lib/ui/compile/types/AnalyzedComponent.ts +3 -5
  36. package/src/lib/ui/compile/wrapReactiveSeeds.ts +44 -0
  37. package/src/lib/ui/compile/wrapSeed.ts +28 -0
  38. package/src/lib/ui/computed.ts +16 -0
  39. package/src/lib/ui/createScope.ts +4 -3
  40. package/src/lib/ui/dom/anchorCursor.ts +2 -1
  41. package/src/lib/ui/dom/anchoredBranch.ts +14 -3
  42. package/src/lib/ui/dom/appendStatic.ts +11 -20
  43. package/src/lib/ui/dom/appendText.ts +41 -43
  44. package/src/lib/ui/dom/appendTextAt.ts +10 -57
  45. package/src/lib/ui/dom/awaitBlock.ts +30 -44
  46. package/src/lib/ui/dom/awaitSubject.ts +118 -0
  47. package/src/lib/ui/dom/claimText.ts +38 -0
  48. package/src/lib/ui/dom/cloneStatic.ts +2 -8
  49. package/src/lib/ui/dom/discardAndRebuild.ts +23 -0
  50. package/src/lib/ui/dom/discardBoundary.ts +5 -4
  51. package/src/lib/ui/dom/each.ts +3 -5
  52. package/src/lib/ui/dom/fillBoundary.ts +2 -1
  53. package/src/lib/ui/dom/hydrate.ts +5 -12
  54. package/src/lib/ui/dom/mountChild.ts +73 -5
  55. package/src/lib/ui/dom/mountSwappableRange.ts +2 -1
  56. package/src/lib/ui/dom/openMarker.ts +2 -4
  57. package/src/lib/ui/dom/outlet.ts +4 -3
  58. package/src/lib/ui/dom/readCell.ts +26 -11
  59. package/src/lib/ui/dom/skeleton.ts +2 -7
  60. package/src/lib/ui/dom/switchBlock.ts +2 -1
  61. package/src/lib/ui/dom/tryBlock.ts +26 -38
  62. package/src/lib/ui/dom/when.ts +6 -2
  63. package/src/lib/ui/linked.ts +32 -1
  64. package/src/lib/ui/resumeSeedScript.ts +1 -1
  65. package/src/lib/ui/router.ts +12 -24
  66. package/src/lib/ui/runtime/AsyncCellError.ts +2 -1
  67. package/src/lib/ui/runtime/BRANCH_SEGMENT.ts +24 -0
  68. package/src/lib/ui/runtime/CELL_SEED.ts +4 -6
  69. package/src/lib/ui/runtime/DOC_SEED.ts +5 -6
  70. package/src/lib/ui/runtime/RENDER.ts +7 -5
  71. package/src/lib/ui/runtime/RESUME.ts +5 -7
  72. package/src/lib/ui/runtime/SEED_MARKS.ts +14 -0
  73. package/src/lib/ui/runtime/SuspenseSignal.ts +2 -2
  74. package/src/lib/ui/runtime/claimChild.ts +5 -7
  75. package/src/lib/ui/runtime/claimExpected.ts +2 -6
  76. package/src/lib/ui/runtime/claimMarker.ts +13 -0
  77. package/src/lib/ui/runtime/claimRun.ts +22 -0
  78. package/src/lib/ui/runtime/consumeSeed.ts +38 -0
  79. package/src/lib/ui/runtime/createAsyncCell.ts +66 -8
  80. package/src/lib/ui/runtime/createComputedNode.ts +1 -0
  81. package/src/lib/ui/runtime/createEffectNode.ts +1 -0
  82. package/src/lib/ui/runtime/createSignalNode.ts +1 -0
  83. package/src/lib/ui/runtime/parkCursor.ts +10 -0
  84. package/src/lib/ui/runtime/readNode.ts +21 -1
  85. package/src/lib/ui/runtime/renderPath.ts +2 -2
  86. package/src/lib/ui/runtime/routeBareSeed.ts +28 -0
  87. package/src/lib/ui/runtime/runHydrationPass.ts +69 -0
  88. package/src/lib/ui/runtime/runNode.ts +14 -0
  89. package/src/lib/ui/runtime/types/HydrationCursor.ts +8 -0
  90. package/src/lib/ui/runtime/types/ReactiveNode.ts +5 -0
  91. package/src/lib/ui/settleAsyncCells.ts +30 -3
  92. package/src/lib/ui/startClient.ts +0 -7
  93. package/src/lib/ui/tryEncodeResume.ts +6 -15
  94. package/src/lib/ui/watch.ts +3 -11
  95. package/src/lib/ui/dom/mountStreamedChild.ts +0 -84
  96. package/src/lib/ui/dom/readCellBlocking.ts +0 -29
  97. package/src/lib/ui/runtime/restoreWarmSeeds.ts +0 -20
  98. package/src/lib/ui/runtime/warmSeedBackup.ts +0 -10
@@ -1,7 +1,8 @@
1
1
  /*
2
2
  The tiny inline script the abide-ui SSR stream ships in <head>. For each streamed
3
3
  `<abide-resolve data-id>` frame it reads the leading `<script type=application/json>`,
4
- stores its raw ref-json text into `window.__abideResume` (the resume manifest hydration
4
+ stores its raw ref-json text into `window.__abideSeeds.resume` (the resume partition of the
5
+ one seed manifest, ADR-0048 — the store hydration
5
6
  reads, decoding each entry at read time) and swaps the resolved markup into the matching
6
7
  `<!--abide:await:ID-->…<!--/abide:await:ID-->` boundary — so the pending shell paints
7
8
  instantly and each value lands as it arrives, before the client bundle even loads. It
@@ -12,7 +13,7 @@ inlines cheaply ahead of the document body.
12
13
  export const SSR_SWAP_SCRIPT =
13
14
  "function __abideSwap(){var f=document.querySelector('abide-resolve');while(f){" +
14
15
  "var id=f.getAttribute('data-id'),p=f.firstChild,w=document.createTreeWalker(document.body,NodeFilter.SHOW_COMMENT),o=null,c;" +
15
- "if(p&&p.nodeName==='SCRIPT'){(window.__abideResume=window.__abideResume||{})[id]=p.textContent||'';p.remove();}" +
16
+ "if(p&&p.nodeName==='SCRIPT'){var s=window.__abideSeeds=window.__abideSeeds||{};(s.resume=s.resume||{})[id]=p.textContent||'';p.remove();}" +
16
17
  "while((c=w.nextNode())){if(c.data==='abide:await:'+id){o=c;break;}}" +
17
18
  "if(o){var n=o.nextSibling;while(n&&!(n.nodeType===8&&n.data==='/abide:await:'+id)){var x=n.nextSibling;n.remove();n=x;}" +
18
19
  "while(f.firstChild){o.parentNode.insertBefore(f.firstChild,n);}}f.remove();f=document.querySelector('abide-resolve');}}"
@@ -1,7 +1,7 @@
1
1
  import { appNameSlot } from '../../shared/appNameSlot.ts'
2
2
  import { SSR_CACHE_CONTROL } from '../../shared/CACHE_CONTROL_VALUES.ts'
3
3
  import { docSnapshotsSlot } from '../../shared/docSnapshotsSlot.ts'
4
- import { encodeRefJson } from '../../shared/encodeRefJson.ts'
4
+ import { encodeSeedValue } from '../../shared/encodeSeedValue.ts'
5
5
  import { formatTraceparent } from '../../shared/formatTraceparent.ts'
6
6
  import { hasSeedableRequest } from '../../shared/hasSeedableRequest.ts'
7
7
  import { layoutChainForRoute } from '../../shared/layoutChainForRoute.ts'
@@ -116,12 +116,13 @@ export function createUiPageRenderer({
116
116
  }
117
117
  const cells: Record<string, string> = {}
118
118
  for (const { key, value } of entries) {
119
- try {
120
- cells[key] = encodeRefJson(value)
121
- } catch {
122
- console.warn(
123
- `[abide] async cell "${key}" resolved to an unserializable value — it will re-run on the client instead of hydrating warm.`,
124
- )
119
+ const encoded = encodeSeedValue(
120
+ value,
121
+ `async cell "${key}" resolved value`,
122
+ 'it will re-run on the client instead of hydrating warm',
123
+ )
124
+ if (encoded !== undefined) {
125
+ cells[key] = encoded
125
126
  }
126
127
  }
127
128
  return Object.keys(cells).length > 0 ? cells : undefined
@@ -152,11 +153,13 @@ export function createUiPageRenderer({
152
153
  ) {
153
154
  continue
154
155
  }
155
- try {
156
- docs[id] = encodeRefJson(snapshot)
157
- } catch {
158
- /* Unserializable synchronous state (a function/class instance) — the client re-inits
159
- it cold rather than blanking the payload. */
156
+ const encoded = encodeSeedValue(
157
+ snapshot,
158
+ `scope "${id}" state snapshot`,
159
+ 'the client re-inits its state cold',
160
+ )
161
+ if (encoded !== undefined) {
162
+ docs[id] = encoded
160
163
  }
161
164
  }
162
165
  return Object.keys(docs).length > 0 ? docs : undefined
@@ -175,12 +178,13 @@ export function createUiPageRenderer({
175
178
  }
176
179
  const sockets: Record<string, string> = {}
177
180
  for (const { name, value } of entries) {
178
- try {
179
- sockets[name] = encodeRefJson(value)
180
- } catch {
181
- console.warn(
182
- `[abide] socket "${name}" retained frame is unserializable — its client peek() will read undefined at hydration instead of the server value.`,
183
- )
181
+ const encoded = encodeSeedValue(
182
+ value,
183
+ `socket "${name}" retained frame`,
184
+ 'its client peek() reads undefined at hydration instead of the server value',
185
+ )
186
+ if (encoded !== undefined) {
187
+ sockets[name] = encoded
184
188
  }
185
189
  }
186
190
  return Object.keys(sockets).length > 0 ? sockets : undefined
@@ -418,10 +422,12 @@ export function createUiPageRenderer({
418
422
  streamed and the client cold-runs it. An unserializable value emits nothing.
419
423
  Read from `store` directly since the stream body runs outside the request ALS. */
420
424
  for (const { key, value } of store.streamedCells.entries) {
421
- let encoded: string
422
- try {
423
- encoded = encodeRefJson(value)
424
- } catch {
425
+ const encoded = encodeSeedValue(
426
+ value,
427
+ `streaming cell "${key}" settled value`,
428
+ 'the client cold-runs its seed instead of adopting the streamed value',
429
+ )
430
+ if (encoded === undefined) {
425
431
  continue
426
432
  }
427
433
  controller.enqueue(
@@ -436,10 +442,12 @@ export function createUiPageRenderer({
436
442
  if (seededCellKeys.has(key)) {
437
443
  continue
438
444
  }
439
- let encoded: string
440
- try {
441
- encoded = encodeRefJson(value)
442
- } catch {
445
+ const encoded = encodeSeedValue(
446
+ value,
447
+ `streamed child's async cell "${key}" resolved value`,
448
+ 'it will re-run on the client instead of hydrating warm',
449
+ )
450
+ if (encoded === undefined) {
443
451
  continue
444
452
  }
445
453
  controller.enqueue(
@@ -0,0 +1,33 @@
1
+ /*
2
+ The ONE hydration seed manifest (ADR-0048): every passive server→client warm-seed
3
+ partition — await resumes, async-cell values, doc snapshots, socket frames — rides a single
4
+ `globalThis.__abideSeeds` global, kind-partitioned. Each partition is a Record of ref-json
5
+ STRINGS decoded lazily at its read site, so the inline pre-bundle scripts (the SSR swap
6
+ script, the resume seed script) can write entries without the codec. The per-kind module
7
+ names (`RESUME`, `CELL_SEED`, `DOC_SEED`, `SOCKET_SEED`) are views onto these partitions,
8
+ so consumers keep their domain name while the wire carries one global.
9
+
10
+ Each partition initializes with its own `??=` — NOT one whole-object create — because the
11
+ vanilla scripts and the bundle race in either order and a script that ran first may have
12
+ created the root with only its own partition; whoever arrives later must fill the gaps and
13
+ adopt the same references. The live streamed-cell machinery (`STREAMED_CELLS`) and the head
14
+ collector buffer (`__abideResumeCache`) deliberately stay separate: they are apply-timing
15
+ phases (ADR-0040), not passive manifests.
16
+ */
17
+ type SeedPartitions = {
18
+ resume: Record<string, string>
19
+ cells: Record<string, string>
20
+ docs: Record<string, string>
21
+ sockets: Record<string, string>
22
+ }
23
+
24
+ const globalScope = globalThis as { __abideSeeds?: Partial<SeedPartitions> }
25
+ globalScope.__abideSeeds ??= {}
26
+ const root = globalScope.__abideSeeds
27
+ root.resume ??= {}
28
+ root.cells ??= {}
29
+ root.docs ??= {}
30
+ root.sockets ??= {}
31
+
32
+ // @documentation plumbing
33
+ export const SEEDS: SeedPartitions = root as SeedPartitions
@@ -1,13 +1,12 @@
1
+ import { SEEDS } from './SEEDS.ts'
2
+
1
3
  /* The socket warm-seed manifest: a socket's retained latest frame as read via `peek(socket)` during
2
4
  SSR, keyed by the socket's name, as a ref-json-encoded STRING (decoded at the read site in
3
5
  `socketProxy`). `startClient` drains `__SSR__.sockets` into here before mount; a hydrating
4
6
  `socketProxy` reads its key to seed the socket's `lastFrame` WARM, so `peek(socket)` returns the
5
7
  SAME retained value the server rendered instead of `undefined` on the not-yet-connected client —
6
8
  killing the SSR/hydration divergence that would otherwise discard the server markup and cold-render.
7
- Backed by `globalThis.__abideSockets` so an inline pre-bundle script and the framework share one
8
- store — whoever runs first creates it, the other adopts the same reference (mirrors CELL_SEED). */
9
- const globalScope = globalThis as { __abideSockets?: Record<string, string> }
10
- globalScope.__abideSockets ??= {}
11
9
 
10
+ The `sockets` partition of the one `__abideSeeds` manifest (ADR-0048, see SEEDS). */
12
11
  // @documentation plumbing
13
- export const SOCKET_SEED: Record<string, string> = globalScope.__abideSockets
12
+ export const SOCKET_SEED: Record<string, string> = SEEDS.sockets
@@ -176,10 +176,16 @@ its value is kept for display regardless of ttl, and ttl marks a staleness
176
176
  deadline (the next read past it revalidates in the background) instead of driving
177
177
  eviction. No call-site options (ADR-0020): all policy is read off the endpoint
178
178
  (fn.cache). Always a decoded RemoteFunction read (the callable carries .raw), so
179
- it returns Promise<Return>.
179
+ it returns Promise<Return>. `key` is the caller's already-derived cache key (the
180
+ callable keys its rpc-error registry with it) — threaded through so readThrough
181
+ reuses it instead of re-deriving the identical string; omitted, readThrough derives.
180
182
  */
181
- function smartRead<Args, Return>(fn: RemoteFunction<Args, Return>, args?: Args): Promise<Return> {
182
- return readThrough(fn, args, undefined, true) as Promise<Return>
183
+ function smartRead<Args, Return>(
184
+ fn: RemoteFunction<Args, Return>,
185
+ args?: Args,
186
+ key?: string,
187
+ ): Promise<Return> {
188
+ return readThrough(fn, args, undefined, true, key) as Promise<Return>
183
189
  }
184
190
  cache.read = smartRead
185
191
 
@@ -196,6 +202,7 @@ function readThrough<Args, Return>(
196
202
  args: Args | undefined,
197
203
  options: CacheOptions | undefined,
198
204
  smart: boolean,
205
+ precomputedKey?: string,
199
206
  ): Promise<Return | Response> {
200
207
  /*
201
208
  A remote function carries the REMOTE_FUNCTION brand (set by
@@ -239,13 +246,17 @@ function readThrough<Args, Return>(
239
246
  - a read that resolved to the process store WITHOUT `shared` — a server read outside any
240
247
  request scope, where activeCacheStore() falls back to the shared store. Nothing bounds
241
248
  its lifetime there, so retaining it would leak forever.
242
- Both apply on either side and however the call is made.
249
+ Both apply on either side and however the call is made. An explicit `shared` defeats both:
250
+ it is a deliberate "memoise this across requests" opt-in (a POST that is a pure function
251
+ of its args, using the body only to carry a large payload — highlightCode), so it retains
252
+ in the process store with the default Infinity ttl rather than dropping on settle. An
253
+ explicit `ttl` already defeats them via the `ttl === undefined` guard above.
243
254
  */
244
255
  const coalesceOnly =
245
256
  isRemote &&
246
257
  policy?.ttl === undefined &&
247
- (!replayable ||
248
- (!policy?.shared && typeof window === 'undefined' && store === sharedCacheStore()))
258
+ !policy?.shared &&
259
+ (!replayable || (typeof window === 'undefined' && store === sharedCacheStore()))
249
260
  const effectiveOptions = coalesceOnly ? { ...policy, ttl: 0 } : policy
250
261
  if (!isRemote) {
251
262
  warnAnonymousProducer(fn as Producer<Args, Return>)
@@ -254,7 +265,12 @@ function readThrough<Args, Return>(
254
265
  return invokeProducer(store, fn as Producer<Args, Return>, args, effectiveOptions)
255
266
  }
256
267
  const remote = rawFn as RawRemoteFunction<Args>
257
- const key = keyForRemoteCall(remote.method, remote.url, args)
268
+ /* Reuse the caller's derived key when it threaded one (the smart bare call already
269
+ computes it for its rpc-error registry, and remote is that callable's .raw — same
270
+ method/url, so the string is identical). Otherwise derive it — the public cache()
271
+ and the raw path pass none. Saves a query-encode / canonical-json stringify per read,
272
+ warm hits included. */
273
+ const key = precomputedKey ?? keyForRemoteCall(remote.method, remote.url, args)
258
274
  if (
259
275
  !effectiveOptions?.shared &&
260
276
  effectiveOptions?.ttl !== undefined &&
@@ -756,22 +772,100 @@ rather than a first-ever load. An empty or unmatched selector is a no-op on the
756
772
  cache; the lifecycle ping still fires but recomputes pending() to the same value.
757
773
  */
758
774
  function invalidate<Args, Return>(arg?: CacheSelector<Args, Return>, args?: Args): void {
759
- /* Resolve the fn-selector prefix once; the matcher and the label both consume it. */
775
+ /* Resolve the fn-selector prefix once; the matcher, the label, and the exact-key
776
+ fast path all consume it. */
760
777
  const prefix = selectorPrefix(arg, args)
761
- invalidateMatching(selectorMatcher(arg, args, prefix), selectorLabel(arg, args, prefix), prefix)
778
+ invalidateMatching(
779
+ selectorMatcher(arg, args, prefix),
780
+ selectorLabel(arg, args, prefix),
781
+ prefix,
782
+ exactSelectorKey(arg, args, prefix),
783
+ )
784
+ }
785
+
786
+ /*
787
+ The single entry key a `fn + args` selector targets: args narrows the match to one
788
+ call, so the resolved prefix IS the exact key — the apply loops can `Map.get` it
789
+ directly instead of scanning every store. undefined for an all / bare-fn-prefix /
790
+ tag selector (which may match many entries) so those still scan, and for a producer
791
+ never cached (prefix undefined) whose scan matches nothing anyway.
792
+ */
793
+ function exactSelectorKey<Args, Return>(
794
+ arg: CacheSelector<Args, Return> | undefined,
795
+ args: Args | undefined,
796
+ prefix: string | undefined,
797
+ ): string | undefined {
798
+ return typeof arg === 'function' && args !== undefined ? prefix : undefined
799
+ }
800
+
801
+ /*
802
+ The ONE per-store selection dispatch every selector-driven verb runs through: an exact
803
+ key (a fn + args selector, from exactSelectorKey) picks its single candidate via a
804
+ direct Map.get instead of scanning; any other selector scans the store against
805
+ `matches`. Shared so the fast path and the scan can't diverge across
806
+ invalidate / refresh / amend (ADR-0041).
807
+ */
808
+ function applyToMatching(
809
+ store: CacheStore,
810
+ exactKey: string | undefined,
811
+ matches: (entry: CacheEntry) => boolean,
812
+ apply: (entry: CacheEntry) => void,
813
+ ): void {
814
+ if (exactKey !== undefined) {
815
+ const entry = store.entries.get(exactKey)
816
+ if (entry !== undefined) {
817
+ apply(entry)
818
+ }
819
+ return
820
+ }
821
+ /* Deleting the current entry mid-iteration is spec-safe on a Map; no snapshot needed. */
822
+ for (const entry of store.entries.values()) {
823
+ if (matches(entry)) {
824
+ apply(entry)
825
+ }
826
+ }
827
+ }
828
+
829
+ /*
830
+ The per-entry drop/refetch decision of invalidate() — the single seam so the full
831
+ scan and the exact-key fast path can't diverge (ADR-0041). A refetch-policy entry is
832
+ kept and its refetch coalesced; every other match is dropped so the next read reloads.
833
+ */
834
+ function invalidateEntry(
835
+ store: CacheStore,
836
+ entry: CacheEntry,
837
+ matched: string[],
838
+ affected: string[],
839
+ ): void {
840
+ matched.push(entry.key)
841
+ if (entry.invalidation) {
842
+ scheduleInvalidationRefetch(store, entry)
843
+ } else {
844
+ store.entries.delete(entry.key)
845
+ /* Flag the next read a reload (refreshing()) — but only if a reader is
846
+ holding the value now; with none on screen the next read is a first
847
+ load, and an ungated add would linger forever on the tab store. */
848
+ if (store.hasReader(entry.key)) {
849
+ store.pendingRefresh.add(entry.key)
850
+ }
851
+ affected.push(entry.key)
852
+ }
762
853
  }
763
854
 
764
855
  /*
765
856
  The store-loop body of invalidate(), driven by a raw entry predicate rather than a
766
- selector — the single apply-by-matcher seam so a local invalidate() and a
767
- wire-driven one (a server broadcast decoded via matcherFromEnvelope) run the EXACT
768
- same drop loop and can't diverge (ADR-0041). `label` feeds the cycle tripwire;
769
- `prefix` (undefined for a tag selector) resets recorded rpc errors for the selector.
857
+ selector — a local invalidate() and a wire-driven one (a server broadcast decoded via
858
+ matcherFromEnvelope) share the per-entry `invalidateEntry` seam and can't diverge
859
+ (ADR-0041). `label` feeds the cycle tripwire; `prefix` (undefined for a tag selector)
860
+ resets recorded rpc errors for the selector. `exactKey` (a fn + args selector's one
861
+ key, from exactSelectorKey) picks that entry directly instead of scanning; the
862
+ wire-driven path passes none and scans as before.
770
863
  */
771
864
  function invalidateMatching(
772
865
  matches: (entry: CacheEntry) => boolean,
773
866
  label: string,
774
867
  prefix: string | undefined,
868
+ exactKey?: string,
775
869
  ): void {
776
870
  invalidateTripwire(label)
777
871
  /* Reset any recorded rpc errors for this selector too (independent of cache entries — a
@@ -782,25 +876,9 @@ function invalidateMatching(
782
876
  for (const store of cacheStores()) {
783
877
  const matched: string[] = []
784
878
  const affected: string[] = []
785
- /* Deleting the current entry mid-iteration is spec-safe on a Map; no snapshot needed. */
786
- for (const entry of store.entries.values()) {
787
- if (!matches(entry)) {
788
- continue
789
- }
790
- matched.push(entry.key)
791
- if (entry.invalidation) {
792
- scheduleInvalidationRefetch(store, entry)
793
- } else {
794
- store.entries.delete(entry.key)
795
- /* Flag the next read a reload (refreshing()) — but only if a reader is
796
- holding the value now; with none on screen the next read is a first
797
- load, and an ungated add would linger forever on the tab store. */
798
- if (store.hasReader(entry.key)) {
799
- store.pendingRefresh.add(entry.key)
800
- }
801
- affected.push(entry.key)
802
- }
803
- }
879
+ applyToMatching(store, exactKey, matches, (entry) =>
880
+ invalidateEntry(store, entry, matched, affected),
881
+ )
804
882
  /* Every match changed state (probes re-derive); only the dropped subset
805
883
  changed its visible value (readers re-read) — swr matches stay put. */
806
884
  notify(store, matched, affected)
@@ -851,18 +929,65 @@ way to re-run, so it drops (the next read reloads) — the invalidate fallback.
851
929
  */
852
930
  function refresh<Args, Return>(arg?: CacheSelector<Args, Return>, args?: Args): void {
853
931
  const prefix = selectorPrefix(arg, args)
854
- refreshMatching(selectorMatcher(arg, args, prefix), selectorLabel(arg, args, prefix), prefix)
932
+ refreshMatching(
933
+ selectorMatcher(arg, args, prefix),
934
+ selectorLabel(arg, args, prefix),
935
+ prefix,
936
+ exactSelectorKey(arg, args, prefix),
937
+ )
938
+ }
939
+
940
+ /*
941
+ The per-entry refetch/drop decision of refresh() — the single seam shared by the full
942
+ scan and the exact-key fast path (ADR-0041). Only a match with a live reader refetches;
943
+ a reader-less match drops so the next read reloads.
944
+ */
945
+ function refreshEntry(
946
+ store: CacheStore,
947
+ entry: CacheEntry,
948
+ matched: string[],
949
+ affected: string[],
950
+ ): void {
951
+ matched.push(entry.key)
952
+ /* No live reader is holding this key — its reactive scope has torn down (or
953
+ it was never read reactively and already dropped). A refresh has no
954
+ on-screen value to keep-stale-and-swap, so firing the network now is
955
+ wasted work that may be stale again before anything shows it. Drop the
956
+ entry so the next read reloads fresh instead — the same lazy path a
957
+ reader-less invalidate() takes. */
958
+ if (!store.hasReader(entry.key)) {
959
+ store.entries.delete(entry.key)
960
+ return
961
+ }
962
+ /* Arm a refetch on the fly for a policy-less remote entry by replaying its
963
+ stored Request — so a refresh always refetches-and-swaps, never blanks. */
964
+ if (entry.invalidation === undefined && entry.request !== undefined) {
965
+ const request = entry.request
966
+ entry.invalidation = { refetch: () => fetch(request.clone()) }
967
+ }
968
+ if (entry.invalidation !== undefined) {
969
+ scheduleInvalidationRefetch(store, entry)
970
+ } else {
971
+ /* No policy, no request (a producer never cached with a refetch window): drop so the
972
+ next read reloads. A reader is holding it (checked above), so flag the
973
+ next read a reload (refreshing()). */
974
+ store.entries.delete(entry.key)
975
+ store.pendingRefresh.add(entry.key)
976
+ affected.push(entry.key)
977
+ }
855
978
  }
856
979
 
857
980
  /*
858
981
  The store-loop body of refresh(), driven by a raw entry predicate — the refetch
859
- analogue of invalidateMatching, so a local refresh() and a wire-driven one share
860
- one refetch loop (ADR-0041).
982
+ analogue of invalidateMatching, so a local refresh() and a wire-driven one share the
983
+ per-entry `refreshEntry` seam (ADR-0041). `exactKey` (a fn + args selector's one key)
984
+ picks that entry directly instead of scanning; the wire-driven path passes none.
861
985
  */
862
986
  function refreshMatching(
863
987
  matches: (entry: CacheEntry) => boolean,
864
988
  label: string,
865
989
  prefix: string | undefined,
990
+ exactKey?: string,
866
991
  ): void {
867
992
  invalidateTripwire(label)
868
993
  if (prefix !== undefined) {
@@ -871,38 +996,9 @@ function refreshMatching(
871
996
  for (const store of cacheStores()) {
872
997
  const matched: string[] = []
873
998
  const affected: string[] = []
874
- for (const entry of store.entries.values()) {
875
- if (!matches(entry)) {
876
- continue
877
- }
878
- matched.push(entry.key)
879
- /* No live reader is holding this key — its reactive scope has torn down (or
880
- it was never read reactively and already dropped). A refresh has no
881
- on-screen value to keep-stale-and-swap, so firing the network now is
882
- wasted work that may be stale again before anything shows it. Drop the
883
- entry so the next read reloads fresh instead — the same lazy path a
884
- reader-less invalidate() takes. */
885
- if (!store.hasReader(entry.key)) {
886
- store.entries.delete(entry.key)
887
- continue
888
- }
889
- /* Arm a refetch on the fly for a policy-less remote entry by replaying its
890
- stored Request — so a refresh always refetches-and-swaps, never blanks. */
891
- if (entry.invalidation === undefined && entry.request !== undefined) {
892
- const request = entry.request
893
- entry.invalidation = { refetch: () => fetch(request.clone()) }
894
- }
895
- if (entry.invalidation !== undefined) {
896
- scheduleInvalidationRefetch(store, entry)
897
- } else {
898
- /* No policy, no request (a producer never cached with a refetch window): drop so the
899
- next read reloads. A reader is holding it (checked above), so flag the
900
- next read a reload (refreshing()). */
901
- store.entries.delete(entry.key)
902
- store.pendingRefresh.add(entry.key)
903
- affected.push(entry.key)
904
- }
905
- }
999
+ applyToMatching(store, exactKey, matches, (entry) =>
1000
+ refreshEntry(store, entry, matched, affected),
1001
+ )
906
1002
  /* Mark the whole match set (probes re-derive); emit only the dropped subset — the
907
1003
  refetched entries emit when their fresh value lands (fireRefetch). */
908
1004
  notify(store, matched, affected)
@@ -930,13 +1026,14 @@ function amend<Args, Return>(
930
1026
  isValue: boolean,
931
1027
  ): void {
932
1028
  const prefix = selectorPrefix(arg, args)
1029
+ const exactKey = exactSelectorKey(arg, args, prefix)
933
1030
  const matches = selectorMatcher(arg, args, prefix)
1031
+ /* The exact-key arm is the optimistic-update / socket-frame path, potentially once
1032
+ per frame — a direct Map.get through the shared dispatch, no scan. */
934
1033
  for (const store of cacheStores()) {
935
- for (const entry of store.entries.values()) {
936
- if (matches(entry)) {
937
- applyAmend(store, entry, updater as (current: unknown) => unknown, isValue)
938
- }
939
- }
1034
+ applyToMatching(store, exactKey, matches, (entry) =>
1035
+ applyAmend(store, entry, updater as (current: unknown) => unknown, isValue),
1036
+ )
940
1037
  }
941
1038
  }
942
1039
 
@@ -1378,8 +1475,5 @@ function notify(store: CacheStore, marked: string[], emitted: string[]): void {
1378
1475
  }
1379
1476
 
1380
1477
  function emit(store: CacheStore, keys: string[]): void {
1381
- if (keys.length === 0) {
1382
- return
1383
- }
1384
1478
  store.events.dispatchEvent(invalidateEvent(keys))
1385
1479
  }
@@ -142,7 +142,10 @@ export function createRemoteFunction<Args, Return>(opts: {
142
142
  `raw`. There is no call-site options argument any more — all cache policy is read
143
143
  from the endpoint (`callable.cache`); per-call transport options live on `.raw`. */
144
144
  const key = keyForRemoteCall(method, url, args)
145
- return cache.read(callable as RemoteFunction<Args, Return>, args as Args).then(
145
+ /* Thread the derived key into the smart read so readThrough reuses it rather than
146
+ re-deriving the identical string (callable.raw carries the same method/url); the
147
+ key already tags the rpc-error registry entries in the handlers below. */
148
+ return cache.read(callable as RemoteFunction<Args, Return>, args as Args, key).then(
146
149
  /* Capture the rejection into the rpc error registry (design Part 4) keyed by call
147
150
  identity, and clear it on success — the reactive `fn.error()` probe reads it. */
148
151
  (value) => {
@@ -0,0 +1,24 @@
1
+ import { encodeRefJson } from './encodeRefJson.ts'
2
+
3
+ /*
4
+ The ONE degradation site for every server→client hydration seed (ADR-0048): ref-json-encode
5
+ a value bound for a warm-seed channel (await resume, cell value, doc snapshot, socket frame,
6
+ streamed-cell chunk), or drop JUST that entry with a warn naming what was dropped and what
7
+ the client does instead. encodeRefJson is total for ordinary graphs (cycles become
8
+ back-references, functions fold to undefined), so a throw is pathological — but it must
9
+ never blank the surrounding payload/stream. Funnelling every channel through here keeps the
10
+ serialize-or-degrade policy, the warn shape, and the per-kind consequence in one place
11
+ instead of seven independently-worded (or silent) drop sites.
12
+ */
13
+ export function encodeSeedValue(
14
+ value: unknown,
15
+ label: string,
16
+ consequence: string,
17
+ ): string | undefined {
18
+ try {
19
+ return encodeRefJson(value)
20
+ } catch (cause) {
21
+ console.warn(`[abide] ${label} is not serializable — ${consequence}.`, cause)
22
+ return undefined
23
+ }
24
+ }
@@ -85,7 +85,7 @@ function project(
85
85
  // A `Set`/`Map` rides the wire as a JSON array (ADR-0029 output encode), so the projected schema
86
86
  // MUST agree with those bytes — a `Set<T>` is `T[]`, a `Map<K,V>` is `[K,V][]` entries.
87
87
  if (symbolName === 'Set' || symbolName === 'ReadonlySet') {
88
- return projectSet(checker, type, seen)
88
+ return projectArray(checker, type, seen)
89
89
  }
90
90
  if (symbolName === 'Map' || symbolName === 'ReadonlyMap') {
91
91
  return projectMap(checker, type, seen)
@@ -131,7 +131,8 @@ function projectUnion(
131
131
  return { anyOf }
132
132
  }
133
133
 
134
- /* `T[]`/`readonly T[]` → `{ type: 'array', items: project(T) }`. */
134
+ /* `T[]`/`readonly T[]` → `{ type: 'array', items: project(T) }`. Also serves `Set<T>`, which
135
+ rides the wire as `[...set]` (ADR-0029) — the same `T[]` shape. */
135
136
  function projectArray(
136
137
  checker: ts.TypeChecker,
137
138
  type: ts.Type,
@@ -144,20 +145,6 @@ function projectArray(
144
145
  return { type: 'array', items }
145
146
  }
146
147
 
147
- /* `Set<T>`/`readonly Set<T>` → `{ type: 'array', items: project(T) }` — congruent with the
148
- `wireJsonReplacer` encode (`[...set]`). */
149
- function projectSet(
150
- checker: ts.TypeChecker,
151
- type: ts.Type,
152
- seen: Set<ts.Type>,
153
- ): Record<string, unknown> {
154
- const element = checker.getTypeArguments(type as ts.TypeReference)[0]
155
- seen.add(type)
156
- const items = element === undefined ? {} : project(checker, element, seen)
157
- seen.delete(type)
158
- return { type: 'array', items }
159
- }
160
-
161
148
  /* `Map<K,V>`/`readonly Map<K,V>` → an array of `[K,V]` entry tuples — congruent with the
162
149
  `wireJsonReplacer` encode (`[...map]`). Each entry is a fixed 2-tuple, so the item schema is
163
150
  positional `prefixItems` with `items: false` to forbid extras (matching projectTuple). */
@@ -1,5 +1,6 @@
1
1
  import { cache } from './cache.ts'
2
2
  import { isAsyncCell } from './isAsyncCell.ts'
3
+ import { isAsyncIterable } from './isAsyncIterable.ts'
3
4
  import type { AsyncComputed } from './types/AsyncComputed.ts'
4
5
  import type { RemoteFunction } from './types/RemoteFunction.ts'
5
6
  import type { Socket } from './types/Socket.ts'
@@ -38,12 +39,8 @@ export function peek(source: unknown, args?: unknown): unknown {
38
39
  /* A subscribable (socket/stream) carries its own latest-frame probe; an rpc does not
39
40
  have Symbol.asyncIterator, so this cleanly splits the two even though both expose a
40
41
  `.peek` method. */
41
- if (
42
- source !== null &&
43
- typeof source === 'object' &&
44
- typeof (source as { [Symbol.asyncIterator]?: unknown })[Symbol.asyncIterator] === 'function'
45
- ) {
46
- return (source as { peek: () => unknown }).peek()
42
+ if (isAsyncIterable(source)) {
43
+ return (source as unknown as { peek: () => unknown }).peek()
47
44
  }
48
45
  return cache.peek(source as RemoteFunction<unknown, unknown>, args as never)
49
46
  }
@@ -57,7 +57,7 @@ export function createAmendReaderHook(
57
57
  /* Transport loss: reconcile the possibly-missed value against server truth,
58
58
  then re-open a fresh LIVE subscription (no replay) and keep going. */
59
59
  if (error instanceof SocketDisconnectedError) {
60
- cache.refreshMatching((entry) => entry.key === key, key, undefined)
60
+ cache.refreshMatching((entry) => entry.key === key, key, undefined, key)
61
61
  try {
62
62
  iterator = socket[Symbol.asyncIterator]()
63
63
  } catch (reopenError) {
@@ -38,16 +38,12 @@ export const UI_RUNTIME_IMPORTS: { name: string; specifier: string; alias?: stri
38
38
  { name: 'eachAsync', specifier: 'ui/dom/eachAsync', alias: '$$eachAsync' },
39
39
  { name: 'when', specifier: 'ui/dom/when', alias: '$$when' },
40
40
  { name: 'awaitBlock', specifier: 'ui/dom/awaitBlock', alias: '$$awaitBlock' },
41
+ { name: 'awaitSubject', specifier: 'ui/dom/awaitSubject', alias: '$$awaitSubject' },
41
42
  { name: 'tryBlock', specifier: 'ui/dom/tryBlock', alias: '$$tryBlock' },
42
43
  { name: 'switchBlock', specifier: 'ui/dom/switchBlock', alias: '$$switchBlock' },
43
44
  { name: 'mountSlot', specifier: 'ui/dom/mountSlot', alias: '$$mountSlot' },
44
45
  { name: 'outlet', specifier: 'ui/dom/outlet', alias: '$$outlet' },
45
46
  { name: 'mountChild', specifier: 'ui/dom/mountChild', alias: '$$mountChild' },
46
- {
47
- name: 'mountStreamedChild',
48
- specifier: 'ui/dom/mountStreamedChild',
49
- alias: '$$mountStreamedChild',
50
- },
51
47
  { name: 'mergeProps', specifier: 'ui/dom/mergeProps', alias: '$$mergeProps' },
52
48
  { name: 'spreadProps', specifier: 'ui/dom/spreadProps', alias: '$$spreadProps' },
53
49
  { name: 'restProps', specifier: 'ui/dom/restProps', alias: '$$restProps' },
@@ -56,7 +52,6 @@ export const UI_RUNTIME_IMPORTS: { name: string; specifier: string; alias?: stri
56
52
  { name: 'spreadAttrs', specifier: 'ui/dom/spreadAttrs', alias: '$$spreadAttrs' },
57
53
  { name: 'readCall', specifier: 'ui/dom/readCall', alias: '$$readCall' },
58
54
  { name: 'readCell', specifier: 'ui/dom/readCell', alias: '$$readCell' },
59
- { name: 'readCellBlocking', specifier: 'ui/dom/readCellBlocking', alias: '$$readCellBlocking' },
60
55
  { name: 'writeCell', specifier: 'ui/dom/writeCell', alias: '$$writeCell' },
61
56
  { name: 'cellPending', specifier: 'ui/dom/cellPending', alias: '$$cellPending' },
62
57
  { name: 'withPath', specifier: 'ui/runtime/withPath', alias: '$$withPath' },