@abide/abide 0.54.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 (96) hide show
  1. package/AGENTS.md +330 -498
  2. package/CHANGELOG.md +49 -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/componentOrdinals.ts +39 -0
  23. package/src/lib/ui/compile/desugarSignals.ts +8 -44
  24. package/src/lib/ui/compile/generateBuild.ts +25 -24
  25. package/src/lib/ui/compile/generateSSR.ts +37 -24
  26. package/src/lib/ui/compile/hoistableAwaits.ts +1 -11
  27. package/src/lib/ui/compile/isSpuriousAsyncReadDiagnostic.ts +46 -5
  28. package/src/lib/ui/compile/lowerContext.ts +26 -6
  29. package/src/lib/ui/compile/lowerScript.ts +1 -11
  30. package/src/lib/ui/compile/renameSignalRefs.ts +7 -21
  31. package/src/lib/ui/compile/seedTypeClassifierForRoot.ts +1 -1
  32. package/src/lib/ui/compile/shadowInterpolationClassifier.ts +1 -1
  33. package/src/lib/ui/compile/types/AnalyzedComponent.ts +3 -5
  34. package/src/lib/ui/compile/wrapReactiveSeeds.ts +44 -0
  35. package/src/lib/ui/compile/wrapSeed.ts +28 -0
  36. package/src/lib/ui/computed.ts +16 -0
  37. package/src/lib/ui/createScope.ts +4 -3
  38. package/src/lib/ui/dom/anchorCursor.ts +2 -1
  39. package/src/lib/ui/dom/anchoredBranch.ts +14 -3
  40. package/src/lib/ui/dom/appendStatic.ts +11 -20
  41. package/src/lib/ui/dom/appendText.ts +41 -43
  42. package/src/lib/ui/dom/appendTextAt.ts +10 -57
  43. package/src/lib/ui/dom/awaitBlock.ts +30 -44
  44. package/src/lib/ui/dom/awaitSubject.ts +118 -0
  45. package/src/lib/ui/dom/claimText.ts +38 -0
  46. package/src/lib/ui/dom/cloneStatic.ts +2 -8
  47. package/src/lib/ui/dom/discardAndRebuild.ts +23 -0
  48. package/src/lib/ui/dom/discardBoundary.ts +5 -4
  49. package/src/lib/ui/dom/each.ts +3 -5
  50. package/src/lib/ui/dom/fillBoundary.ts +2 -1
  51. package/src/lib/ui/dom/hydrate.ts +5 -12
  52. package/src/lib/ui/dom/mountChild.ts +73 -5
  53. package/src/lib/ui/dom/mountSwappableRange.ts +2 -1
  54. package/src/lib/ui/dom/openMarker.ts +2 -4
  55. package/src/lib/ui/dom/outlet.ts +4 -3
  56. package/src/lib/ui/dom/readCell.ts +26 -11
  57. package/src/lib/ui/dom/skeleton.ts +2 -7
  58. package/src/lib/ui/dom/switchBlock.ts +2 -1
  59. package/src/lib/ui/dom/tryBlock.ts +26 -38
  60. package/src/lib/ui/dom/when.ts +6 -2
  61. package/src/lib/ui/linked.ts +14 -0
  62. package/src/lib/ui/resumeSeedScript.ts +1 -1
  63. package/src/lib/ui/router.ts +12 -24
  64. package/src/lib/ui/runtime/AsyncCellError.ts +2 -1
  65. package/src/lib/ui/runtime/BRANCH_SEGMENT.ts +24 -0
  66. package/src/lib/ui/runtime/CELL_SEED.ts +4 -6
  67. package/src/lib/ui/runtime/DOC_SEED.ts +5 -6
  68. package/src/lib/ui/runtime/RENDER.ts +7 -5
  69. package/src/lib/ui/runtime/RESUME.ts +5 -7
  70. package/src/lib/ui/runtime/SEED_MARKS.ts +14 -0
  71. package/src/lib/ui/runtime/SuspenseSignal.ts +2 -2
  72. package/src/lib/ui/runtime/claimChild.ts +5 -7
  73. package/src/lib/ui/runtime/claimExpected.ts +2 -6
  74. package/src/lib/ui/runtime/claimMarker.ts +13 -0
  75. package/src/lib/ui/runtime/claimRun.ts +22 -0
  76. package/src/lib/ui/runtime/consumeSeed.ts +38 -0
  77. package/src/lib/ui/runtime/createAsyncCell.ts +56 -8
  78. package/src/lib/ui/runtime/createComputedNode.ts +1 -0
  79. package/src/lib/ui/runtime/createEffectNode.ts +1 -0
  80. package/src/lib/ui/runtime/createSignalNode.ts +1 -0
  81. package/src/lib/ui/runtime/parkCursor.ts +10 -0
  82. package/src/lib/ui/runtime/readNode.ts +21 -1
  83. package/src/lib/ui/runtime/renderPath.ts +2 -2
  84. package/src/lib/ui/runtime/routeBareSeed.ts +28 -0
  85. package/src/lib/ui/runtime/runHydrationPass.ts +69 -0
  86. package/src/lib/ui/runtime/runNode.ts +14 -0
  87. package/src/lib/ui/runtime/types/HydrationCursor.ts +8 -0
  88. package/src/lib/ui/runtime/types/ReactiveNode.ts +5 -0
  89. package/src/lib/ui/settleAsyncCells.ts +30 -3
  90. package/src/lib/ui/startClient.ts +0 -7
  91. package/src/lib/ui/tryEncodeResume.ts +6 -15
  92. package/src/lib/ui/watch.ts +3 -11
  93. package/src/lib/ui/dom/mountStreamedChild.ts +0 -84
  94. package/src/lib/ui/dom/readCellBlocking.ts +0 -29
  95. package/src/lib/ui/runtime/restoreWarmSeeds.ts +0 -20
  96. package/src/lib/ui/runtime/warmSeedBackup.ts +0 -10
