@abide/abide 0.53.0 → 0.55.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 (129) hide show
  1. package/AGENTS.md +330 -494
  2. package/CHANGELOG.md +106 -0
  3. package/README.md +166 -146
  4. package/package.json +3 -4
  5. package/src/checkAbide.ts +1 -1
  6. package/src/lib/server/rpc/defineRpc.ts +2 -1
  7. package/src/lib/server/rpc/types/RpcHelper.ts +29 -22
  8. package/src/lib/server/runtime/SSR_SWAP_SCRIPT.ts +3 -2
  9. package/src/lib/server/runtime/amendBroadcaster.ts +53 -0
  10. package/src/lib/server/runtime/createUiPageRenderer.ts +54 -20
  11. package/src/lib/server/runtime/runWithRequestScope.ts +1 -0
  12. package/src/lib/server/runtime/types/RequestStore.ts +9 -0
  13. package/src/lib/server/sockets/amendFamilyEntry.ts +39 -0
  14. package/src/lib/server/sockets/createSocketDispatcher.ts +9 -0
  15. package/src/lib/server/sockets/defineSocket.ts +11 -1
  16. package/src/lib/shared/AMEND_TOPIC_PREFIX.ts +9 -0
  17. package/src/lib/shared/SEEDS.ts +33 -0
  18. package/src/lib/shared/SOCKET_SEED.ts +12 -0
  19. package/src/lib/shared/amend.ts +48 -0
  20. package/src/lib/shared/amendBroadcastSlot.ts +19 -0
  21. package/src/lib/shared/applyAmendLocally.ts +20 -0
  22. package/src/lib/shared/attachRpcSelectorMethods.ts +5 -5
  23. package/src/lib/shared/buildSocketOverChannel.ts +9 -2
  24. package/src/lib/shared/cache.ts +203 -84
  25. package/src/lib/shared/cacheReaderSocketSlot.ts +11 -0
  26. package/src/lib/shared/createCacheStore.ts +8 -0
  27. package/src/lib/shared/createRemoteFunction.ts +4 -1
  28. package/src/lib/shared/encodeSeedValue.ts +24 -0
  29. package/src/lib/shared/jsonSchemaForType.ts +3 -16
  30. package/src/lib/shared/peek.ts +4 -7
  31. package/src/lib/shared/socketTailsSlot.ts +13 -0
  32. package/src/lib/shared/types/AmendApply.ts +15 -0
  33. package/src/lib/shared/types/CacheReaderHook.ts +11 -0
  34. package/src/lib/shared/types/RemoteFunction.ts +30 -19
  35. package/src/lib/shared/types/SocketTails.ts +13 -0
  36. package/src/lib/shared/types/SsrPayload.ts +6 -0
  37. package/src/lib/test/createTestApp.ts +6 -0
  38. package/src/lib/ui/amendReaderHook.ts +125 -0
  39. package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +1 -6
  40. package/src/lib/ui/compile/analyzeComponent.ts +0 -2
  41. package/src/lib/ui/compile/awaitSubjectExpr.ts +23 -0
  42. package/src/lib/ui/compile/cachedShadowProgram.ts +39 -0
  43. package/src/lib/ui/compile/compileComponent.ts +1 -3
  44. package/src/lib/ui/compile/componentOrdinals.ts +39 -0
  45. package/src/lib/ui/compile/desugarSignals.ts +65 -36
  46. package/src/lib/ui/compile/generateBuild.ts +25 -24
  47. package/src/lib/ui/compile/generateSSR.ts +37 -24
  48. package/src/lib/ui/compile/hoistableAwaits.ts +1 -11
  49. package/src/lib/ui/compile/interpolationClassifierForRoot.ts +8 -13
  50. package/src/lib/ui/compile/isSpuriousAsyncReadDiagnostic.ts +46 -5
  51. package/src/lib/ui/compile/lowerCompoundAssignment.ts +49 -0
  52. package/src/lib/ui/compile/lowerContext.ts +26 -6
  53. package/src/lib/ui/compile/lowerDocAccess.ts +19 -31
  54. package/src/lib/ui/compile/lowerScript.ts +1 -11
  55. package/src/lib/ui/compile/lowerUpdateExpression.ts +32 -0
  56. package/src/lib/ui/compile/renameSignalRefs.ts +25 -45
  57. package/src/lib/ui/compile/seedTypeClassifierForRoot.ts +10 -15
  58. package/src/lib/ui/compile/shadowInterpolationClassifier.ts +1 -1
  59. package/src/lib/ui/compile/types/AnalyzedComponent.ts +3 -5
  60. package/src/lib/ui/compile/wrapReactionCellSources.ts +10 -6
  61. package/src/lib/ui/compile/wrapReactiveSeeds.ts +44 -0
  62. package/src/lib/ui/compile/wrapSeed.ts +28 -0
  63. package/src/lib/ui/computed.ts +16 -0
  64. package/src/lib/ui/createScope.ts +4 -3
  65. package/src/lib/ui/dom/anchorCursor.ts +2 -1
  66. package/src/lib/ui/dom/anchoredBranch.ts +14 -3
  67. package/src/lib/ui/dom/appendStatic.ts +11 -20
  68. package/src/lib/ui/dom/appendText.ts +41 -43
  69. package/src/lib/ui/dom/appendTextAt.ts +10 -57
  70. package/src/lib/ui/dom/awaitBlock.ts +31 -45
  71. package/src/lib/ui/dom/awaitSubject.ts +118 -0
  72. package/src/lib/ui/dom/claimText.ts +38 -0
  73. package/src/lib/ui/dom/cloneStatic.ts +2 -8
  74. package/src/lib/ui/dom/discardAndRebuild.ts +23 -0
  75. package/src/lib/ui/dom/discardBoundary.ts +5 -4
  76. package/src/lib/ui/dom/each.ts +3 -5
  77. package/src/lib/ui/dom/fillBoundary.ts +2 -1
  78. package/src/lib/ui/dom/hydrate.ts +5 -12
  79. package/src/lib/ui/dom/mountChild.ts +73 -5
  80. package/src/lib/ui/dom/mountSwappableRange.ts +2 -1
  81. package/src/lib/ui/dom/openMarker.ts +2 -4
  82. package/src/lib/ui/dom/outlet.ts +4 -3
  83. package/src/lib/ui/dom/readCell.ts +26 -11
  84. package/src/lib/ui/dom/skeleton.ts +2 -7
  85. package/src/lib/ui/dom/switchBlock.ts +2 -1
  86. package/src/lib/ui/dom/tryBlock.ts +27 -39
  87. package/src/lib/ui/dom/when.ts +6 -2
  88. package/src/lib/ui/linked.ts +14 -0
  89. package/src/lib/ui/resumeSeedScript.ts +1 -1
  90. package/src/lib/ui/router.ts +12 -16
  91. package/src/lib/ui/runtime/AsyncCellError.ts +2 -1
  92. package/src/lib/ui/runtime/BRANCH_SEGMENT.ts +24 -0
  93. package/src/lib/ui/runtime/CELL_SEED.ts +4 -6
  94. package/src/lib/ui/runtime/DOC_SEED.ts +5 -6
  95. package/src/lib/ui/runtime/RENDER.ts +7 -5
  96. package/src/lib/ui/runtime/RESUME.ts +5 -7
  97. package/src/lib/ui/runtime/SEED_MARKS.ts +14 -0
  98. package/src/lib/ui/runtime/SuspenseSignal.ts +2 -2
  99. package/src/lib/ui/runtime/claimChild.ts +5 -7
  100. package/src/lib/ui/runtime/claimExpected.ts +2 -6
  101. package/src/lib/ui/runtime/claimMarker.ts +13 -0
  102. package/src/lib/ui/runtime/claimRun.ts +22 -0
  103. package/src/lib/ui/runtime/consumeSeed.ts +38 -0
  104. package/src/lib/ui/runtime/createAsyncCell.ts +56 -8
  105. package/src/lib/ui/runtime/createComputedNode.ts +1 -0
  106. package/src/lib/ui/runtime/createDoc.ts +5 -1
  107. package/src/lib/ui/runtime/createEffectNode.ts +1 -0
  108. package/src/lib/ui/runtime/createSignalNode.ts +1 -0
  109. package/src/lib/ui/runtime/parkCursor.ts +10 -0
  110. package/src/lib/ui/runtime/readNode.ts +21 -1
  111. package/src/lib/ui/runtime/renderPath.ts +2 -2
  112. package/src/lib/ui/runtime/routeBareSeed.ts +28 -0
  113. package/src/lib/ui/runtime/runHydrationPass.ts +69 -0
  114. package/src/lib/ui/runtime/runNode.ts +14 -0
  115. package/src/lib/ui/runtime/types/Cell.ts +4 -2
  116. package/src/lib/ui/runtime/types/Doc.ts +3 -1
  117. package/src/lib/ui/runtime/types/HydrationCursor.ts +8 -0
  118. package/src/lib/ui/runtime/types/ReactiveNode.ts +5 -0
  119. package/src/lib/ui/settleAsyncCells.ts +30 -3
  120. package/src/lib/ui/socketProxy.ts +17 -1
  121. package/src/lib/ui/startClient.ts +23 -0
  122. package/src/lib/ui/tryEncodeResume.ts +6 -15
  123. package/src/lib/ui/types/Scope.ts +1 -1
  124. package/src/lib/ui/watch.ts +21 -16
  125. package/src/serverEntry.ts +15 -0
  126. package/src/lib/shared/patch.ts +0 -41
  127. package/src/lib/ui/compile/COMPOUND_ASSIGNMENT_OPERATORS.ts +0 -19
  128. package/src/lib/ui/dom/mountStreamedChild.ts +0 -84
  129. package/src/lib/ui/dom/readCellBlocking.ts +0 -29
