@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,35 +1,50 @@
1
1
  import { isAsyncCell } from '../../shared/isAsyncCell.ts'
2
2
  import type { AsyncComputed } from '../../shared/types/AsyncComputed.ts'
3
3
  import { AsyncCellError } from '../runtime/AsyncCellError.ts'
4
+ import { SuspenseSignal } from '../runtime/SuspenseSignal.ts'
4
5
 
5
6
  /*
6
- Unified read for a `computed`/`linked` reference the compiler lowers to `$$readCell(NAME)`:
7
- an async cell (`AsyncComputed`/`AsyncState`) yields its latest retained value, a `derive`
8
- reader (a function) is called, and a sync `Computed`/`State` yields its `.value`. One read
9
- shape lets a `linked`/`computed` binding auto-track whichever source it resolved to — a
10
- settling promise, a stream, or a plain value — with no read-site branching in codegen.
7
+ The one read for a `computed`/`linked` reference the compiler lowers to `$$readCell(NAME)`,
8
+ everywhere — script and template, both sides. An async cell (`AsyncComputed`/`AsyncState`)
9
+ yields its latest retained value, a `derive` reader (a function) is called, and a sync
10
+ `Computed`/`State` yields its `.value`. One read shape lets a `linked`/`computed` binding
11
+ auto-track whichever source it resolved to — a settling promise, a stream, or a plain value —
12
+ with no read-site branching in codegen.
11
13
 
12
- For an async cell this is a **throwing peek** (ADR-0019 D3.2), the value-aware rule that
13
- routes a "nothing to render" error to the nearest `{#try}`:
14
+ Whether a read PAUSES is a property of the NODE, not the read site (ADR-0042). A pending
15
+ blocking cell (`cell.blocking` — author `await`, the one that joins the SSR barrier) throws
16
+ a `SuspenseSignal`: the reader's branch of the graph pauses until the value resolves, and
17
+ the throwing read subscribed the reader to the cell so it re-runs on settle. A pending
18
+ STREAMING cell peeks `undefined` (composes with `?.`/`??`). So a rendering region withholds,
19
+ a derive over it pauses too, and the SSR barrier awaits it — all off the same node bit,
20
+ with no per-site knowledge threaded through the compiler.
21
+
22
+ For a settled async cell this stays the value-aware **throwing peek** (ADR-0019 D3.2):
14
23
  - error AND no retained value → throw `AsyncCellError` (carries the cell for keep-the-watch);
15
24
  - error WITH a retained value → the value (a failed background refresh stays visible — SWR);
16
- - pending (no value) → `undefined`;
17
25
  - resolved → the value.
18
- The throw is a codegen behaviour only — `cell.peek()`/`cell.error()` in JS never throw; the
26
+ The throws are codegen behaviour only — `cell.peek()`/`cell.error()` in JS never throw; the
19
27
  author handles an error locally via the probes, or lets it reach a `{#try}`.
20
28
  */
21
29
  // @documentation plumbing
