@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
@@ -0,0 +1,38 @@
1
+ import { SEED_MARKS } from './SEED_MARKS.ts'
2
+
3
+ /*
4
+ Reads a warm-seed entry (`CELL_SEED` / `DOC_SEED`) under the one-shot contract: a seed
5
+ hydrates exactly one pass, so a LATER fresh mount at the same render-path can't
6
+ warm-adopt a stale boot-time snapshot (an SPA navigation recomputes the identical key).
7
+
8
+ Two-phase (ADR-0048): during a hydration pass the read MARKS the entry instead of deleting
9
+ it — a clean pass exit deletes the marked keys, and a THROWING pass leaves every value in
10
+ place, so the discard→cold-rebuild recovery re-adopts the SSR-resolved values instead of
11
+ refetching (a cold refetch would leave blocking `await` cells pending and escape as an
12
+ uncaught SuspenseSignal at mount). WITHIN a pass the read is idempotent — a repeat read
13
+ re-adopts the still-present value, because the repeat is a block-level cold rebuild
14
+ reconstructing the same render-path inside the live pass (its first construction marked
15
+ the key, then the adopt desynced); the one-shot half is enforced at pass exit, not per
16
+ read. Outside a pass — the recovery rebuild itself, a manual client-only mount — the
17
+ read deletes immediately, the plain one-shot.
18
+ */
19
+ export function consumeSeed(store: Record<string, string>, key: string): string | undefined {
20
+ const marks = SEED_MARKS.current
21
+ if (marks === undefined) {
22
+ const value = store[key]
23
+ if (value !== undefined) {
24
+ delete store[key]
25
+ }
26
+ return value
27
+ }
28
+ const value = store[key]
29
+ if (value !== undefined) {
30
+ let keys = marks.get(store)
31
+ if (keys === undefined) {
32
+ keys = new Set()
33
+ marks.set(store, keys)
34
+ }
35
+ keys.add(key)
36
+ }
37
+ return value
38
+ }
@@ -12,10 +12,12 @@ import { activePendingCells } from '../activePendingCells.ts'
12
12
  import type { Scope } from '../types/Scope.ts'
13
13
  import { CELL_SEED } from './CELL_SEED.ts'
14
14
  import { CURRENT_SCOPE } from './CURRENT_SCOPE.ts'
15
+ import { consumeSeed } from './consumeSeed.ts'
15
16
  import { createEffectNode } from './createEffectNode.ts'
16
17
  import { createSignalNode } from './createSignalNode.ts'
17
18
  import { readNode } from './readNode.ts'
18
19
  import { registerStreamedCell } from './STREAMED_CELLS.ts'
20
+ import { SuspenseSignal } from './SuspenseSignal.ts'
19
21
  import { writeNode } from './writeNode.ts'
20
22
 
21
23
  /* The seed's transform gate (mirrors `state`/`linked`): coerces every value entering
@@ -83,6 +85,14 @@ export function createAsyncCell(
83
85
  let inFlight: Promise<unknown> | undefined
84
86
  /* Cancels the active stream subscription (a reseed, refresh, or dispose supersedes it). */
85
87
  let cancelStream: (() => void) | undefined
88
+ /* Whether a pending read of this cell PAUSES its reader (ADR-0046). True only for a
89
+ BLOCKING source — a settling PROMISE the cell would join the SSR barrier for (author
90
+ `await`, or a fail-open bare-call that registers): `streaming !== true` AND the seed
91
+ produced a thenable. A STREAM (`isAsyncIterable`) never settles and always peeks, so it
92
+ is NOT blocking even though it carries the same default `streaming` flag; `run` clears
93
+ this when it sees a stream. Resolved from the produced source, not the flag alone, so
94
+ `streaming: false` cannot conflate "author `await`" with "stream". */
95
+ let blockingSource = false
86
96
 
87
97
  /* Store a produced value, honouring the write latch and the transform gate. */
88
98
  const acceptValue = (value: unknown): void => {
@@ -126,6 +136,16 @@ export function createAsyncCell(
126
136
  return
127
137
  }
128
138
  inFlight = undefined
139
+ /* A `SuspenseSignal` is a PAUSE, not a failure — the same as the synchronous case in
140
+ `run`, but arriving through a REJECTION: an async seed (`computed(await f(dep))`) reads a
141
+ pending blocking `dep` in its synchronous prefix, and the throw becomes the thunk's
142
+ rejection rather than a sync throw. Stay pending (leave `inFlightNode` true, hold no
143
+ error); the throwing read subscribed this seed's effect to `dep` during that prefix, so
144
+ it re-runs and produces the real source once `dep` settles. */
145
+ if (error instanceof SuspenseSignal) {
146
+ writeNode(errorNode, undefined)
147
+ return
148
+ }
129
149
  writeNode(errorNode, error)
130
150
  writeNode(inFlightNode, false)
131
151
  }