@@ -1,11 +1,10 @@
1
+ import { SEEDS } from '../../shared/SEEDS.ts'
2
+
1
3
  /* The doc-state warm-seed manifest: an SSR-captured reactive-document snapshot, keyed by its
2
4
  scope's serialization-stable render-path id, as a ref-json-encoded STRING (decoded at the read
3
5
  site in `createScope`). `startClient` drains `__SSR__.docs` into here before mount; a hydrating
4
6
  scope reads its key to seed a plain `state(initial)` to the SERVER value instead of the fresh
5
- init — a uuid/timestamp/random otherwise diverges from the SSR HTML. Backed by
6
- `globalThis.__abideDocs` so an inline pre-bundle script and the framework share one store —
7
- whoever runs first creates it, the other adopts the same reference (mirrors CELL_SEED). */
8
- const globalScope = globalThis as { __abideDocs?: Record<string, string> }
9
- globalScope.__abideDocs ??= {}
7
+ init — a uuid/timestamp/random otherwise diverges from the SSR HTML.
10
8
 
11
- export const DOC_SEED: Record<string, string> = globalScope.__abideDocs
9
+ The `docs` partition of the one `__abideSeeds` manifest (ADR-0048, see SEEDS). */
10
+ export const DOC_SEED: Record<string, string> = SEEDS.docs
@@ -1,9 +1,11 @@
1
+ import type { HydrationCursor } from './types/HydrationCursor.ts'
2
+
1
3
  /*
2
4
  Render mode shared by the dom helpers. In the default (create) mode `hydration`
3
- is undefined and helpers build fresh nodes. During `hydrate` it holds, per parent,
4
- the next server-rendered child node to claim — a node pointer (not an index) so it
5
- survives nodes a block inserts (anchors) mid-hydration. Helpers claim in build
6
- order, which matches the SSR order, advancing the pointer to the next sibling.
5
+ is undefined and helpers build fresh nodes. During `hydrate` it holds the pass's
6
+ claim cursor (see `HydrationCursor`): helpers claim in build order, which matches
7
+ the SSR order, advancing through the claim verbs (`claimMarker`/`claimRun`/
8
+ `claimText`) or parking explicitly (`parkCursor`).
7
9
 
8
10
  `blockCounters`/`depth` drive the render-pass block-id counters: every `await`/`try`
9
11
  block draws a path-namespaced id via `nextBlockId` (ADR-0037) — `blockCounters` maps
@@ -21,7 +23,7 @@ elements built there get the right namespace — the fragment itself carries non
21
23
  undefined outside foreign content. See `enterNamespace`/`effectiveChildNamespace`.
22
24
  */