22
30
  export function readCell(cell: unknown): unknown {
23
31
  if (isAsyncCell(cell)) {
24
- const asyncCell = cell as AsyncComputed<unknown>
32
+ const asyncCell = cell as AsyncComputed<unknown> & { blocking?: boolean }
25
33
  const value = asyncCell.peek()
26
34
  /* A retained value wins over an error (SWR) and over pending — return it. Only when
27
- nothing is retained does an error become "nothing to render" and throw. */
35
+ nothing is retained does the cell's state decide: an error is "nothing to render"
36
+ (throw to `{#try}`); a pending blocking cell PAUSES (throw suspense); a pending
37
+ streaming cell — or a blocking cell that legitimately resolved to `undefined` — is
38
+ the bare `undefined`. `pending()` (in flight, no value) gates the suspend so a
39
+ resolved-`undefined` blocking cell never suspends forever. */
28
40
  if (value === undefined) {
29
41
  const error = asyncCell.error()
30
42
  if (error !== undefined) {
31
43
  throw new AsyncCellError(asyncCell, error)
32
44
  }
45
+ if (asyncCell.blocking === true && asyncCell.pending()) {
46
+ throw new SuspenseSignal(asyncCell)
47
+ }
33
48
  }
34
49
  return value
35
50
  }
@@ -1,6 +1,6 @@
1
1
  import { walkAnchorOrder } from '../compile/walkAnchorOrder.ts'
2
2
  import { walkElementOrder } from '../compile/walkElementOrder.ts'
3
- import { claimChild } from '../runtime/claimChild.ts'
3
+ import { claimRun } from '../runtime/claimRun.ts'
4
4
  import { HOLE_ATTRIBUTE } from '../runtime/HOLE_ATTRIBUTE.ts'
5
5
  import { RENDER } from '../runtime/RENDER.ts'
6
6
  import { commentData } from './commentData.ts'
@@ -121,12 +121,7 @@ export function skeleton(parent: Node, html: string): SkeletonHoles {
121
121
  const hydration = RENDER.hydration
122
122
  const topLevel: Node[] = []
123
123
  if (hydration !== undefined) {
124
- let node = claimChild(hydration, parent)
125
- for (let count = 0; count < topLevelCount && node !== null; count += 1) {
126
- topLevel.push(node)
127
- node = node.nextSibling
128
- }
129
- hydration.next.set(parent, node)
124
+ claimRun(hydration, parent, topLevelCount, topLevel)
130
125
  } else {
131
126
  const children = source.childNodes
132
127
  /* Stage clones in a fragment ONLY for a live (connected) parent, where one
@@ -11,7 +11,8 @@ to the default (`match` undefined); the chosen case's content lives in a RANGE
11
11
  bounded by two comment markers, so a case holds any content. Staying on the same
12
12
  case across a subject change leaves it mounted; switching clears the range and
13
13
  builds the new case fresh. See `mountSwappableRange` for the shared
14
- hydrate/swap/teardown semantics — the case index is the swap key.
14
+ hydrate/swap/teardown semantics — the case index is the swap key, which doubles as
15
+ the case's render-path segment (`caseSegment` — the alphabet SSR also emits).
15
16
  */
16
17
  // @documentation plumbing
17
18
  export function switchBlock(
@@ -1,17 +1,16 @@
1
1
  import { effect } from '../effect.ts'
2
2
  import { AsyncCellError } from '../runtime/AsyncCellError.ts'
3
3
  import { CURRENT_BOUNDARY } from '../runtime/CURRENT_BOUNDARY.ts'
4
- import { claimExpected } from '../runtime/claimExpected.ts'
4
+ import { claimMarker } from '../runtime/claimMarker.ts'
5
5
  import { OWNER } from '../runtime/OWNER.ts'
6
6
  import { RENDER } from '../runtime/RENDER.ts'
7
7
  import { SuspenseSignal } from '../runtime/SuspenseSignal.ts'
8
8
  import { scopeGroup } from '../runtime/scopeGroup.ts'
9
9
  import type { Boundary } from '../runtime/types/Boundary.ts'
10
10
  import type { State } from '../runtime/types/State.ts'
11
- import { withoutHydration } from '../runtime/withoutHydration.ts'
12
11
  import { state } from '../state.ts'
13
12
  import { anchoredBranch } from './anchoredBranch.ts'
14
- import { discardBoundary } from './discardBoundary.ts'
13
+ import { discardAndRebuild } from './discardAndRebuild.ts'
15
14
 
16
15
  /*
17
16
  Reactive error boundary — the runtime for `<template try>` (ADR-0019 D3). Unlike the old
@@ -191,9 +190,7 @@ export function tryBlock(
191
190
  firstHydrate()
192
191
  return
193
192
  }
194
- const anchorNode = document.createTextNode('')
195
- branch.anchor = anchorNode
196
- parent.insertBefore(anchorNode, before)
193
+ branch.parkAnchor(before)
197
194
  showTry()
198
195
 
199
196
  /* The first run when hydrating: claim the boundary's open marker, adopt the guarded
@@ -202,26 +199,25 @@ export function tryBlock(
202
199
  throws too) discards the boundary and builds the catch fresh. */
203
200
  function firstHydrate(): void {
204
201
  const cursor = hydration as NonNullable<typeof hydration>
205
- const open = claimExpected(cursor, parent, `abide:try:${id} open marker`)
202
+ const open = claimMarker(cursor, parent, `abide:try:${id} open marker`)
206
203
  try {
207
- adopt(open)
204
+ adopt()
208
205
  } catch (error) {
209
206
  rebuildCold(open, error)
210
207
  }
211
208
  }
212
209
 
213
- /* Adopt the guarded branch in place: claim the server's `[` range-open marker, build the
214
- guarded content (which claims its nodes) under the boundary, then claim the `]`
215
- range-close and the boundary close, parking an anchor so a later swap detaches this
216
- range like any other. A build throw disposes the partial scope and rethrows so
217
- `firstHydrate` rebuilds cold. */
218
- function adopt(open: Node): void {
210
+ /* Adopt the guarded branch in place — the cursor already sits past the boundary open
211
+ (claiming it advanced): claim the server's `[` range-open marker, build the guarded
212
+ content (which claims its nodes) under the boundary, then claim the `]` range-close
213
+ and the boundary close, parking an anchor so a later swap detaches this range like
214
+ any other. A build throw disposes the partial scope and rethrows so `firstHydrate`
215
+ rebuilds cold. */
216
+ function adopt(): void {
219
217
  const cursor = hydration as NonNullable<typeof hydration>
220
- cursor.next.set(parent, open.nextSibling ?? null)
221
218
  /* The server emits the `[ … ]` range inside the boundary — claim `[` as the live
222
- range start, then advance the cursor to the first content node. */
223
- const start = claimExpected(cursor, parent, `abide:try:${id} range-open marker`) as Comment
224
- cursor.next.set(parent, start.nextSibling ?? null)
219
+ range start, advancing the cursor to the first content node. */
220
+ const start = claimMarker(cursor, parent, `abide:try:${id} range-open marker`)
225
221
  /* Install the boundary around the whole adopt (build + marker claims); the shared
226
222
  strand-dispose guard in `adoptStrand` disposes the partial scope and rethrows on any
227
223
  failure, and this `finally` restores the boundary before the rethrow reaches
@@ -234,17 +230,9 @@ export function tryBlock(
234
230
  branch.adoptStrand(
235
231
  (host) => renderTry(host),
236
232
  () => {
237
- const end = claimExpected(
238
- cursor,
239
- parent,
240
- `abide:try:${id} range-close marker`,
241
- ) as Comment
242
- cursor.next.set(parent, end.nextSibling ?? null)
243
- const close = claimExpected(cursor, parent, `/abide:try:${id} close marker`)
244
- cursor.next.set(parent, close.nextSibling ?? null)
245
- const anchorNode = document.createTextNode('')
246
- parent.insertBefore(anchorNode, close)
247
- branch.anchor = anchorNode
233
+ const end = claimMarker(cursor, parent, `abide:try:${id} range-close marker`)
234
+ const close = claimMarker(cursor, parent, `/abide:try:${id} close marker`)
235
+ branch.parkAnchor(close)
248
236
  return { start, end }
249
237
  },
250
238
  )
@@ -258,18 +246,18 @@ export function tryBlock(
258
246
  No catch → rethrow (the throw surfaces past the boundary, as the sync version did). */
259
247
  function rebuildCold(open: Node, error: unknown): void {
260
248
  branch.detach()
261
- const after = discardBoundary(
249
+ discardAndRebuild(
250
+ hydration as NonNullable<typeof hydration>,
262
251
  parent,
263
252
  open,
264
253
  `/abide:try:${id}`,
265
- hydration as NonNullable<typeof hydration>,
254
+ (after) => {
255
+ if (!hasCatch) {
256
+ throw error
257
+ }
258
+ branch.parkAnchor(after)
259
+ showCatch(error)
260
+ },
266
261
  )
267
- if (!hasCatch) {
268
- throw error
269
- }
270
- const anchorNode = document.createTextNode('')
271
- branch.anchor = anchorNode
272
- parent.insertBefore(anchorNode, after)
273
- withoutHydration(() => showCatch(error))
274
262
  }
275
263
  }
@@ -1,3 +1,4 @@
1
+ import { ELSE_SEGMENT, THEN_SEGMENT } from '../runtime/BRANCH_SEGMENT.ts'
1
2
  import { SuspenseSignal } from '../runtime/SuspenseSignal.ts'
2
3
  import { mountSwappableRange } from './mountSwappableRange.ts'
3
4
 
@@ -37,7 +38,9 @@ export function when(
37
38
  block (render neither branch) until it settles, exactly as a bare async subject holds;
38
39
  the read tracked its cell, so the swap effect re-runs on resolve. */
39
40
  try {
40
- return condition() ? 'then' : 'else'
41
+ /* The branch key doubles as the branch's render-path segment (see
42
+ BRANCH_SEGMENT), so it is sourced from the shared alphabet SSR also emits. */
43
+ return condition() ? THEN_SEGMENT : ELSE_SEGMENT
41
44
  } catch (signal) {
42
45
  if (!(signal instanceof SuspenseSignal)) {
43
46
  throw signal
@@ -45,7 +48,8 @@ export function when(
45
48
  return 'pending'
46
49
  }
47
50
  },
48
- (branch) => (branch === 'then' ? render : branch === 'else' ? renderElse : undefined),
51
+ (branch) =>
52
+ branch === THEN_SEGMENT ? render : branch === ELSE_SEGMENT ? renderElse : undefined,
49
53
  before,
50
54
  /* An async subject (compiler supplies `isPending`) can render a different branch on the
51
55
  server than the pending client, so hydration must not adopt the SSR range in place. */
@@ -4,6 +4,8 @@ import type { NamedAsyncIterable } from '../shared/types/NamedAsyncIterable.ts'
4
4
  import { createAsyncCell } from './runtime/createAsyncCell.ts'
5
5
  import { createEffectNode } from './runtime/createEffectNode.ts'
6
6
  import { isAsyncFunction } from './runtime/isAsyncFunction.ts'
7
+ import { routeBareSeed } from './runtime/routeBareSeed.ts'
8
+ import { SuspenseSignal } from './runtime/SuspenseSignal.ts'
7
9
  import type { State } from './runtime/types/State.ts'
8
10
  import { state } from './state.ts'
9
11
 
@@ -41,6 +43,19 @@ export function linked<T>(
41
43
  transform?: (next: T, previous: T) => T,
42
44
  ): State<T> | AsyncState<T> {
43
45
  const coerce = transform as ((next: unknown, previous: unknown) => unknown) | undefined
46
+ /* A BARE seed that reached the runtime literally (a branch-nested `<script>`, a direct
47
+ JS caller) — the compile-time `wrapSeed` normalization only covers the leading script,
48
+ so honor the authored contract (ADR-0045) by VALUE via the shared `routeBareSeed`,
49
+ exactly as `computed` does; a non-async value becomes a constant seed thunk (reseeds
50
+ never fire — nothing tracked — matching the wrapped `() => value` form). */
51
+ if (typeof seed !== 'function') {
52
+ const value: unknown = seed
53
+ const cell = routeBareSeed(value, true, coerce)
54
+ if (cell !== undefined) {
55
+ return cell as AsyncState<T>
56
+ }
57
+ seed = () => value as T
58
+ }
44
59
  /* `await` marker: an async-function seed unwraps its promise into a writable async cell. */
45
60
  if (isAsyncFunction(seed)) {
46
61
  return createAsyncCell(seed as () => unknown, {
@@ -53,10 +68,26 @@ export function linked<T>(
53
68
  non-stream value falls through to the plain synchronous cell. */
54
69
  let probe: unknown
55
70
  let threw = false
71
+ let suspended = false
56
72
  try {
57
73
  probe = seed()
58
- } catch {
74
+ } catch (error) {
59
75
  threw = true
76
+ suspended = error instanceof SuspenseSignal
77
+ }
78
+ /* The seed SUSPENDED — it read a pending BLOCKING cell (`await`-marked). A sync `state`
79
+ here would hold `undefined` while the reseed effect re-threw the suspend, letting it
80
+ escape at construction. Route to the eager async cell instead: `createAsyncCell` already
81
+ treats a synchronously-suspending seed as "pending" — it stays in flight, the throwing
82
+ read subscribed its effect to the blocking dependency, and it re-runs on settle — and it
83
+ marks the cell BLOCKING so its own reads pause too, matching a lazy `computed(() =>
84
+ blockingCell)`. This makes a blocking read behave the same in a `linked` seed as in a
85
+ `computed` seed or a template. A real (non-suspense) throw keeps the sync fall-through. */
86
+ if (suspended) {
87
+ return createAsyncCell(seed as () => unknown, {
88
+ writable: true,
89
+ transform: coerce,
90
+ }) as AsyncState<T>
60
91
  }
61
92
  if (!threw && isAsyncIterable(probe)) {
62
93
  return createAsyncCell(seed as () => unknown, {
@@ -23,5 +23,5 @@ export function resumeSeedScript(resume: Record<string, ResumeEntry>): string {
23
23
  if (encoded.length === 0) {
24
24
  return ''
25
25
  }
26
- return `<script>Object.assign(window.__abideResume=window.__abideResume||{},${safeJsonForScript(Object.fromEntries(encoded))})</script>`
26
+ return `<script>Object.assign((window.__abideSeeds=window.__abideSeeds||{}).resume=window.__abideSeeds.resume||{},${safeJsonForScript(Object.fromEntries(encoded))})</script>`
27
27
  }
@@ -1,4 +1,3 @@
1
- import { hydrationWindow } from '../shared/hydrationWindow.ts'
2
1
  import { layoutChainForRoute } from '../shared/layoutChainForRoute.ts'
3
2
  import { matchRoute } from '../shared/matchRoute.ts'
4
3
  import { fillBoundary } from './dom/fillBoundary.ts'
@@ -7,12 +6,10 @@ import { effect } from './effect.ts'
7
6
  import { navigatePath } from './navigate.ts'
8
7
  import { CHILD_PRESENT } from './runtime/CHILD_PRESENT.ts'
9
8
  import { clientPage } from './runtime/clientPage.ts'
10
- import { enterRenderPass } from './runtime/enterRenderPass.ts'
11
- import { exitRenderPass } from './runtime/exitRenderPass.ts'
12
9
  import { historyEntries } from './runtime/historyEntries.ts'
13
10
  import { PENDING_OUTLET } from './runtime/PENDING_OUTLET.ts'
14
11
  import { RENDER } from './runtime/RENDER.ts'
15
- import { restoreWarmSeeds } from './runtime/restoreWarmSeeds.ts'
12
+ import { runHydrationPass } from './runtime/runHydrationPass.ts'
16
13
  import { runtimePath } from './runtime/runtimePath.ts'
17
14
  import type { AbideHistoryState } from './runtime/types/AbideHistoryState.ts'
18
15
  import type { NavVerdict } from './runtime/types/NavVerdict.ts'
@@ -258,19 +255,10 @@ export function router(
258
255
  mountedPageKey = pageKey
259
256
  }
260
257
  if (hydrating) {
261
- const previous = RENDER.hydration
262
- RENDER.hydration = { next: new Map() }
263
- hydrationWindow.enter()
264
- enterRenderPass()
265
- try {
266
- run()
267
- } finally {
268
- exitRenderPass()
269
- RENDER.hydration = previous
270
- /* Outermost exit clears the window and wakes the peeks this pass withheld
271
- for SSR congruence, now that the warm value is congruent to show. */
272
- hydrationWindow.exit()
273
- }
258
+ /* The pass lifecycle (claim cursor, withhold window, render pass, and the
259
+ warm-seed restore a throwing pass owes its cold rebuild) is owned by
260
+ `runHydrationPass` — the same bracket `hydrate` runs. */
261
+ runHydrationPass(run)
274
262
  return
275
263
  }
276
264
  run()
@@ -567,13 +555,13 @@ export function router(
567
555
  disposeFrom(0)
568
556
  rootBoundary = undefined
569
557
  mountedPageKey = undefined
570
- /* Re-seed the cell/doc warm partitions the failed hydration pass consumed,
571
- so the cold rebuild re-adopts the SSR-resolved values instead of
572
- refetching — a cold refetch leaves blocking `await` cells pending, and a
573
- top-level `$$readCellBlocking` sits in no suspense region, so its
574
- SuspenseSignal would escape this rebuild (it runs inside the catch, past
575
- the try) and kill the mount. Restored, the rebuild reads settled. */
576
- restoreWarmSeeds()
558
+ /* The cell/doc warm seeds the failed pass adopted are still in place —
559
+ a pass only MARKS them, deleting on a clean exit (`consumeSeed`,
560
+ ADR-0048) — so this cold rebuild re-adopts the SSR-resolved values
561
+ instead of refetching (a cold refetch would leave blocking `await`
562
+ cells pending, and a top-level `$$readCell` of one sits in no suspense
563
+ region — its SuspenseSignal would escape this rebuild and kill the
564
+ mount). */
577
565
  buildFrom(0, chainKeys, layoutViews, pageView, key, params, false)
578
566
  }
579
567
  /* Reapply the destination entry's scroll once its DOM exists — a
@@ -1,3 +1,4 @@
1
+ import { messageFromError } from '../../shared/messageFromError.ts'
1
2
  import type { AsyncComputed } from '../../shared/types/AsyncComputed.ts'
2
3
 
3
4
  /*
@@ -13,7 +14,7 @@ export class AsyncCellError extends Error {
13
14
  readonly cell: AsyncComputed<unknown>
14
15
 
15
16
  constructor(cell: AsyncComputed<unknown>, cause: unknown) {
16
- super(cause instanceof Error ? cause.message : String(cause), { cause })
17
+ super(messageFromError(cause), { cause })
17
18
  this.name = 'AsyncCellError'
18
19
  this.cell = cell
19
20
  }
@@ -0,0 +1,24 @@
1
+ /*
2
+ The render-path segment alphabet for control-flow branches — the one place the segment a
3
+ branch's content composes under is decided, read by BOTH sides (the same pattern
4
+ `RANGE_MARKER` uses for the wire markers, so a literal can never drift):
5
+
6
+ - the client runtimes (`when` returns THEN/ELSE as its swap key, `switchBlock` its case
7
+ index) hand the key to `mountSwappableRange`, which pushes `String(key)` as the branch's
8
+ path segment;
9
+ - the SSR back-end (`generateSSR`) emits the same segment around a branch's content
10
+ (`withPathBranch`), so a component/cell inside composes a byte-identical
11
+ serialization-stable id — the warm-seed key (ADR-0033).
12
+
13
+ A `when` keys its branches by name; a `switchBlock` (a `{#switch}`, or an `{#if}` chain with
14
+ any `{:elseif}`) keys each case by its index in the `[then, ...branches]` source order —
15
+ the default at its own position.
16
+ */
17
+ export const THEN_SEGMENT = 'then'
18
+ export const ELSE_SEGMENT = 'else'
19
+
20
+ /* A switchBlock case's segment: its source-order index, stringified exactly as
21
+ `mountSwappableRange` stringifies the numeric swap key. */
22
+ export function caseSegment(index: number): string {
23
+ return String(index)
24
+ }
@@ -1,3 +1,5 @@
1
+ import { SEEDS } from '../../shared/SEEDS.ts'
2
+
1
3
  /* The async-cell warm-seed manifest: an SSR-resolved cell value, keyed by the cell's
2
4
  serialization-stable render-path id (`${scope.id}:${index}`), as a ref-json-encoded STRING
3
5
  (decoded lazily at the read site in `createAsyncCell`, so an in-process graph with cycles /
@@ -5,10 +7,6 @@
5
7
  `startClient` drains `__SSR__.cells` into here before mount; a hydrating cell reads its key to
6
8
  seed its value warm instead of re-running the seed (no refetch, no pending flash).
7
9
 
8
- Backed by `globalThis.__abideCells` so an inline pre-bundle script and the framework share one
9
- store — whoever runs first creates it, the other adopts the same reference (mirrors RESUME). */
10
- const globalScope = globalThis as { __abideCells?: Record<string, string> }
11
- globalScope.__abideCells ??= {}
12
-
10
+ The `cells` partition of the one `__abideSeeds` manifest (ADR-0048, see SEEDS). */
13
11
  // @documentation plumbing
14
- export const CELL_SEED: Record<string, string> = globalScope.__abideCells
12
+ export const CELL_SEED: Record<string, string> = SEEDS.cells
@@ -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
+ }