@@ -181,15 +201,45 @@ export function createAsyncCell(
181
201
  try {
182
202
  produced = seed()
183
203
  } catch (error) {
204
+ /* A `SuspenseSignal` is a PAUSE, not a failure: this seed read a blocking
205
+ dependency that has no value yet, so this cell's branch pauses too. Stay
206
+ pending — leave `inFlightNode` true, hold no value, carry no error — rather
207
+ than latching the pause into `error()`. The throwing read already subscribed
208
+ this seed's effect to the pending dependency, so when it settles the effect
209
+ re-runs the seed, which now reads a value and produces the real source. No
210
+ promise is registered (there is none), so the SSR barrier's fixpoint drain
211
+ picks this cell up on that re-run instead. */
212
+ if (error instanceof SuspenseSignal) {
213
+ /* This cell's pending reads must PAUSE too — the seed derives from a blocking
214
+ source, so a reader peeking `undefined` (the streaming default) would race the
215
+ dependency instead of waiting for it. Mark blocking from the SUSPEND itself,
216
+ since a synchronously-suspending seed produces no promise for the `isThenable`
217
+ branch to read it from; honour a `streaming` opt-out for symmetry with that
218
+ branch. Once the seed re-runs and settles a value this flag is moot (a retained
219
+ value is returned before `blocking` is ever consulted). */
220
+ if (options.streaming !== true) {
221
+ blockingSource = true
222
+ }
223
+ if (myRun === runId) {
224
+ inFlight = undefined
225
+ writeNode(errorNode, undefined)
226
+ }
227
+ return
228
+ }
184
229
  settleError(myRun, error)
185
230
  return
186
231
  }
187
232
 
188
233
  if (isAsyncIterable(produced)) {
234
+ /* A stream never settles — it PEEKS `undefined` until its first frame, never pauses. */
235
+ blockingSource = false
189
236
  consumeStream(myRun, produced as NamedAsyncIterable<unknown>)
190
237
  return
191
238
  }
192
239
  if (isThenable(produced)) {
240
+ /* A settling promise the cell would join the SSR barrier for (author `await`, or a
241
+ fail-open bare-call) PAUSES a pending read; a `streaming` promise peeks. */
242
+ blockingSource = options.streaming !== true
193
243
  inFlight = produced as Promise<unknown>
194
244
  /* Server-only: register the in-flight promise on the request-scoped pending list so
195
245
  the SSR barrier (`$$settleAsyncCells`) awaits it before the template peeks the
@@ -222,15 +272,15 @@ export function createAsyncCell(
222
272
  only in effect (the store is only ever populated by `startClient`, so it's empty on the
223
273
  server — no `window` sniff needed); a decode failure falls through to a cold run. */
224
274
  if (warmKey !== undefined) {
225
- const seeded = CELL_SEED[warmKey]
275
+ /* One-shot via `consumeSeed` (two-phase, ADR-0048): a LATER fresh mount at the same
276
+ render-path can't warm-adopt this boot-time snapshot. `scope.id` is the route PATTERN
277
+ (params-independent), so an SPA navigation `/products/42`→`/products/99` — or a
278
+ back-nav that rebuilds the SSR page — recomputes the identical warmKey; unconsumed,
279
+ the new cell would render `/products/42`'s stale value until revalidation. During a
280
+ hydration pass the consume only MARKS, so a desync throw hands the seed to the cold
281
+ rebuild instead of losing it. */
282
+ const seeded = consumeSeed(CELL_SEED, warmKey)
226
283
  if (seeded !== undefined) {
227
- /* One-shot: consume the seed so a LATER fresh mount at the same render-path can't
228
- warm-adopt this boot-time snapshot. `scope.id` is the route PATTERN (params-
229
- independent), so an SPA navigation `/products/42`→`/products/99` — or a back-nav
230
- that rebuilds the SSR page — recomputes the identical warmKey; without deleting,
231
- the new cell would render `/products/42`'s stale value until revalidation. The seed
232
- only ever hydrates the initial SSR render, where each cell is constructed once. */
233
- delete CELL_SEED[warmKey]
234
284
  try {
235
285
  acceptValue(decodeRefJson(seeded))
236
286
  } catch {
@@ -279,6 +329,14 @@ export function createAsyncCell(
279
329
  /* The current in-flight promise (undefined once settled / not-a-promise) — an internal
280
330
  runtime affordance the SSR barrier reads structurally; not on the public cell types. */
281
331
  settled: () => inFlight,
332
+ /* Whether a pending read of this cell PAUSES its reader (ADR-0046) — true only for a
333
+ blocking source (a barrier-joining settling promise), resolved from what the seed
334
+ produced (`blockingSource`) so a stream (which shares the default `streaming` flag)
335
+ is correctly NON-blocking. A getter, since the seed's eager run sets it. `readCell`
336
+ consults it; an internal affordance, not on the public cell type. */
337
+ get blocking(): boolean {
338
+ return blockingSource
339
+ },
282
340
  } as AsyncComputed<unknown>
283
341
  if (!options.writable) {
284
342
  return readOnly
@@ -13,6 +13,7 @@ export function createComputedNode(compute: () => unknown): ReactiveNode {
13
13
  subsHead: undefined,
14
14
  subsTail: undefined,
15
15
  status: NODE_STATE.DIRTY,
16
+ thrown: undefined,
16
17
  isEffect: false,
17
18
  }
18
19
  }
@@ -52,6 +52,7 @@ export function createEffectNode(fn: () => EffectResult): () => void {
52
52
  subsTail: undefined,
53
53
  /* Born DIRTY; the immediate `runNode` below captures deps and settles it CLEAN. */
54
54
  status: NODE_STATE.DIRTY,
55
+ thrown: undefined,
55
56
  isEffect: true,
56
57
  }
57
58
  runNode(node)
@@ -13,6 +13,7 @@ export function createSignalNode(value: unknown): ReactiveNode {
13
13
  subsHead: undefined,
14
14
  subsTail: undefined,
15
15
  status: NODE_STATE.CLEAN,
16
+ thrown: undefined,
16
17
  isEffect: false,
17
18
  }
18
19
  }
@@ -0,0 +1,10 @@
1
+ import type { HydrationCursor } from './types/HydrationCursor.ts'
2
+
3
+ /* Repositions the parent's claim cursor at `reference` (null = past the last child). The
4
+ ONE write site of the cursor invariant: every claim must leave the cursor on the next
5
+ unclaimed sibling, or the following claim grabs the wrong node — a silent desync. The
6
+ claim verbs advance through here; a block parks explicitly when it skips or discards a
7
+ region (an anchor's content, a boundary it re-claims later, a discarded range). */
8
+ export function parkCursor(hydration: HydrationCursor, parent: Node, reference: Node | null): void {
9
+ hydration.next.set(parent, reference)
10
+ }
@@ -8,10 +8,30 @@ settles first (the lazy pull — `updateIfNecessary` refreshes only the deps tha
8
8
  changed, recomputing if any did); a signal has no compute and returns its stored
9
9
  value directly. Tracking happens after the settle so the reader links to the
10
10
  computed itself, not its transitive deps.
11
+
12
+ The settle runs in a `try` whose `finally` still links the reader: an edge to a
13
+ node is an edge even when that node is currently PAUSED. A computed whose compute
14
+ throws — the common case being a `SuspenseSignal` from a pending blocking cell it
15
+ reads — must still leave its reader subscribed to it, or the reader would never
16
+ re-run when the paused branch resolves (the compute re-runs on settle via its own
17
+ retained deps, then this reader wakes off its edge to the computed). The signal
18
+ fast-path stays a plain track — it has no compute to throw and is the hot read.
11
19
  */
12
20
  export function readNode(node: ReactiveNode): unknown {
13
21
  if (node.compute !== undefined) {
14
- updateIfNecessary(node)
22
+ try {
23
+ updateIfNecessary(node)
24
+ /* Throw memoisation: a paused/erroring compute settled CLEAN with its throw
25
+ retained (`runNode`'s catch) — rethrow it so EVERY reader observes the
26
+ pause/error, not just the one whose read ran the compute. Cleared by the
27
+ next successful run once a dep change re-dirties the node. */
28
+ if (node.thrown !== undefined) {
29
+ throw node.thrown
30
+ }
31
+ } finally {
32
+ track(node)
33
+ }
34
+ return node.value
15
35
  }
16
36
  track(node)
17
37
  return node.value
@@ -3,8 +3,8 @@ import { withPath } from './withPath.ts'
3
3
 
4
4
  /*
5
5
  The render-path a `<Child/>` mounts under — the boundary id of a STREAMED child component (ADR-0039).
6
- Composes the child's ordinal segment onto the ambient path exactly as the client's `mountChild` /
7
- `mountStreamedChild` does (`withPath(ordinal, …)`), so the server-written `abide:await:CHILDPATH`
6
+ Composes the child's ordinal segment onto the ambient path exactly as the client's dual-mode
7
+ `mountChild` does (`withPath(ordinal, …)`), so the server-written `abide:await:CHILDPATH`
8
8
  boundary and the client adopter compute the SAME id with no counter to drift. It carries `/`
9
9
  separators and never a `:`, so a child path (`products/0`) can't collide with an await-block id
10
10
  (`products:0`). Server-emit-only (the streaming boundary is server codegen), so it tree-shakes out of
@@ -0,0 +1,28 @@
1
+ import { isAsyncIterable } from '../../shared/isAsyncIterable.ts'
2
+ import { isThenable } from '../../shared/isThenable.ts'
3
+ import type { AsyncComputed } from '../../shared/types/AsyncComputed.ts'
4
+ import type { AsyncState } from '../../shared/types/AsyncState.ts'
5
+ import { createAsyncCell } from './createAsyncCell.ts'
6
+
7
+ /*
8
+ The ONE statement of the ADR-0045 bare-seed contract for `computed`/`linked`: a bare
9
+ seed that reached the runtime literally (a branch-nested `<script>` keeps its
10
+ `state.computed(...)` calls literal, and a direct JS caller never compiles) routes BY
11
+ VALUE exactly as its thunk form would — a promise → a streaming unwrapped value cell, a
12
+ stream → a frame cell. Anything else returns `undefined`: not async, the caller wraps it
13
+ as a constant seed thunk. The classification order (thenable before async-iterable) and
14
+ the streaming flag are load-bearing and shared here so the two primitives cannot drift.
15
+ */
16
+ export function routeBareSeed(
17
+ value: unknown,
18
+ writable: boolean,
19
+ transform?: (next: unknown, previous: unknown) => unknown,
20
+ ): AsyncComputed<unknown> | AsyncState<unknown> | undefined {
21
+ if (isThenable(value)) {
22
+ return createAsyncCell(() => value, { writable, transform, streaming: true })
23
+ }
24
+ if (isAsyncIterable(value)) {
25
+ return createAsyncCell(() => value, { writable, transform })
26
+ }
27
+ return undefined
28
+ }
@@ -0,0 +1,69 @@
1
+ import { hydrationWindow } from '../../shared/hydrationWindow.ts'
2
+ import { enterRenderPass } from './enterRenderPass.ts'
3
+ import { exitRenderPass } from './exitRenderPass.ts'
4
+ import { RENDER } from './RENDER.ts'
5
+ import { SEED_MARKS } from './SEED_MARKS.ts'
6
+
7
+ /*
8
+ The one owner of a hydration pass's lifecycle. A pass is four lifetimes that must open
9
+ and close together — the claim cursor (`RENDER.hydration`), the cache-withhold window
10
+ (`hydrationWindow`, which `cache.peek` reads), the block-id render pass
11
+ (`enterRenderPass`), and the two-phase seed consume (`SEED_MARKS`/`consumeSeed`,
12
+ ADR-0048): the seeds a pass adopts are only MARKED while it runs, deleted on the
13
+ OUTERMOST clean exit, and left in place on a throw — so the caller's
14
+ discard→cold-rebuild recovery re-adopts the SSR-resolved values instead of refetching
15
+ (a cold refetch would leave blocking `await` cells pending and escape as an uncaught
16
+ SuspenseSignal at mount).
17
+
18
+ Both entry points (`hydrate`, the router's hydrating first mount) run through here, so
19
+ the bracket order and the throw semantics can't drift between them. Nesting is safe:
20
+ the cursor saves/restores, the window/render-pass are depth-counted, and a nested
21
+ clean exit hands its marks to the enclosing pass rather than committing — if the outer
22
+ pass later throws, the nested pass's seeds are still in place for the recovery rebuild.
23
+ */
24
+ export function runHydrationPass<T>(run: () => T): T {
25
+ const previous = RENDER.hydration
26
+ const previousMarks = SEED_MARKS.current
27
+ RENDER.hydration = { next: new Map() }
28
+ SEED_MARKS.current = new Map()
29
+ try {
30
+ hydrationWindow.enter()
31
+ enterRenderPass()
32
+ try {
33
+ const result = run()
34
+ const marks = SEED_MARKS.current
35
+ if (previousMarks === undefined) {
36
+ /* Outermost clean pass: spend the adopted seeds, preserving the one-shot
37
+ contract (a later mount at the same render-path re-inits fresh, never a
38
+ stale snapshot). */
39
+ for (const [store, keys] of marks) {
40
+ for (const key of keys) {
41
+ delete store[key]
42
+ }
43
+ }
44
+ } else {
45
+ /* Nested clean pass: merge the marks upward — only the outermost owner
46
+ commits, so an outer throw still leaves every value in place. */
47
+ for (const [store, keys] of marks) {
48
+ let outerKeys = previousMarks.get(store)
49
+ if (outerKeys === undefined) {
50
+ outerKeys = new Set()
51
+ previousMarks.set(store, outerKeys)
52
+ }
53
+ for (const key of keys) {
54
+ outerKeys.add(key)
55
+ }
56
+ }
57
+ }
58
+ return result
59
+ } finally {
60
+ exitRenderPass()
61
+ RENDER.hydration = previous
62
+ /* Outermost exit clears the window and wakes the peeks this pass withheld for
63
+ SSR congruence, now that the warm value is congruent to show. */
64
+ hydrationWindow.exit()
65
+ }
66
+ } finally {
67
+ SEED_MARKS.current = previousMarks
68
+ }
69
+ }
@@ -52,7 +52,21 @@ export function runNode(node: ReactiveNode): unknown {
52
52
  }
53
53
  }
54
54
  node.status = NODE_STATE.CLEAN
55
+ node.thrown = undefined
55
56
  return node.value
57
+ } catch (error) {
58
+ /* The compute threw — commonly a `SuspenseSignal` from a pending blocking cell it read,
59
+ propagating the pause down this edge. Settle the node CLEAN anyway (its value is left
60
+ stale) so a later change to the deps it DID track before throwing re-marks it DIRTY and
61
+ re-propagates to its subscribers; left DIRTY, `mark`'s status gate would take the
62
+ settle's CLEAN→DIRTY edge as already-dirty and leave the node permanently inert — the
63
+ same reset `flushEffects` applies to a thrown effect. The throw is also RETAINED on the
64
+ node: a CLEAN settle memoises, so without it only this first reader would observe the
65
+ pause/error and every later reader would silently get the stale value — `readNode`
66
+ rethrows the retained throw until the next successful run clears it. */
67
+ node.status = NODE_STATE.CLEAN
68
+ node.thrown = error
69
+ throw error
56
70
  } finally {
57
71
  REACTIVE_CONTEXT.observer = previous
58
72
  /* Drop the edges `track` didn't reuse — last run's now-stale dependencies. */
@@ -0,0 +1,8 @@
1
+ /* The active hydration pass's claim cursor: per parent, the next server-rendered child
2
+ to claim — a node pointer (not an index) so it survives nodes a block inserts (anchors,
3
+ range brackets) mid-hydration. Read through `claimChild` (probe) / the claim verbs
4
+ (claim + advance); repositioned through `parkCursor`. The live instance hangs off
5
+ `RENDER.hydration` (undefined outside a pass). */
6
+ export type HydrationCursor = {
7
+ next: Map<Node, Node | null>
8
+ }
@@ -24,5 +24,10 @@ export type ReactiveNode = {
24
24
  NODE_STATE). A signal is always CLEAN (no compute); a computed is born DIRTY and
25
25
  cycles CLEAN→CHECK/DIRTY→CLEAN as deps change and reads settle it. */
26
26
  status: number
27
+ /* Throw memoisation: what the last compute threw (commonly a `SuspenseSignal`
28
+ pause), retained while the node sits CLEAN so EVERY reader observes the pause or
29
+ error — not just the one whose read ran the compute. Cleared by the next
30
+ successful run; undefined on a signal (no compute to throw). */
31
+ thrown: unknown
27
32
  isEffect: boolean
28
33
  }
@@ -10,15 +10,42 @@ earlier settled layer's cells. `Promise.allSettled` never rejects the render: a
10
10
  rejected cell settles into its `error()` state, surfaced at the read site (via
11
11
  `$$readCell`), not here. On the client the list is always empty (cells register
12
12
  server-side only), so the barrier is a no-op await.
13
+
14
+ It drains to a FIXPOINT — "await until this branch of the graph stops pausing" —
15
+ not a single snapshot. Pending propagates down dependency edges: a blocking cell
16
+ whose seed reads a still-pending blocking dependency stays pending and registers no
17
+ promise (its seed threw a `SuspenseSignal`, caught as pending, not error). When the
18
+ dependency settles inside this await, the reactive flush is synchronous, so the
19
+ dependent's seed re-runs and registers ITS promise before the loop re-checks — the
20
+ whole chain resolves in order. The per-render list isolation (`isolateCellBarrier`)
21
+ keeps the loop draining only this render's cascade, never a concurrent sibling's.
22
+
23
+ The round cap bounds a pathological feedback edge — a cell whose settle writes a
24
+ signal its own seed reads re-registers a fresh promise every round, and an
25
+ unbounded fixpoint would hang the SSR request forever. Legitimate chains resolve
26
+ one dependency level per round, so the cap is far above any real graph; hitting
27
+ it renders with whatever settled (the old snapshot-drain behavior) and warns.
13
28
  */
29
+ const MAX_SETTLE_ROUNDS = 1000
30
+
14
31
  // @documentation plumbing
15
32
  export async function settleAsyncCells(): Promise<void> {
16
33
  const pending = activePendingCells()
17
34
  if (pending === undefined) {
18
35
  return
19
36
  }
20
- const promises = pending.promises.splice(0)
21
- if (promises.length > 0) {
22
- await Promise.allSettled(promises)
37
+ let rounds = 0
38
+ while (pending.promises.length > 0) {
39
+ if (rounds >= MAX_SETTLE_ROUNDS) {
40
+ console.warn(
41
+ '[abide] settleAsyncCells did not reach a fixpoint after ' +
42
+ `${MAX_SETTLE_ROUNDS} rounds — an async cell is re-registering ` +
43
+ 'a promise on every settle (a seed that reads state its own ' +
44
+ 'resolution writes). Rendering with the values settled so far.',
45
+ )
46
+ break
47
+ }
48
+ rounds += 1
49
+ await Promise.allSettled(pending.promises.splice(0))
23
50
  }
24
51
  }
@@ -17,7 +17,6 @@ import { CELL_SEED } from './runtime/CELL_SEED.ts'
17
17
  import { clientPage } from './runtime/clientPage.ts'
18
18
  import { DOC_SEED } from './runtime/DOC_SEED.ts'
19
19
  import type { RouteLoader } from './runtime/types/RouteLoader.ts'
20
- import { warmSeedBackup } from './runtime/warmSeedBackup.ts'
21
20
  import { seedBootState } from './seedBootState.ts'
22
21
  import { seedStreamedResolution } from './seedStreamedResolution.ts'
23
22
  import { subscribeCacheStaleness } from './subscribeCacheStaleness.ts'
@@ -99,12 +98,6 @@ export function startClient(
99
98
  if (ssr.sockets !== undefined) {
100
99
  Object.assign(SOCKET_SEED, ssr.sockets)
101
100
  }
102
- /* Stash the pristine cell/doc manifests for the router's hydration-desync recovery: the hydration
103
- pass consumes CELL_SEED/DOC_SEED (each adopted key is deleted), so a discard→cold-rebuild would
104
- otherwise refetch every blocking cell — leaving it pending and throwing an uncaught
105
- SuspenseSignal at mount. `restoreWarmSeeds` re-seeds from these before the cold rebuild. */
106
- warmSeedBackup.cells = ssr.cells
107
- warmSeedBackup.docs = ssr.docs
108
101
  /* Keep the cache channel live past boot: replace the head's buffering collector with
109
102
  the store-connected sink so a post-boot resolution — the inline doc-stream cache
110
103
  script — seeds the store through `seedStreamedResolution` directly instead of pushing
@@ -1,20 +1,11 @@
1
- import { encodeRefJson } from '../shared/encodeRefJson.ts'
1
+ import { encodeSeedValue } from '../shared/encodeSeedValue.ts'
2
2
  import type { ResumeEntry } from './runtime/RESUME.ts'
3
3
 
4
- /* ref-json-encode an await-resume entry, or `undefined` if it can't be serialized.
5
- encodeRefJson is total (cycles become back-references, functions fold to undefined),
6
- but a pathological throw must not blank the surrounding seed/stream — drop just this
7
- entry and warn, so the client re-runs that one branch's promise while every other
4
+ /* ref-json-encode an await-resume entry, or `undefined` if it can't be serialized — the
5
+ await-resume arm of the shared serialize-or-degrade policy (`encodeSeedValue`): drop
6
+ just this entry, so the client re-runs that one branch's promise while every other
8
7
  branch stays seeded. Shared by the streaming (`renderToStream`) and buffered/seed
9
- (`resumeSeedScript`) paths so the serialize-or-refetch policy lives in one place. */
8
+ (`resumeSeedScript`) paths. */
10
9
  export function tryEncodeResume(entry: ResumeEntry, id: number | string): string | undefined {
11
- try {
12
- return encodeRefJson(entry)
13
- } catch (cause) {
14
- console.warn(
15
- `[abide] resume for await ${id} is not serializable; client will refetch it`,
16
- cause,
17
- )
18
- return undefined
19
- }
10
+ return encodeSeedValue(entry, `resume for await ${id}`, 'the client will refetch it')
20
11
  }
@@ -1,4 +1,5 @@
1
1
  import { cache } from '../shared/cache.ts'
2
+ import { isAsyncIterable } from '../shared/isAsyncIterable.ts'
2
3
  import { REMOTE_FUNCTION } from '../shared/REMOTE_FUNCTION.ts'
3
4
  import type { CacheOnContext } from '../shared/types/CacheOnContext.ts'
4
5
  import type { NamedAsyncIterable } from '../shared/types/NamedAsyncIterable.ts'
@@ -86,7 +87,7 @@ export function watch(
86
87
  const handler = argsOrHandler as (value: unknown, context?: CacheOnContext) => void
87
88
  /* A subscribable (socket / stream): per-frame delivery with reconnect-replay — the
88
89
  existing cache.on loop is the single implementation this branch delegates to. */
89
- if (isSubscribable(source)) {
90
+ if (isAsyncIterable(source)) {
90
91
  return cache.on(
91
92
  source as NamedAsyncIterable<unknown>,
92
93
  handler as (frame: unknown, context: CacheOnContext) => void | Promise<void>,
@@ -94,7 +95,7 @@ export function watch(
94
95
  }
95
96
  /* An rpc without args → run the smart read reactively, pipe its value to the handler. A
96
97
  RemoteFunction is a callable, so accept `function` too — a state cell is a branded-free
97
- object and never matches, and a streaming rpc was already caught by isSubscribable above. */
98
+ object and never matches, and a streaming rpc was already caught by isAsyncIterable above. */
98
99
  if (
99
100
  source !== null &&
100
101
  (typeof source === 'object' || typeof source === 'function') &&
@@ -121,15 +122,6 @@ export function watch(
121
122
  })
122
123
  }
123
124
 
124
- /* True for a socket / rpc stream — anything async-iterable. */
125
- function isSubscribable(source: unknown): boolean {
126
- return (
127
- source !== null &&
128
- (typeof source === 'object' || typeof source === 'function') &&
129
- typeof (source as { [Symbol.asyncIterator]?: unknown })[Symbol.asyncIterator] === 'function'
130
- )
131
- }
132
-
133
125
  /*
134
126
  Reacts to an rpc's cached value: an effect that runs the smart read (subscribing
135
127
  its key synchronously, so the effect re-runs when the value changes — a refresh,
@@ -1,84 +0,0 @@
1
- import { CURRENT_PATH } from '../runtime/CURRENT_PATH.ts'
2
- import { claimChild } from '../runtime/claimChild.ts'
3
- import { OWNER } from '../runtime/OWNER.ts'
4
- import { RANGE_OPEN } from '../runtime/RANGE_MARKER.ts'
5
- import { RENDER } from '../runtime/RENDER.ts'
6
- import type { UiComponent } from '../runtime/types/UiComponent.ts'
7
- import { withOptionalPath } from '../runtime/withOptionalPath.ts'
8
- import { withoutHydration } from '../runtime/withoutHydration.ts'
9
- import { discardBoundary } from './discardBoundary.ts'
10
- import { mountRange } from './mountRange.ts'
11
- import { openMarker } from './openMarker.ts'
12
-
13
- /*
14
- The client mount for a HOISTABLE child (ADR-0039) — a DUAL-MODE adopter. The server decides per
15
- render whether a hoistable child inlined (fast/settled) or streamed (still-pending), and the client
16
- build can't know which, so this probes the hydration cursor:
17
-
18
- - `<!--[-->` (RANGE_OPEN) next → the server INLINED the child; adopt its range exactly as
19
- `mountChild` does (byte-identical to the pre-ADR-0039 path).
20
- - `<!--abide:await:CHILDPATH-->` next → the server STREAMED it; `__abideSwap` has already spliced
21
- the child's `<!--[-->…<!--]-->` between the boundary markers before the bundle ran, so claim the
22
- boundary open, adopt the inner range in place, claim the boundary close.
23
- - no hydration (a client-side navigation) → a plain create-mode mount, like `mountChild`.
24
-
25
- CHILDPATH is the child's render-path — `withPath(ordinal, () => CURRENT_PATH.current)` — computed the
26
- same way the server's `renderPath(ordinal)` wrote the boundary id, so the two agree with no counter.
27
- A streamed child re-mounts (no RESUME value of its own — the boundary is html-only); nested `{#await}`
28
- blocks and async cells inside it adopt through their own existing channels.
29
- */
30
- // @documentation plumbing
31
- export function mountStreamedChild(
32
- parent: Node,
33
- factory: UiComponent,
34
- props: Parameters<UiComponent>[1],
35
- before: Node | null = null,
36
- ordinal?: number,
37
- ): void {
38
- const run = <T>(build: () => T): T => withOptionalPath(ordinal, build)
39
- const mount = (): { dispose: () => void } =>
40
- run(() => mountRange(parent, factory.build, props, before))
41
-
42
- const hydration = RENDER.hydration
43
- if (hydration === undefined) {
44
- /* Call mount() first, THEN register its dispose — `OWNER.current?.push(mount())` would
45
- short-circuit the whole expression (never mounting) when there is no owner. */
46
- const handle = mount()
47
- OWNER.current?.push(handle.dispose)
48
- return
49
- }
50
-
51
- /* Probe the cursor without consuming it. Streamed iff the next node is this child's boundary. */
52
- const childPath = run(() => CURRENT_PATH.current)
53
- const cursor = claimChild(hydration, parent)
54
- const streamed =
55
- cursor !== null &&
56
- cursor.nodeType === 8 &&
57
- (cursor as Comment).data === `abide:await:${childPath}`
58
-
59
- if (!streamed) {
60
- /* INLINE — the server inlined a settled child; adopt its `[ … ]` range in place. */
61
- const handle = mount()
62
- OWNER.current?.push(handle.dispose)
63
- return
64
- }
65
-
66
- /* STREAMED — claim the boundary open (advances the cursor to the swapped-in `[`). */
67
- const open = openMarker(parent, `abide:await:${childPath}`)
68
- const inner = claimChild(hydration, parent)
69
- const warm = inner !== null && inner.nodeType === 8 && (inner as Comment).data === RANGE_OPEN
70
- if (warm) {
71
- const handle = mount()
72
- openMarker(parent, `/abide:await:${childPath}`)
73
- OWNER.current?.push(handle.dispose)
74
- return
75
- }
76
- /* COLD (defensive — renderToStream drains every await before closing, so a warm boundary is the
77
- deterministic path). The fragment never arrived: discard the empty boundary and create-mount
78
- the child fresh at that position; its streamed cells resolve post-mount via receiveStreamedCell. */
79
- const after = discardBoundary(parent, open, `/abide:await:${childPath}`, hydration)
80
- const handle = withoutHydration(() =>
81
- run(() => mountRange(parent, factory.build, props, after)),
82
- )
83
- OWNER.current?.push(handle.dispose)
84
- }
@@ -1,29 +0,0 @@
1
- import { isAsyncCell } from '../../shared/isAsyncCell.ts'
2
- import type { AsyncComputed } from '../../shared/types/AsyncComputed.ts'
3
- import { SuspenseSignal } from '../runtime/SuspenseSignal.ts'
4
- import { readCell } from './readCell.ts'
5
-
6
- /*
7
- The blocking-cell read the compiler lowers to `$$readCellBlocking(NAME)` for an `await`-marked
8
- cell (ADR-0042 D5.3). Exactly `readCell`, with one added rule ahead of it: a cell with no value
9
- *yet* throws a `SuspenseSignal` so the enclosing region SUSPENDS until the value resolves rather
10
- than rendering against `undefined`. This is what makes an `await` binding read as a resolved `T`
11
- (never `undefined`-while-pending) on both sides — the client mirrors the server flush barrier.
12
-
13
- The suspend is keyed on `cell.pending()` (in-flight AND no value), NOT `peek() === undefined`:
14
- - a blocking cell that legitimately resolves to `undefined` is not pending, so it does not
15
- suspend forever;
16
- - a warm-seeded cell on hydrate is `refreshing()` (has value, in flight), not `pending()`, so it
17
- reads its held value and adopts the SSR markup with no suspend and no flash (ADR-0042 D4).
18
- Everything past the pending gate — the throwing peek (error-with-no-retained-value → `AsyncCellError`
19
- to `{#try}`, a retained value winning over pending/error via SWR), a function call, or a `.value`
20
- read — is `readCell` verbatim, so the two reads can never drift.
21
- */
22
- // @documentation plumbing
23
- export function readCellBlocking(cell: unknown): unknown {
24
- /* No value yet → suspend the reading region (distinct from an error, which `readCell` routes). */
25
- if (isAsyncCell(cell) && (cell as AsyncComputed<unknown>).pending()) {
26
- throw new SuspenseSignal(cell as AsyncComputed<unknown>)
27
- }
28
- return readCell(cell)
29
- }