@@ -1,12 +1,6 @@
1
- import { rawHtmlString } from '../../shared/html.ts'
2
- import { snippetPayload } from '../../shared/snippet.ts'
3
- import { effect } from '../effect.ts'
1
+ import { parkCursor } from '../runtime/parkCursor.ts'
4
2
  import { RENDER } from '../runtime/RENDER.ts'
5
- import { SuspenseSignal } from '../runtime/SuspenseSignal.ts'
6
- import { appendSnippet } from './appendSnippet.ts'
7
3
  import { appendText } from './appendText.ts'
8
- import { parseRawNodes } from './parseRawNodes.ts'
9
- import { readTextOrSuspend } from './readTextOrSuspend.ts'
10
4
 
11
5
  /*
12
6
  A reactive `{expr}` interpolation mounted at a skeleton anchor comment (`<!--a-->`), used
@@ -20,67 +14,26 @@ The anchor is KEPT (in both the SSR markup and the client DOM, like control flow
20
14
  `<!--[-->` range markers), so server and client render identical markup — `appendTextAt`
21
15
  never strips it.
22
16
 
23
- Hydrate delegates to `appendText` with the cursor temporarily pointed at the anchor's
24
- content (the server rendered `<!--a-->value`, so the value is the anchor's next sibling),
25
- reusing its text-split / snippet / raw-html claiming.
17
+ Both paths delegate to `appendText`: hydrate with the cursor temporarily pointed at the
18
+ anchor's content (the server rendered `<!--a-->value`, so the value is the anchor's next
19
+ sibling), create with the anchor's next sibling as the insertion reference — one
20
+ text-split / snippet / raw-html implementation, positioned two ways.
26
21
  */
27
22
  // @documentation plumbing
