@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,7 +1,7 @@
1
1
  import { effect } from '../effect.ts'
2
2
  import { CURRENT_PATH } from '../runtime/CURRENT_PATH.ts'
3
3
  import { claimChild } from '../runtime/claimChild.ts'
4
- import { claimExpected } from '../runtime/claimExpected.ts'
4
+ import { claimMarker } from '../runtime/claimMarker.ts'
5
5
  import { RENDER } from '../runtime/RENDER.ts'
6
6
  import { scope } from '../runtime/scope.ts'
7
7
  import { scopeGroup } from '../runtime/scopeGroup.ts'
@@ -82,8 +82,7 @@ export function each<T>(
82
82
  const segment = keyed ? keyOf(item) : position
83
83
  const hydration = RENDER.hydration
84
84
  if (hydration !== undefined) {
85
- const start = claimExpected(hydration, parent, 'each row start marker')
86
- hydration.next.set(parent, start.nextSibling)
85
+ const start = claimMarker(hydration, parent, 'each row start marker')
87
86
  const dispose = group.track(
88
87
  scope(() =>
89
88
  withPathFrom(basePath, segment, () =>
@@ -91,8 +90,7 @@ export function each<T>(
91
90
  ),
92
91
  ),
93
92
  )
94
- const end = claimExpected(hydration, parent, 'each row end marker')
95
- hydration.next.set(parent, end.nextSibling)
93
+ const end = claimMarker(hydration, parent, 'each row end marker')
96
94
  return { start, end, dispose, cell, indexCell }
97
95
  }
98
96
  /* Shared detached-range create primitive: a `[ … ]` fragment built under `parent`'s
@@ -1,3 +1,4 @@
1
+ import { parkCursor } from '../runtime/parkCursor.ts'
1
2
  import { RENDER } from '../runtime/RENDER.ts'
2
3
  import { scope } from '../runtime/scope.ts'
3
4
  import type { UiProps } from '../runtime/types/UiProps.ts'
@@ -31,7 +32,7 @@ export function fillBoundary(
31
32
  }
32
33
  /* Hydrate: adopt the server content between the markers in place. */
33
34
  const parent = open.parentNode as Node
34
- hydration.next.set(parent, open.nextSibling)
35
+ parkCursor(hydration, parent, open.nextSibling)
35
36
  const scoped = withScope(() => scope(() => build(parent, props)))
36
37
  return { dispose: disposeRange(scoped, open, close) }
37
38
  }
@@ -1,5 +1,4 @@
1
- import { hydrationWindow } from '../../shared/hydrationWindow.ts'
2
- import { RENDER } from '../runtime/RENDER.ts'
1
+ import { runHydrationPass } from '../runtime/runHydrationPass.ts'
3
2
  import { scope } from '../runtime/scope.ts'
4
3
  import { withScope } from './withScope.ts'
5
4
 
@@ -24,18 +23,12 @@ export function hydrate(
24
23
  build: (host: Element, props: unknown) => void,
25
24
  props?: unknown,
26
25
  ): () => void {
27
- const previous = RENDER.hydration
28
- RENDER.hydration = { next: new Map() }
29
- hydrationWindow.enter()
30
- try {
26
+ /* The pass lifecycle (claim cursor, withhold window, render pass, restore-on-throw)
27
+ is owned by `runHydrationPass` — the same bracket the router's hydrating mount runs. */
28
+ return runHydrationPass(() => {
31
29
  /* Same shared mount core as `mount` (see `withScope`) — a hydrated component owns a
32
30
  scope too, adopting the model its build adopts — run with the claim cursor active. */
33
31
  const { lexical } = withScope(() => scope(() => build(host, props)))
34
32
  return () => lexical.dispose()
35
- } finally {
36
- RENDER.hydration = previous
37
- /* Outermost exit clears the window and wakes the peeks this pass withheld; a nested
38
- child hydrate raises/lowers the depth without firing it early. */
39
- hydrationWindow.exit()
40
- }
33
+ })
41
34
  }
@@ -1,13 +1,37 @@
1
+ import { CURRENT_PATH } from '../runtime/CURRENT_PATH.ts'
2
+ import { claimChild } from '../runtime/claimChild.ts'
1
3
  import { OWNER } from '../runtime/OWNER.ts'
4
+ import { RANGE_OPEN } from '../runtime/RANGE_MARKER.ts'
5
+ import { RENDER } from '../runtime/RENDER.ts'
2
6
  import type { UiComponent } from '../runtime/types/UiComponent.ts'
3
7
  import { withOptionalPath } from '../runtime/withOptionalPath.ts'
8
+ import { commentData } from './commentData.ts'
9
+ import { discardAndRebuild } from './discardAndRebuild.ts'
4
10
  import { mountRange } from './mountRange.ts'
11
+ import { openMarker } from './openMarker.ts'
5
12
 
6
13
  /*
7
- Mounts a child component as a marker-bounded range at `before` in `parent` — no
8
- wrapper element, so the child's root is a true direct child of the parent (see
9
- `mountRange`). Files the range's dispose with the mounting owner so its scope and DOM
10
- leave together when the parent (or branch/row) tears down.
14
+ The client mount for a child component — a DUAL-MODE adopter, and the ONLY child mount the
15
+ compiler emits. A child mounts as a marker-bounded range at `before` in `parent` (no wrapper
16
+ element, so the child's root is a true direct child — see `mountRange`), its dispose filed
17
+ with the mounting owner so scope and DOM leave together on teardown.
18
+
19
+ The server decides per render whether a HOISTABLE child (ADR-0039) inlined (fast/settled) or
20
+ streamed (still-pending), and the client build can't know which — nor whether this child was
21
+ hoistable at all — so hydrate mode probes the cursor:
22
+
23
+ - `<!--[-->` (RANGE_OPEN) next → the server INLINED the child (every non-hoistable child,
24
+ and a hoistable one whose flight settled); adopt its range in place.
25
+ - `<!--abide:await:CHILDPATH-->` next → the server STREAMED it; `__abideSwap` has already
26
+ spliced the child's `<!--[-->…<!--]-->` between the boundary markers before the bundle
27
+ ran, so claim the boundary open, adopt the inner range in place, claim the boundary close.
28
+ - no hydration (a client-side navigation) → a plain create-mode mount.
29
+
30
+ CHILDPATH is the child's render-path — `withPath(ordinal, () => CURRENT_PATH.current)` —
31
+ computed the same way the server's `renderPath(ordinal)` wrote the boundary id, so the two
32
+ agree with no counter. A streamed child re-mounts (no RESUME value of its own — the boundary
33
+ is html-only); nested `{#await}` blocks and async cells inside it adopt through their own
34
+ existing channels.
11
35
  */
12
36
  // @documentation plumbing
13
37
  export function mountChild(
@@ -21,6 +45,50 @@ export function mountChild(
21
45
  row key the each block already pushed). Absent (a non-compiled caller) → no path segment. */
22
46
  ordinal?: number,
23
47
  ): void {
24
- const handle = withOptionalPath(ordinal, () => mountRange(parent, factory.build, props, before))
48
+ const run = <T>(build: () => T): T => withOptionalPath(ordinal, build)
49
+ const mount = (): { dispose: () => void } =>
50
+ run(() => mountRange(parent, factory.build, props, before))
51
+
52
+ const hydration = RENDER.hydration
53
+ if (hydration === undefined) {
54
+ /* Call mount() first, THEN register its dispose — `OWNER.current?.push(mount())` would
55
+ short-circuit the whole expression (never mounting) when there is no owner. */
56
+ const handle = mount()
57
+ OWNER.current?.push(handle.dispose)
58
+ return
59
+ }
60
+
61
+ /* Probe the cursor without consuming it. Streamed iff the next node is this child's boundary. */
62
+ const childPath = run(() => CURRENT_PATH.current)
63
+ const cursor = claimChild(hydration, parent)
64
+ const streamed = cursor !== null && commentData(cursor) === `abide:await:${childPath}`
65
+
66
+ if (!streamed) {
67
+ /* INLINE — the server inlined the child's `[ … ]` range; adopt it in place. */
68
+ const handle = mount()
69
+ OWNER.current?.push(handle.dispose)
70
+ return
71
+ }
72
+
73
+ /* STREAMED — claim the boundary open (advances the cursor to the swapped-in `[`). */
74
+ const open = openMarker(parent, `abide:await:${childPath}`)
75
+ const inner = claimChild(hydration, parent)
76
+ const warm = inner !== null && commentData(inner) === RANGE_OPEN
77
+ if (warm) {
78
+ const handle = mount()
79
+ openMarker(parent, `/abide:await:${childPath}`)
80
+ OWNER.current?.push(handle.dispose)
81
+ return
82
+ }
83
+ /* COLD (defensive — renderToStream drains every await before closing, so a warm boundary is the
84
+ deterministic path). The fragment never arrived: discard the empty boundary and create-mount
85
+ the child fresh at that position; its streamed cells resolve post-mount via receiveStreamedCell. */
86
+ const handle = discardAndRebuild(
87
+ hydration,
88
+ parent,
89
+ open,
90
+ `/abide:await:${childPath}`,
91
+ (after) => run(() => mountRange(parent, factory.build, props, after)),
92
+ )
25
93
  OWNER.current?.push(handle.dispose)
26
94
  }
@@ -1,5 +1,6 @@
1
1
  import { effect } from '../effect.ts'
2
2
  import { CURRENT_PATH } from '../runtime/CURRENT_PATH.ts'
3
+ import { parkCursor } from '../runtime/parkCursor.ts'
3
4
  import { RANGE_CLOSE, RANGE_OPEN } from '../runtime/RANGE_MARKER.ts'
4
5
  import { RENDER } from '../runtime/RENDER.ts'
5
6
  import { scope } from '../runtime/scope.ts'
@@ -82,7 +83,7 @@ export function mountSwappableRange<Key>(
82
83
  const chosen = chosenFor(activeKey)
83
84
  const matchedEnd = matchingRangeClose(start)
84
85
  clearBetween(start, matchedEnd)
85
- hydration.next.set(parent, matchedEnd)
86
+ parkCursor(hydration, parent, matchedEnd)
86
87
  end = openMarker(parent, RANGE_CLOSE)
87
88
  if (chosen !== undefined) {
88
89
  dispose = group.track(fillBefore(end, chosen))
@@ -1,4 +1,4 @@
1
- import { claimExpected } from '../runtime/claimExpected.ts'
1
+ import { claimMarker } from '../runtime/claimMarker.ts'
2
2
  import { RENDER } from '../runtime/RENDER.ts'
3
3
 
4
4
  /*
@@ -12,9 +12,7 @@ rather than a list of single nodes.
12
12
  export function openMarker(parent: Node, data: string, before: Node | null = null): Comment {
13
13
  const hydration = RENDER.hydration
14
14
  if (hydration !== undefined) {
15
- const node = claimExpected(hydration, parent, `control-flow marker "${data}"`) as Comment
16
- hydration.next.set(parent, node.nextSibling)
17
- return node
15
+ return claimMarker(hydration, parent, `control-flow marker "${data}"`)
18
16
  }
19
17
  const node = document.createComment(data)
20
18
  /* `before` (a node already in `parent`) places the block among static siblings —
@@ -1,6 +1,7 @@
1
- import { claimExpected } from '../runtime/claimExpected.ts'
1
+ import { claimMarker } from '../runtime/claimMarker.ts'
2
2
  import { OUTLET_CLOSE, OUTLET_OPEN } from '../runtime/OUTLET_MARKER.ts'
3
3
  import { PENDING_OUTLET } from '../runtime/PENDING_OUTLET.ts'
4
+ import { parkCursor } from '../runtime/parkCursor.ts'
4
5
  import { RENDER } from '../runtime/RENDER.ts'
5
6
  import { commentData } from './commentData.ts'
6
7
 
@@ -30,7 +31,7 @@ export function outlet(
30
31
  PENDING_OUTLET.current = { open, close }
31
32
  return { open, close }
32
33
  }
33
- const open = claimExpected(hydration, parent, 'outlet open marker') as Comment
34
+ const open = claimMarker(hydration, parent, 'outlet open marker')
34
35
  /* Skip to the matching close: depth-count outlet markers (nested child-layer slots
35
36
  are balanced), so a layout wrapping another layout skips its WHOLE subtree. */
36
37
  let depth = 1
@@ -56,7 +57,7 @@ export function outlet(
56
57
  )
57
58
  }
58
59
  const close = node as Comment
59
- hydration.next.set(parent, close.nextSibling)
60
+ parkCursor(hydration, parent, close.nextSibling)
60
61
  PENDING_OUTLET.current = { open, close }
61
62
  return { open, close }
62
63
  }
@@ -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
- import { scopeGroup } from '../runtime/scopeGroup.ts'
8
7
  import { SuspenseSignal } from '../runtime/SuspenseSignal.ts'
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,7 @@ 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'
7
8
  import type { State } from './runtime/types/State.ts'
8
9
  import { state } from './state.ts'
9
10
 
@@ -41,6 +42,19 @@ export function linked<T>(
41
42
  transform?: (next: T, previous: T) => T,
42
43
  ): State<T> | AsyncState<T> {
43
44
  const coerce = transform as ((next: unknown, previous: unknown) => unknown) | undefined
45
+ /* A BARE seed that reached the runtime literally (a branch-nested `<script>`, a direct
46
+ JS caller) — the compile-time `wrapSeed` normalization only covers the leading script,
47
+ so honor the authored contract (ADR-0045) by VALUE via the shared `routeBareSeed`,
48
+ exactly as `computed` does; a non-async value becomes a constant seed thunk (reseeds
49
+ never fire — nothing tracked — matching the wrapped `() => value` form). */
50
+ if (typeof seed !== 'function') {
51
+ const value: unknown = seed
52
+ const cell = routeBareSeed(value, true, coerce)
53
+ if (cell !== undefined) {
54
+ return cell as AsyncState<T>
55
+ }
56
+ seed = () => value as T
57
+ }
44
58
  /* `await` marker: an async-function seed unwraps its promise into a writable async cell. */
45
59
  if (isAsyncFunction(seed)) {
46
60
  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,11 +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'
12
+ import { runHydrationPass } from './runtime/runHydrationPass.ts'
15
13
  import { runtimePath } from './runtime/runtimePath.ts'
16
14
  import type { AbideHistoryState } from './runtime/types/AbideHistoryState.ts'
17
15
  import type { NavVerdict } from './runtime/types/NavVerdict.ts'
@@ -257,19 +255,10 @@ export function router(
257
255
  mountedPageKey = pageKey
258
256
  }
259
257
  if (hydrating) {
260
- const previous = RENDER.hydration
261
- RENDER.hydration = { next: new Map() }
262
- hydrationWindow.enter()
263
- enterRenderPass()
264
- try {
265
- run()
266
- } finally {
267
- exitRenderPass()
268
- RENDER.hydration = previous
269
- /* Outermost exit clears the window and wakes the peeks this pass withheld
270
- for SSR congruence, now that the warm value is congruent to show. */
271
- hydrationWindow.exit()
272
- }
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)
273
262
  return
274
263
  }
275
264
  run()
@@ -566,6 +555,13 @@ export function router(
566
555
  disposeFrom(0)
567
556
  rootBoundary = undefined
568
557
  mountedPageKey = undefined
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). */
569
565
  buildFrom(0, chainKeys, layoutViews, pageView, key, params, false)
570
566
  }
571
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