23
25
  export const RENDER: {
24
- hydration: { next: Map<Node, Node | null> } | undefined
26
+ hydration: HydrationCursor | undefined
25
27
  blockCounters: Map<string, number>
26
28
  depth: number
27
29
  namespace: string | undefined
@@ -1,3 +1,5 @@
1
+ import { SEEDS } from '../../shared/SEEDS.ts'
2
+
1
3
  /* The await-resume manifest: the resolved value (or error) of each streamed
2
4
  `await` block, keyed by its boundary id. The SSR stream serializes each entry
3
5
  alongside its fragment; the client registers it (via the inline swap script),
@@ -11,13 +13,9 @@
11
13
  stream-swap script — vanilla, running before the bundle and the codec load — able
12
14
  to register an entry without the decoder.
13
15
 
14
- Backed by `globalThis.__abideResume` so the inline stream-swap script and the
15
- framework share one store: whoever runs first creates it, the other adopts the
16
- same reference. */
16
+ The `resume` partition of the one `__abideSeeds` manifest (ADR-0048, see SEEDS) —
17
+ the inline scripts and the framework share the same reference. */
17
18
  export type ResumeEntry = { ok: true; value: unknown } | { ok: false; error: unknown }
18
19
 
19
- const globalScope = globalThis as { __abideResume?: Record<string, string> }
20
- globalScope.__abideResume ??= {}
21
-
22
20
  // @documentation plumbing
23
- export const RESUME: Record<string, string> = globalScope.__abideResume
21
+ export const RESUME: Record<string, string> = SEEDS.resume
@@ -0,0 +1,14 @@
1
+ /* The active hydration pass's consumed-seed marks (ADR-0048 two-phase consume): each seed a
2
+ pass adopts is MARKED here rather than deleted, so a THROWING pass leaves every value in
3
+ place for the cold rebuild to re-adopt (no shadow backup), while the OUTERMOST clean pass
4
+ exit deletes the marked keys — preserving the one-shot contract (an SPA re-nav to the same
5
+ render-path re-inits fresh). Keyed per store so a mark is an O(1) Set add on the hydrate
6
+ hot path. Owned by `runHydrationPass`: save/restore for nesting, and a nested clean exit
7
+ hands its marks to the enclosing pass instead of committing — only the outermost owner
8
+ spends seeds, so an outer-pass throw can still hand every value to the recovery rebuild.
9
+ undefined outside a pass, where `consumeSeed` falls back to delete-on-read. Deliberately
10
+ NOT cleared by `withoutHydration` — a fresh build inside a live pass (a block's cold
11
+ rebuild) still marks and re-adopts. */
12
+ export const SEED_MARKS: {
13
+ current: Map<Record<string, string>, Set<string>> | undefined
14
+ } = { current: undefined }
@@ -1,8 +1,8 @@
1
1
  import type { AsyncComputed } from '../../shared/types/AsyncComputed.ts'
2
2
 
3
3
  /*
4
- The sentinel a compiled blocking read (`$$readCellBlocking`, an `await`-marked cell) raises
5
- when the cell has no value *yet* — "not resolved," not an error (ADR-0042 D3). It is a sibling
4
+ The sentinel `readCell` raises when a BLOCKING cell (`cell.blocking` — an `await`-marked cell)
5
+ has no value *yet* — "not resolved," not an error (ADR-0042 D3). It is a sibling
6
6
  of `AsyncCellError` but a DISTINCT class so the two are told apart by `instanceof`: a suspend is
7
7
  caught LOCALLY by the reading region — each DOM primitive (`appendText`, `attr`, `each`,
8
8
  `spreadAttrs`, `watch`, and the `when`/`switchBlock` condition) swallows it and withholds to an
@@ -1,10 +1,8 @@
1
- import type { RENDER } from './RENDER.ts'
1
+ import type { HydrationCursor } from './types/HydrationCursor.ts'
2
2
 
3
- /* The next server-rendered node to claim under `parent` during hydration,
4
- defaulting to its first child when the pointer hasn't been set yet. */
5
- export function claimChild(
6
- hydration: NonNullable<(typeof RENDER)['hydration']>,
7
- parent: Node,
8
- ): Node | null {
3
+ /* The next server-rendered node to claim under `parent` during hydration — a PROBE
4
+ (the cursor doesn't move), defaulting to the first child when the pointer hasn't
5
+ been set yet. The claim verbs read through this and advance via `parkCursor`. */
6
+ export function claimChild(hydration: HydrationCursor, parent: Node): Node | null {
9
7
  return hydration.next.has(parent) ? (hydration.next.get(parent) ?? null) : parent.firstChild
10
8
  }
@@ -1,7 +1,7 @@
1
1
  import { CURRENT_PATH } from './CURRENT_PATH.ts'
2
2
  import { claimChild } from './claimChild.ts'
3
- import type { RENDER } from './RENDER.ts'
4
3
  import { reportHydrationDivergence } from './reportHydrationDivergence.ts'
4
+ import type { HydrationCursor } from './types/HydrationCursor.ts'
5
5
 
6
6
  /*
7
7
  A hydration claim that MUST succeed. Like `claimChild`, but throws a structural error
@@ -13,11 +13,7 @@ invariant from a compile-time-only parity test into a runtime guard. Use only wh
13
13
  compiled skeleton GUARANTEES a node — control-flow range markers, keyed-row boundaries —
14
14
  never where an end-of-list null is legitimate (e.g. a trailing reactive text binding).
15
15
  */
16
- export function claimExpected(
17
- hydration: NonNullable<(typeof RENDER)['hydration']>,
18
- parent: Node,
19
- expected: string,
20
- ): Node {
16
+ export function claimExpected(hydration: HydrationCursor, parent: Node, expected: string): Node {
21
17
  const node = claimChild(hydration, parent)
22
18
  if (node === null) {
23
19
  /* Name where first on the `hydrate` channel (return ignored — a missing structural node
@@ -0,0 +1,13 @@
1
+ import { claimExpected } from './claimExpected.ts'
2
+ import { parkCursor } from './parkCursor.ts'
3
+ import type { HydrationCursor } from './types/HydrationCursor.ts'
4
+
5
+ /* Claims a compiler-guaranteed marker node (a range bracket, a block/boundary comment,
6
+ a keyed-row bracket) at the parent's cursor and advances past it. Throws a legible
7
+ structural desync when no node is there (`claimExpected`) — a missing marker means
8
+ SSR markup and the client build disagree on structure at this position. */
9
+ export function claimMarker(hydration: HydrationCursor, parent: Node, expected: string): Comment {
10
+ const node = claimExpected(hydration, parent, expected)
11
+ parkCursor(hydration, parent, node.nextSibling)
12
+ return node as Comment
13
+ }
@@ -0,0 +1,22 @@
1
+ import { claimChild } from './claimChild.ts'
2
+ import { parkCursor } from './parkCursor.ts'
3
+ import type { HydrationCursor } from './types/HydrationCursor.ts'
4
+
5
+ /* Claims a run of `count` top-level server-rendered siblings at the parent's cursor and
6
+ parks it past them (a short run — the server DOM ending early — parks on null). The
7
+ static-clone paths advance through this: the markup is byte-identical to the server's,
8
+ so nothing inside needs claiming individually. `into` collects the claimed nodes when
9
+ the caller resolves holes against them (`skeleton`); omitted, the run is only skipped. */
10
+ export function claimRun(
11
+ hydration: HydrationCursor,
12
+ parent: Node,
13
+ count: number,
14
+ into?: Node[],
15
+ ): void {
16
+ let node = claimChild(hydration, parent)
17
+ for (let index = 0; index < count && node !== null; index += 1) {
18
+ into?.push(node)
19
+ node = node.nextSibling
20
+ }
21
+ parkCursor(hydration, parent, node)
22
+ }
@@ -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,35 @@ 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
+ if (myRun === runId) {
214
+ inFlight = undefined
215
+ writeNode(errorNode, undefined)
216
+ }
217
+ return
218
+ }
184
219
  settleError(myRun, error)
185
220
  return
186
221
  }
187
222
 
188
223
  if (isAsyncIterable(produced)) {
224
+ /* A stream never settles — it PEEKS `undefined` until its first frame, never pauses. */
225
+ blockingSource = false
189
226
  consumeStream(myRun, produced as NamedAsyncIterable<unknown>)
190
227
  return
191
228
  }
192
229
  if (isThenable(produced)) {
230
+ /* A settling promise the cell would join the SSR barrier for (author `await`, or a
231
+ fail-open bare-call) PAUSES a pending read; a `streaming` promise peeks. */
232
+ blockingSource = options.streaming !== true
193
233
  inFlight = produced as Promise<unknown>
194
234
  /* Server-only: register the in-flight promise on the request-scoped pending list so
195
235
  the SSR barrier (`$$settleAsyncCells`) awaits it before the template peeks the
@@ -222,15 +262,15 @@ export function createAsyncCell(
222
262
  only in effect (the store is only ever populated by `startClient`, so it's empty on the
223
263
  server — no `window` sniff needed); a decode failure falls through to a cold run. */
224
264
  if (warmKey !== undefined) {
225
- const seeded = CELL_SEED[warmKey]
265
+ /* One-shot via `consumeSeed` (two-phase, ADR-0048): a LATER fresh mount at the same
266
+ render-path can't warm-adopt this boot-time snapshot. `scope.id` is the route PATTERN
267
+ (params-independent), so an SPA navigation `/products/42`→`/products/99` — or a
268
+ back-nav that rebuilds the SSR page — recomputes the identical warmKey; unconsumed,
269
+ the new cell would render `/products/42`'s stale value until revalidation. During a
270
+ hydration pass the consume only MARKS, so a desync throw hands the seed to the cold
271
+ rebuild instead of losing it. */
272
+ const seeded = consumeSeed(CELL_SEED, warmKey)
226
273
  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
274
  try {
235
275
  acceptValue(decodeRefJson(seeded))
236
276
  } catch {
@@ -279,6 +319,14 @@ export function createAsyncCell(
279
319
  /* The current in-flight promise (undefined once settled / not-a-promise) — an internal
280
320
  runtime affordance the SSR barrier reads structurally; not on the public cell types. */
281
321
  settled: () => inFlight,
322
+ /* Whether a pending read of this cell PAUSES its reader (ADR-0046) — true only for a
323
+ blocking source (a barrier-joining settling promise), resolved from what the seed
324
+ produced (`blockingSource`) so a stream (which shares the default `streaming` flag)
325
+ is correctly NON-blocking. A getter, since the seed's eager run sets it. `readCell`
326
+ consults it; an internal affordance, not on the public cell type. */
327
+ get blocking(): boolean {
328
+ return blockingSource
329
+ },
282
330
  } as AsyncComputed<unknown>
283
331
  if (!options.writable) {
284
332
  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
  }
@@ -312,6 +312,7 @@ export function createDoc(initial: unknown): Doc {
312
312
  parent ??= resolveParent()
313
313
  parent[leafKey] = value
314
314
  writeNode(node, value)
315
+ return value
315
316
  },
316
317
  }
317
318
  }
@@ -321,7 +322,10 @@ export function createDoc(initial: unknown): Doc {
321
322
  cell,
322
323
  derive,
323
324
  apply,
324
- replace: (path, value) => apply({ op: 'replace', path, value }),
325
+ replace: (path, value) => {
326
+ apply({ op: 'replace', path, value })
327
+ return value
328
+ },
325
329
  add: (path, value) => apply({ op: 'add', path, value }),
326
330
  remove: (path) => apply({ op: 'remove', path }),
327
331
  snapshot: () => tree,
@@ -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. */
@@ -1,5 +1,7 @@
1
1
  /* A stable reactive accessor bound to one document path: `get` subscribes the
2
2
  running observer, `set` mutates that path and wakes its readers — all path
3
3
  resolution done once at bind time, so the access itself is string-free. This
4
- is the shape the template compiler emits for a `{path}` read / write. */
5
- export type Cell<T> = { get: () => T; set: (value: T) => void }
4
+ is the shape the template compiler emits for a `{path}` read / write. `set`
5
+ returns the written value so a lowered assignment evaluates to that value in
6
+ expression position (chained `a = b = x`, the postfix `x++` correction). */
7
+ export type Cell<T> = { get: () => T; set: (value: T) => T }
@@ -15,7 +15,9 @@ export type Doc = {
15
15
  stored/serialized/journalled) and returns a string-free reader bound to it. */
16
16
  derive: <T>(path: string, compute: () => T) => () => T
17
17
  apply: (patch: Patch) => void
18
- replace: (path: string, value: unknown) => void
18
+ /* Returns the written value so a lowered assignment/`++` evaluates to it in
19
+ expression position (mirrored by `Scope.replace` and `Cell.set`). */
20
+ replace: (path: string, value: unknown) => unknown
19
21
  add: (path: string, value: unknown) => void
20
22
  remove: (path: string) => void
21
23
  snapshot: () => unknown
@@ -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
  }