28
23
  export function appendTextAt(anchor: Node, read: () => unknown): void {
29
24
  const parent = anchor.parentNode as Node
30
- if (RENDER.hydration !== undefined) {
31
- const hydration = RENDER.hydration
25
+ const hydration = RENDER.hydration
26
+ if (hydration !== undefined) {
32
27
  const had = hydration.next.has(parent)
33
28
  const saved = hydration.next.get(parent)
34
- hydration.next.set(parent, anchor.nextSibling)
29
+ parkCursor(hydration, parent, anchor.nextSibling)
35
30
  appendText(parent, read)
36
31
  if (had) {
37
- hydration.next.set(parent, saved ?? null)
32
+ parkCursor(hydration, parent, saved ?? null)
38
33
  } else {
39
34
  hydration.next.delete(parent)
40
35
  }
41
36
  return
42
37
  }
43
-
44
- /* Probe once, tolerating a suspend (a pending blocking read): a suspended interpolation skips
45
- snippet/html detection and takes the text path, starting empty until the value resolves. */
46
- let first: unknown
47
- let suspended = false
48
- try {
49
- first = read()
50
- } catch (signal) {
51
- if (!(signal instanceof SuspenseSignal)) {
52
- throw signal
53
- }
54
- suspended = true
55
- }
56
- if (!suspended && typeof snippetPayload(first) === 'function') {
57
- const fragment = document.createDocumentFragment()
58
- appendSnippet(fragment, read)
59
- parent.insertBefore(fragment, anchor.nextSibling)
60
- return
61
- }
62
- if (!suspended && rawHtmlString(first) !== undefined) {
63
- let nodes: Node[] = []
64
- effect(() => {
65
- for (const node of nodes) {
66
- parent.removeChild(node)
67
- }
68
- nodes = parseRawNodes(parent, rawHtmlString(read()) ?? '')
69
- /* Insert the fresh markup just after the anchor (its live re-insertion point). */
70
- const after = anchor.nextSibling
71
- for (const node of nodes) {
72
- parent.insertBefore(node, after)
73
- }
74
- })
75
- return
76
- }
77
- const node = document.createTextNode('')
78
- parent.insertBefore(node, anchor.nextSibling)
79
- /* Text/suspend handling shared with `appendText`'s bind via `readTextOrSuspend`: stringify the
80
- value, show '' while a blocking read is pending (re-running on settle, since the read tracked
81
- its cell), and coerce nullish to '' (never the literal "undefined") — keeping this create path
82
- congruent with the hydrate path above, which delegates to `appendText`. */
83
- effect(() => {
84
- node.data = readTextOrSuspend(read)
85
- })
38
+ appendText(parent, read, false, anchor.nextSibling)
86
39
  }
@@ -1,7 +1,8 @@
1
1
  import { decodeRefJson } from '../../shared/decodeRefJson.ts'
2
+ import { isThenable } from '../../shared/isThenable.ts'
2
3
  import { effect } from '../effect.ts'
3
4
  import { CURRENT_BOUNDARY } from '../runtime/CURRENT_BOUNDARY.ts'
4
- import { claimExpected } from '../runtime/claimExpected.ts'
5
+ import { claimMarker } from '../runtime/claimMarker.ts'
5
6
  import { generationGuard } from '../runtime/generationGuard.ts'
6
7
  import { RANGE_CLOSE, RANGE_OPEN } from '../runtime/RANGE_MARKER.ts'
7
8
  import { RENDER } from '../runtime/RENDER.ts'
@@ -10,10 +11,9 @@ import { RESUME } from '../runtime/RESUME.ts'
10
11
  import { SuspenseSignal } from '../runtime/SuspenseSignal.ts'
11
12
  import { scopeGroup } from '../runtime/scopeGroup.ts'
12
13
  import type { State } from '../runtime/types/State.ts'
13
- import { withoutHydration } from '../runtime/withoutHydration.ts'
14
14
  import { state } from '../state.ts'
15
15
  import { anchoredBranch } from './anchoredBranch.ts'
16
- import { discardBoundary } from './discardBoundary.ts'
16
+ import { discardAndRebuild } from './discardAndRebuild.ts'
17
17
 
18
18
  /*
19
19
  Async binding — the runtime for `<template await>`. Renders the pending branch,
@@ -159,44 +159,42 @@ export function awaitBlock(
159
159
  bracketing — it runs only after a clean build. */
160
160
  const adopt = (open: Node | null, build: (parent: Node) => void): void => {
161
161
  const cursor = hydration as NonNullable<typeof hydration>
162
+ /* The cursor already sits here (claiming `open` advanced past it); kept for the
163
+ bracketing below. */
162
164
  const firstAdopted = open?.nextSibling ?? null
163
- cursor.next.set(parent, firstAdopted)
164
165
  branch.adoptStrand(build, () => {
165
- /* A guaranteed control-flow marker — claimExpected throws on a desync (caught by
166
+ /* A guaranteed control-flow marker — the claim throws on a desync (caught by
166
167
  firstHydrate's adopt try/catch → rebuildCold) instead of silently claiming null
167
168
  and over-clearing the parent. */
168
- const close = claimExpected(cursor, parent, `/abide:await:${id} close marker`)
169
- cursor.next.set(parent, close.nextSibling ?? null)
169
+ const close = claimMarker(cursor, parent, `/abide:await:${id} close marker`)
170
170
  /* Bracket the adopted nodes: `[` before the first claimed node (or before `close`
171
171
  for an empty branch), `]` then the anchor just before `close`. */
172
172
  const start = document.createComment(RANGE_OPEN)
173
173
  parent.insertBefore(start, firstAdopted ?? close)
174
174
  const end = document.createComment(RANGE_CLOSE)
175
175
  parent.insertBefore(end, close)
176
- const anchorNode = document.createTextNode('')
177
- parent.insertBefore(anchorNode, close)
178
- branch.anchor = anchorNode
176
+ branch.parkAnchor(close)
179
177
  return { start, end }
180
178
  })
181
179
  }
182
180
 
183
- /* Discard the SSR boundary and (re)build the block from the live promise, fresh
184
- (hydration off) — the recovery path when adoption can't use the server markup. */
185
- const rebuildCold = (open: Node | null): void => {
181
+ /* Discard the SSR boundary and (re)build the block from `result`, fresh (hydration
182
+ off) — the recovery path when adoption can't use the server markup. Insert the
183
+ anchor at the node AFTER the discarded boundary — NOT the captured `before`, which
184
+ for a skeleton-anchored block is the open boundary itself and is removed here, so
185
+ reusing it throws `NotFoundError` in a strict DOM. */
186
+ const rebuildCold = (open: Node | null, result: () => unknown): void => {
186
187
  branch.detach()
187
- /* Insert at the node AFTER the discarded boundary (its return) — NOT the captured
188
- `before`, which for a skeleton-anchored block is the open boundary itself and is
189
- removed here, so reusing it throws `NotFoundError` in a strict DOM. */
190
- const after = discardBoundary(
188
+ discardAndRebuild(
189
+ hydration as NonNullable<typeof hydration>,
191
190
  parent,
192
191
  open,
193
192
  `/abide:await:${id}`,
194
- hydration as NonNullable<typeof hydration>,
193
+ (after) => {
194
+ branch.parkAnchor(after)
195
+ render(result())
196
+ },
195
197
  )
196
- const anchorNode = document.createTextNode('')
197
- branch.anchor = anchorNode
198
- parent.insertBefore(anchorNode, after)
199
- withoutHydration(() => render(promiseThunk()))
200
198
  }
201
199
 
202
200
  /* The first run when hydrating: adopt by precedence (resume / warm-sync), else
@@ -206,9 +204,9 @@ export function awaitBlock(
206
204
  warm cache (or re-fetches) instead of crashing hydration. */
207
205
  const firstHydrate = (): void => {
208
206
  const cursor = hydration as NonNullable<typeof hydration>
209
- /* The await block's open marker is compiler-guaranteed — claimExpected throws a
207
+ /* The await block's open marker is compiler-guaranteed — the claim throws a
210
208
  legible desync here rather than propagating a null that over-clears the parent. */
211
- const open = claimExpected(cursor, parent, `abide:await:${id} open marker`)
209
+ const open = claimMarker(cursor, parent, `abide:await:${id} open marker`)
212
210
  /* RESUME holds the ref-json-encoded entry STRING; decode here, where the codec
213
211
  lives. A decode failure (malformed/absent payload) reads as "no resume" — fall
214
212
  through to the live promise rather than crash hydration. */
@@ -233,7 +231,7 @@ export function awaitBlock(
233
231
  would otherwise surface as an unhandledrejection at boot (process-fatal under
234
232
  Bun) instead of being harmlessly dropped. */
235
233
  if (isThenable(result)) {
236
- void (result as PromiseLike<unknown>).then(undefined, () => undefined)
234
+ void result.then(undefined, () => undefined)
237
235
  }
238
236
  /* Build the adopted branch around a value CELL (then) so a later re-run updates
239
237
  it in place, exactly like a fresh mount. The `throw` for a catch-less rejection
@@ -260,7 +258,7 @@ export function awaitBlock(
260
258
  valueCell = cell
261
259
  activeKind = kind
262
260
  } catch {
263
- rebuildCold(open)
261
+ rebuildCold(open, promiseThunk)
264
262
  }
265
263
  return
266
264
  }
@@ -271,19 +269,14 @@ export function awaitBlock(
271
269
  valueCell = cell
272
270
  activeKind = 'then'
273
271
  } catch {
274
- rebuildCold(open)
272
+ rebuildCold(open, promiseThunk)
275
273
  }
276
274
  return
277
275
  }
278
- /* Insert at the node after the discarded boundary (see `rebuildCold`). */
279
- const after = discardBoundary(parent, open, `/abide:await:${id}`, cursor)
280
- const anchorNode = document.createTextNode('')
281
- branch.anchor = anchorNode
282
- parent.insertBefore(anchorNode, after)
283
- /* The boundary's server nodes are gone, so the pending branch builds FRESH — clear
284
- the claim cursor (see withoutHydration) so its `cloneStatic`/text don't try to
285
- claim discarded nodes and silently render nothing. */
286
- withoutHydration(() => render(result))
276
+ /* Genuinely pending — no resume, not warm: the boundary's server nodes can't be
277
+ adopted, so discard them and build the pending branch fresh from the already-read
278
+ promise (nothing was placed yet; the detach inside is a no-op). */
279
+ rebuildCold(open, () => result)
287
280
  }
288
281
 
289
282
  effect(() => {
@@ -296,9 +289,7 @@ export function awaitBlock(
296
289
  firstHydrate()
297
290
  return
298
291
  }
299
- const anchorNode = document.createTextNode('')
300
- branch.anchor = anchorNode
301
- parent.insertBefore(anchorNode, before)
292
+ branch.parkAnchor(before)
302
293
  }
303
294
  /* Read the promise every subsequent run so an invalidate re-runs the block. ONLY this
304
295
  read is tracked (the branch builds untracked via `scope`), so the block re-runs only
@@ -321,8 +312,3 @@ export function awaitBlock(
321
312
  render(result)
322
313
  })
323
314
  }
324
-
325
- /* Whether a value is Promise-like (the cold path); a non-thenable is warm-sync. */
326
- function isThenable(value: unknown): value is Promise<unknown> {
327
- return value !== null && typeof (value as { then?: unknown })?.then === 'function'
328
- }
@@ -0,0 +1,118 @@
1
+ import { isAsyncCell } from '../../shared/isAsyncCell.ts'
2
+ import type { AsyncComputed } from '../../shared/types/AsyncComputed.ts'
3
+ import { createEffectNode } from '../runtime/createEffectNode.ts'
4
+ import { SuspenseSignal } from '../runtime/SuspenseSignal.ts'
5
+ import { readCell } from './readCell.ts'
6
+
7
+ /*
8
+ The subject of an `{#await X}` block, normalised so the block AWAITS an async cell's
9
+ resolution instead of peeking its `undefined`-while-pending value (ADR-0047). `{#await}`
10
+ means "wait", so when its subject is a `computed`/`linked` async cell the block should show
11
+ its pending branch until the cell settles, then `{:then}` with the resolved value — but a
12
+ cell reference otherwise lowers to `$$readCell(cell)`, a PEEK, which reads `undefined` while
13
+ pending and fires `{:then}` with `undefined` (the `{#if}`/`{#switch}` cell subjects avoid this
14
+ with a `cellPending` guard; `{#await}` had no equivalent).
15
+
16
+ The compiler wraps ONLY a bare cell-reference subject in this (a plain-promise subject like
17
+ `{#await getFoo()}` bypasses it), so `subject` is always some cell the `cellReadNames` set
18
+ named — of two runtime shapes:
19
+
20
+ - **an async cell** (`AsyncComputed`/`AsyncState` — a `computed`/`linked` whose seed unwraps a
21
+ promise/stream). Pending (in flight, no value) → a PROMISE resolving to the cell's value once
22
+ it settles (rejecting with its error); the `pending()` read subscribes the reading block, so a
23
+ client reseed/`cache.invalidate` re-runs it and the SSR `renderToStream` awaits the returned
24
+ promise. Settled (a value, or a held value being refreshed — SWR) → the value now, so `{:then}`
25
+ shows it with no pending flash; an error with no retained value throws (→ `{:catch}`/`{#try}`).
26
+ - **a lazy `Computed`/`State`/derive** holding a bare promise (`computed(getFoo())` with no
27
+ `await` stays an opaque promise-holder, not an async cell) → read through `readCell` to its
28
+ `.value`, i.e. the promise itself, which `awaitBlock`/`renderToStream` then awaits — exactly the
29
+ pre-existing `{#await computedPromise}` behaviour, preserved.
30
+ */
31
+ // @documentation plumbing
32
+ export function awaitSubject(subject: unknown): unknown {
33
+ if (!isAsyncCell(subject)) {
34
+ /* A lazy `Computed`/`State`/derive reference: unwrap to its `.value` (the held promise or
35
+ value), which the await block awaits — never peeked to `undefined`. */
36
+ return readCell(subject)
37
+ }
38
+ const cell = subject as AsyncComputed<unknown> & {
39
+ settled?: () => Promise<unknown> | undefined
40
+ }
41
+ /* `pending()` — in flight AND no value — is the only state that WAITS; a settled or
42
+ refreshing cell reads its retained value now (SWR). The read subscribes the caller. */
43
+ if (cell.pending()) {
44
+ const inFlight = cell.settled?.()
45
+ if (inFlight !== undefined) {
46
+ /* Await the in-flight settle, then read the freshly-stored value (a `settled` cell
47
+ writes its value before this `.then` runs — it registered its handler first); an
48
+ error there rejects this promise → `{:catch}`. EXCEPT a `SuspenseSignal`
49
+ rejection, which is a PAUSE, not a failure (an async seed's sync prefix read a
50
+ pending blocking dep — the cell swallows it and stays pending, reseeding once the
51
+ dep settles); rejecting would render `{:catch SuspenseSignal}`, so keep waiting
52
+ for the real settle instead. */
53
+ return inFlight.then(
54
+ () => settledValue(cell),
55
+ (error) => {
56
+ if (error instanceof SuspenseSignal) {
57
+ return whenSettled(cell)
58
+ }
59
+ throw error
60
+ },
61
+ )
62
+ }
63
+ /* Pending with NO in-flight promise — a stream before its first frame, or a
64
+ `SuspenseSignal`-paused seed (both leave `pending()` true with `settled()` cleared).
65
+ Resolving now would read `undefined` into `{:then}` — the exact flash ADR-0047 kills;
66
+ wait for the cell to actually settle. */
67
+ return whenSettled(cell)
68
+ }
69
+ /* Settled: an error with no retained value becomes a REJECTED promise (never a synchronous
70
+ throw), so `awaitBlock`/`renderToStream` route it to `{:catch}` rather than letting it
71
+ escape the block; otherwise the retained value now. `settledValue` is the ONE statement
72
+ of the value-else-error rule for both the settled and the just-settled arms. */
73
+ try {
74
+ return settledValue(cell)
75
+ } catch (error) {
76
+ return Promise.reject(error)
77
+ }
78
+ }
79
+
80
+ /* The value of an in-flight cell once it settles: its retained value, or a throw when it holds
81
+ only an error — the throw rejects the awaiting promise, routing to `{:catch}`/`{#try}`. */
82
+ function settledValue(cell: AsyncComputed<unknown>): unknown {
83
+ const value = cell.peek()
84
+ if (value === undefined) {
85
+ const error = cell.error()
86
+ if (error !== undefined) {
87
+ throw error
88
+ }
89
+ }
90
+ return value
91
+ }
92
+
93
+ /* A promise for the cell's NEXT settle, driven by the graph: an effect watches `pending()` and
94
+ resolves with the settled value (or rejects with its error) the moment it flips false — the
95
+ waiter for the states that hold no in-flight promise to chain on. The write that settles the
96
+ cell flushes effects synchronously (client and SSR alike), so this needs no polling; the
97
+ effect disposes itself on a microtask once done. */
98
+ function whenSettled(cell: AsyncComputed<unknown>): Promise<unknown> {
99
+ return new Promise((resolve, reject) => {
100
+ let done = false
101
+ let dispose: (() => void) | undefined
102
+ const settle = (): void => {
103
+ if (done || cell.pending()) {
104
+ return
105
+ }
106
+ done = true
107
+ try {
108
+ resolve(settledValue(cell))
109
+ } catch (error) {
110
+ reject(error)
111
+ }
112
+ /* Deferred: disposing mid-run would unlink the node while `runNode` is still
113
+ tracking it. */
114
+ queueMicrotask(() => dispose?.())
115
+ }
116
+ dispose = createEffectNode(settle)
117
+ })
118
+ }
@@ -0,0 +1,38 @@
1
+ import { claimChild } from '../runtime/claimChild.ts'
2
+ import { parkCursor } from '../runtime/parkCursor.ts'
3
+ import type { HydrationCursor } from '../runtime/types/HydrationCursor.ts'
4
+ import { assertClaimedText } from './assertClaimedText.ts'
5
+
6
+ /*
7
+ Claims one text consumer's portion of the merged SSR text node at the parent's cursor:
8
+ assert the node begins with `value` (or the split below misaligns and orphans the tail),
9
+ split the node at the value's length so the remainder stays claimable, and advance past
10
+ the claimed portion. `splitAlways` splits even on an exact-length consume — a non-final
11
+ consumer in a run must leave a node behind for its sibling (an interpolation rendering
12
+ empty otherwise has no node and the next claim grabs the wrong sibling); the final
13
+ consumer keeps the cheaper split-only-when-shorter path.
14
+
15
+ Returns undefined WITHOUT moving the cursor when the cursor doesn't hold a text node —
16
+ a value that rendered empty on the server emitted no text node, so the cursor sits on
17
+ the following element/comment or past the end. The caller owns that miss policy (bind a
18
+ synthesized node, or skip); a text node is detected by `splitText`, not `nodeType`, so
19
+ the test mini-dom is covered too.
20
+ */
21
+ export function claimText(
22
+ hydration: HydrationCursor,
23
+ parent: Node,
24
+ value: string,
25
+ splitAlways: boolean,
26
+ ): Text | undefined {
27
+ const claimed = claimChild(hydration, parent)
28
+ if (claimed === null || typeof (claimed as Text).splitText !== 'function') {
29
+ return undefined
30
+ }
31
+ const node = claimed as Text
32
+ assertClaimedText(node, value)
33
+ if (splitAlways ? value.length <= node.data.length : value.length < node.data.length) {
34
+ node.splitText(value.length)
35
+ }
36
+ parkCursor(hydration, parent, node.nextSibling)
37
+ return node
38
+ }
@@ -1,4 +1,4 @@
1
- import { claimChild } from '../runtime/claimChild.ts'
1
+ import { claimRun } from '../runtime/claimRun.ts'
2
2
  import { RENDER } from '../runtime/RENDER.ts'
3
3
  import { foreignWrapperTag } from './foreignWrapperTag.ts'
4
4
  import { templateFor } from './templateFor.ts'
@@ -29,13 +29,7 @@ export function cloneStatic(parent: Node, html: string): void {
29
29
  const source = wrapper === undefined ? template.content : (template.content.firstChild as Node)
30
30
  const hydration = RENDER.hydration
31
31
  if (hydration !== undefined) {
32
- let node = claimChild(hydration, parent)
33
- let remaining = source.childNodes.length
34
- while (remaining > 0 && node !== null) {
35
- node = node.nextSibling
36
- remaining -= 1
37
- }
38
- hydration.next.set(parent, node)
32
+ claimRun(hydration, parent, source.childNodes.length)
39
33
  return
40
34
  }
41
35
  for (const child of source.childNodes) {
@@ -0,0 +1,23 @@
1
+ import type { HydrationCursor } from '../runtime/types/HydrationCursor.ts'
2
+ import { withoutHydration } from '../runtime/withoutHydration.ts'
3
+ import { discardBoundary } from './discardBoundary.ts'
4
+
5
+ /*
6
+ The shared divergence-recovery mechanics of every named-boundary block: remove the SSR
7
+ boundary (open marker through `closeData`, parking the cursor after it) and run a FRESH
8
+ build at the freed position with the claim cursor cleared — so the build's helpers create
9
+ nodes instead of claiming the discarded ones. `build` receives the node after the removed
10
+ boundary (its insertion reference; null = parent's end). Each block keeps its own POLICY —
11
+ when to give up on adoption (a resume value that didn't round-trip, a rejected guarded
12
+ build, a boundary whose streamed fragment never arrived) — and delegates the dance here.
13
+ */
14
+ export function discardAndRebuild<T>(
15
+ hydration: HydrationCursor,
16
+ parent: Node,
17
+ open: Node | null,
18
+ closeData: string,
19
+ build: (after: Node | null) => T,
20
+ ): T {
21
+ const after = discardBoundary(parent, open, closeData, hydration)
22
+ return withoutHydration(() => build(after))
23
+ }
@@ -1,4 +1,5 @@
1
- import type { RENDER } from '../runtime/RENDER.ts'
1
+ import { parkCursor } from '../runtime/parkCursor.ts'
2
+ import type { HydrationCursor } from '../runtime/types/HydrationCursor.ts'
2
3
 
3
4
  /* Remove an SSR boundary — open marker through close marker (inclusive) — and park
4
5
  the hydration cursor on the node after it, returning that node. A fresh run then
@@ -8,11 +9,11 @@ export function discardBoundary(
8
9
  parent: Node,
9
10
  open: Node | null,
10
11
  closeData: string,
11
- hydration: NonNullable<(typeof RENDER)['hydration']>,
12
+ hydration: HydrationCursor,
12
13
  ): Node | null {
13
14
  /* Nothing to discard (no open marker) — park the cursor and return, as before. */
14
15
  if (open === null) {
15
- hydration.next.set(parent, null)
16
+ parkCursor(hydration, parent, null)
16
17
  return null
17
18
  }
18
19
  /* Locate the close marker WITHOUT mutating first. Removing as we walk would, on a
@@ -40,6 +41,6 @@ export function discardBoundary(
40
41
  }
41
42
  node = next
42
43
  }
43
- hydration.next.set(parent, after)
44
+ parkCursor(hydration, parent, after)
44
45
  return after
45
46
  }
@@ -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
  }