@abide/abide 0.57.0 → 0.58.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,30 @@
1
1
  # abide
2
2
 
3
+ ## 0.58.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 3941314: address every component mount as its own hydration boundary so a structural desync inside a child recovers at that one boundary instead of discarding the whole page (ADR-0049)
8
+
9
+ A component's range now ships as `<!--abide:c:PATH-->…<!--/abide:c:PATH-->` (the child's render-path) in place of the anonymous `<!--[-->…<!--]-->`. On a hydration desync inside the child — including a client-true / server-false `{#if}` gating an element's presence (`pending(...)`, `refreshing(...)`, any state fed by a client-only effect) — `mountChild` discards just that boundary and remounts the child fresh, rather than throwing out of hydrate and cold-rebuilding the entire page. Structural gating on client-asymmetric probes is therefore now safe (it degrades to a local remount). `cloneStatic` now verifies its claimed hydration run against the template's top-level shape, so a purely-static gated element throws (and recovers) instead of silently mis-claiming a diverged branch. No public API change; the SSR wire format for component mounts changes (server and client ship together and stay congruent).
10
+
11
+ ## 0.57.1
12
+
13
+ ### Patch Changes
14
+
15
+ - 866c96d: degrade a mangled **SSR** payload to a cold boot (ADR-0051) ([`adfee02`](https://github.com/briancray/abide/commit/adfee024d570e8bf46230fac1fa2521fcaae056f))
16
+ - 1abc943: Reuse the keyed-list (`{#for}`) reconcile scratch across passes so a steady-state reconcile allocates nothing
17
+
18
+ Each `each` reconcile built two fresh arrays per list change — `list.map(keyOf)` for the desired keys and `new Array(n)` for the resolved rows — then threw them away. On a list re-reconciled per keystroke that churn showed up as avoidable minor-GC pressure mid-interaction. The two buffers are now hoisted into persistent per-`each` scratch, reused every pass and cleared down to the live length on shrink (so no disposed-row references are retained). The `keyOf` pass is fused into the resolve loop — still exactly one `keyOf` call per item. Behaviour is unchanged: keyed diffing, in-place row updates, duplicate-key collapse, and prune-before-place all resolve identically; only the per-pass allocation is gone. The `[...source]` materialization for non-array iterables (generators must be drained) is unchanged.
19
+
20
+ - e137242: `spreadAttrs` — swallow a per-key suspend on a `{...rest}` spread
21
+
22
+ A `{...rest}` spread guards its enumeration against a pending blocking `await` read, but not the per-key value read. When `source()` is a restProps proxy (the `<Button {...rest}>` case), its `get` invokes the underlying prop thunk, so a key whose expression reads a still-pending blocking cell suspends inside `bindKeys` — after the enumeration guard has already passed — and the `SuspenseSignal` escaped the render, killing the mount (seen as a crash on a cold client remount, e.g. navigating back to a page whose top-level blocking cells re-fetch and are briefly `pending()`). The per-key read now catches the suspend and binds the key as a deferred attribute (its `attr` effect swallows the signal and fills the value in on settle), matching `attr` and the deferred-enumeration branch; event handlers, whose thunks return a function without reading a cell, never suspend and stay on the listener path.
23
+
24
+ - 7367a23: SSR hydration payload ships as inert JSON instead of executable JavaScript (ADR-0051)
25
+
26
+ The `window.__SSR__` boot payload now ships as `<script type="application/json">` parsed once by the deferred client bundle with `JSON.parse`, rather than a `window.__SSR__ = {…}` statement the browser had to compile and evaluate as a multi-MB program on the critical path. The global still exists for debugging — the bundle republishes it after parsing. Additionally, a json cache body is now seeded pre-parsed (`data`) rather than as a re-escaped JSON string nested in the payload, so it's single-encoded (roughly half the wire bytes and gzip for a json-heavy grid) and read without a second parse. Warm-seed semantics — zero refetch, zero hydration divergence — are unchanged.
27
+
3
28
  ## 0.57.0
4
29
 
5
30
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abide/abide",
3
- "version": "0.57.0",
3
+ "version": "0.58.0",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "description": "Isomorphic multimodal HTTP framework built for humans and machines in a single Bun runtime",
@@ -6,6 +6,7 @@ import { formatTraceparent } from '../../shared/formatTraceparent.ts'
6
6
  import { hasSeedableRequest } from '../../shared/hasSeedableRequest.ts'
7
7
  import { layoutChainForRoute } from '../../shared/layoutChainForRoute.ts'
8
8
  import { resolvedCellsSlot } from '../../shared/resolvedCellsSlot.ts'
9
+ import { SSR_SCRIPT_ID } from '../../shared/SSR_SCRIPT_ID.ts'
9
10
  import { safeJsonForScript } from '../../shared/safeJsonForScript.ts'
10
11
  import { socketTailsSlot } from '../../shared/socketTailsSlot.ts'
11
12
  import type { CacheEntry } from '../../shared/types/CacheEntry.ts'
@@ -213,7 +214,11 @@ export function createUiPageRenderer({
213
214
  health,
214
215
  clientTimeout,
215
216
  } satisfies SsrPayload)
216
- return `<script>window.__SSR__ = ${payload};</script>`
217
+ /* Inert JSON data, NOT a `window.__SSR__ = {…}` statement (ADR-0051): the browser never
218
+ compiles this multi-MB payload as JavaScript on the critical path — the deferred bundle
219
+ reads it with one `JSON.parse`. `safeJsonForScript` has already `<`-escaped every
220
+ `<`, so an embedded `</script>` can't close the tag early. */
221
+ return `<script type="application/json" id="${SSR_SCRIPT_ID}">${payload}</script>`
217
222
  }
218
223
 
219
224
  /* Per-route `<link rel=modulepreload>`s for the route's page + layout-chain chunks
@@ -87,15 +87,29 @@ export async function snapshotEntryFromCache(
87
87
  headers: response.headers,
88
88
  }),
89
89
  )
90
- return {
90
+ const wire: CacheSnapshotEntry = {
91
91
  key: entry.key,
92
92
  url: entry.request.url,
93
93
  method,
94
94
  status: response.status,
95
95
  statusText: response.statusText,
96
96
  headers: Array.from(response.headers.entries()),
97
- body,
98
97
  }
98
+ /* Ship a json body PARSED so it single-encodes in the payload rather than nesting a
99
+ re-escaped JSON string (ADR-0051 — the double-encoding that ~2.35×'d the wire). On a
100
+ parse failure ship the raw string under `body`: the client decodes it async exactly as
101
+ before (warmValueFromSnapshot's own try/catch), so a malformed-but-json-typed body still
102
+ round-trips rather than throwing here. A text body always ships raw. */
103
+ if (kind === 'json') {
104
+ try {
105
+ wire.data = JSON.parse(body)
106
+ return wire
107
+ } catch {
108
+ /* fall through to the raw-string form */
109
+ }
110
+ }
111
+ wire.body = body
112
+ return wire
99
113
  }
100
114
 
101
115
  async function readSettled(promise: Promise<Response>): Promise<Response | undefined> {
@@ -0,0 +1,11 @@
1
+ /*
2
+ The id of the `<script type="application/json">` element the server emits the
3
+ `window.__SSR__` payload into and the client (`startClient`) parses back out
4
+ (ADR-0051). Shipping the payload as inert JSON data — not a `window.__SSR__ = {…}`
5
+ executable statement — means the browser never COMPILES a multi-MB object literal
6
+ as JavaScript on the critical path; the deferred client bundle reads it with one
7
+ `JSON.parse` (a restricted grammar parsed several times faster than equivalent JS
8
+ source). Both sides import this constant so the write id and read id can't drift.
9
+ */
10
+ // @documentation plumbing
11
+ export const SSR_SCRIPT_ID = 'abide-ssr'
@@ -15,7 +15,12 @@ the first cache() read adopts its call site's ttl (see CacheEntry).
15
15
  */
16
16
  export function cacheEntryFromSnapshot(entry: CacheSnapshotEntry): CacheEntry {
17
17
  const headers = new Headers(entry.headers)
18
- const response = new Response(entry.body, {
18
+ /* Body text for async readers / shareable clones. A json body arrives PARSED as `data`
19
+ (ADR-0051): re-stringify it (compact JSON — value-identical to the server body, whose
20
+ `json()` also emits compact JSON) so a later `.json()`/`.text()`/`.clone()` still works.
21
+ A text (or parse-failed json) body arrives raw as `body`. */
22
+ const bodyText = 'data' in entry ? JSON.stringify(entry.data) : (entry.body ?? '')
23
+ const response = new Response(bodyText, {
19
24
  status: entry.status,
20
25
  statusText: entry.statusText,
21
26
  headers,
@@ -26,7 +31,7 @@ export function cacheEntryFromSnapshot(entry: CacheSnapshotEntry): CacheEntry {
26
31
  request: new Request(entry.url, { method: entry.method }),
27
32
  ttl: undefined,
28
33
  expiresAt: undefined,
29
- value: warmValueFromSnapshot(entry.status, headers, entry.body),
34
+ value: warmValueFromSnapshot(entry, headers, bodyText),
30
35
  settled: true,
31
36
  hydrated: true,
32
37
  }
@@ -42,19 +47,23 @@ non-2xx, 204 (the status gate below), streaming, binary (the DEFER sentinel) —
42
47
  yields no warm value and defers to the async path, which throws HttpError /
43
48
  returns the streaming error / blobs exactly as a live call would.
44
49
  */
45
- function warmValueFromSnapshot(status: number, headers: Headers, body: string): unknown {
50
+ function warmValueFromSnapshot(entry: CacheSnapshotEntry, headers: Headers, body: string): unknown {
46
51
  /* Status gate is side-specific: an error/204 has no warm value here, but the live
47
52
  read throws HttpError / returns undefined for the same status — so it stays out of
48
53
  the shared kind mapping. */
49
- if (status === 204 || status < 200 || status >= 300) {
54
+ if (entry.status === 204 || entry.status < 200 || entry.status >= 300) {
50
55
  return undefined
51
56
  }
52
57
  const kind = contentBodyKind(contentTypeOf(headers))
53
58
  const value = bodyValueForKind(
54
59
  kind,
55
- /* The body may still be malformed JSON; fall back to the async path rather than
56
- throwing a SyntaxError synchronously during hydration. */
60
+ /* Pre-parsed json body (`data`, ADR-0051) IS the value — no second parse. A raw json
61
+ body may still be malformed; fall back to the async path rather than throwing a
62
+ SyntaxError synchronously during hydration. */
57
63
  () => {
64
+ if ('data' in entry) {
65
+ return entry.data
66
+ }
58
67
  try {
59
68
  return JSON.parse(body)
60
69
  } catch {
@@ -6,6 +6,15 @@ Any entry called inline during render (any method) with a textual Content-Type i
6
6
  emitted, so the client hydrates warm instead of re-firing the call after render;
7
7
  binary bodies don't survive a JSON round-trip. The seeded value is read warm once
8
8
  — it is never auto-replayed (that stays GET-only, see REPLAYABLE_METHODS).
9
+
10
+ The body travels in ONE of two shapes, never both (ADR-0051): a json-kind body
11
+ ships PARSED as `data` — a live JSON value nested directly in the payload — so the
12
+ whole snapshot serialises once (the payload's own `JSON.stringify`) instead of the
13
+ body being pre-serialised to a string and then re-escaped as a nested string
14
+ literal (the double-encoding that ~2.35×'d the wire and forced the client to parse
15
+ the body a second time). A text-kind body — or a json body the server couldn't
16
+ parse — ships raw as `body`. The client re-derives which from the presence of
17
+ `data` (`cacheEntryFromSnapshot`).
9
18
  */
10
19
  export type CacheSnapshotEntry = {
11
20
  key: string
@@ -14,5 +23,10 @@ export type CacheSnapshotEntry = {
14
23
  status: number
15
24
  statusText: string
16
25
  headers: Array<[string, string]>
17
- body: string
26
+ /* Raw body text — present for a text-kind body, or a json body that failed to parse
27
+ server-side (shipped raw, decoded async client-side as before). Absent when `data` is. */
28
+ body?: string
29
+ /* Pre-parsed json-kind body, single-encoded. Present iff the body was json and parsed;
30
+ `'data' in entry` is the discriminator (a `null` json body still keys present). */
31
+ data?: unknown
18
32
  }
@@ -616,16 +616,22 @@ export function generateSSR(
616
616
  )
617
617
  }
618
618
  /* Non-hoistable child (off the top-level spine): await inline at its structural position,
619
- sequential, as before — never streams. */
619
+ sequential, as before — never streams. Bracket its html in the ADDRESSED boundary
620
+ (ADR-0049) `abide:c:CHILDPATH` in place of the anonymous `[`/`]` — CHILDPATH is the
621
+ child's render-path (`$$renderPath(ordinal)`), the same id the client's `mountChild`
622
+ composes — so a hydration desync inside the child recovers AT this boundary instead of
623
+ reaching the router and discarding the whole page. */
620
624
  const result = nextVar('$child')
625
+ const childPath = nextVar('$cpath')
621
626
  return (
622
627
  anchor +
623
- push(target, RANGE_OPEN) +
628
+ `const ${childPath} = $$renderPath(${ordinal});\n` +
629
+ `${target}.push('<!--abide:c:' + ${childPath} + '-->');\n` +
624
630
  `const ${result} = await ${renderExpr};\n` +
625
631
  `${target}.push(${result}.html);\n` +
626
632
  `for (const $a of ${result}.awaits) { $awaits.push($a); }\n` +
627
633
  `Object.assign($resume, ${result}.resume);\n` +
628
- push(target, RANGE_CLOSE)
634
+ `${target}.push('<!--/abide:c:' + ${childPath} + '-->');\n`
629
635
  )
630
636
  }
631
637
  if (node.kind === 'element' && node.tag === OUTLET_TAG) {
@@ -0,0 +1,27 @@
1
+ import { scope } from '../runtime/scope.ts'
2
+ import type { UiProps } from '../runtime/types/UiProps.ts'
3
+ import { disposeRange } from './disposeRange.ts'
4
+ import { openMarker } from './openMarker.ts'
5
+ import { withScope } from './withScope.ts'
6
+
7
+ /*
8
+ Adopts a component's server range IN PLACE during hydration, given its already-claimed
9
+ `start` marker and the `closeData` of the marker that ends the range. Establishes the
10
+ child's lexical scope + render pass (`withScope`), builds claiming the existing nodes,
11
+ then claims the end marker the build's content stops before — the hydrate half of
12
+ `mountRange`, factored out so `mountChild`'s ADDRESSED adopt (`abide:c:PATH` brackets,
13
+ ADR-0049) and `mountRange`'s anonymous `[`/`]` adopt share ONE implementation and can't
14
+ drift. A build throw is leak-safe: `withScope`/`scope` dispose the partial scope before
15
+ rethrowing, so the caller's `discardAndRebuild` recovery starts clean.
16
+ */
17
+ export function adoptRange(
18
+ parent: Node,
19
+ build: (host: Node, props?: UiProps) => void,
20
+ props: UiProps | undefined,
21
+ start: Comment,
22
+ closeData: string,
23
+ ): { start: Comment; end: Comment; dispose: () => void } {
24
+ const scoped = withScope(() => scope(() => build(parent, props)))
25
+ const end = openMarker(parent, closeData)
26
+ return { start, end, dispose: disposeRange(scoped, start, end) }
27
+ }
@@ -1,5 +1,7 @@
1
+ import { CURRENT_PATH } from '../runtime/CURRENT_PATH.ts'
1
2
  import { claimRun } from '../runtime/claimRun.ts'
2
3
  import { RENDER } from '../runtime/RENDER.ts'
4
+ import { reportHydrationDivergence } from '../runtime/reportHydrationDivergence.ts'
3
5
  import { foreignWrapperTag } from './foreignWrapperTag.ts'
4
6
  import { templateFor } from './templateFor.ts'
5
7
 
@@ -29,10 +31,39 @@ export function cloneStatic(parent: Node, html: string): void {
29
31
  const source = wrapper === undefined ? template.content : (template.content.firstChild as Node)
30
32
  const hydration = RENDER.hydration
31
33
  if (hydration !== undefined) {
32
- claimRun(hydration, parent, source.childNodes.length)
34
+ /* Claim the run AND verify its top-level shape. A static clone is byte-identical to the
35
+ server markup, so it can only DIVERGE when a client-asymmetric gate above it (a sync
36
+ `{#if}`/`{#switch}` whose condition differs SSR↔client — `{#if pending()}`) rendered a
37
+ different branch on the server, leaving the claim cursor on foreign nodes. Unverified,
38
+ `claimRun` would SILENTLY adopt them — wrong nodes wired, cursor desynced for every later
39
+ sibling; assert the node names instead and throw the standard structural desync (like
40
+ `claimExpected`) so an addressed component boundary (ADR-0049) recovers locally. */
41
+ const claimed: Node[] = []
42
+ claimRun(hydration, parent, source.childNodes.length, claimed)
43
+ assertStaticRun(claimed, source.childNodes)
33
44
  return
34
45
  }
35
46
  for (const child of source.childNodes) {
36
47
  parent.appendChild(child.cloneNode(true))
37
48
  }
38
49
  }
50
+
51
+ /* Throw a structural hydration desync when a claimed static run doesn't match its template's
52
+ top-level nodes (a short run, or a differing node at any position — an element vs the comment
53
+ marker of a diverged branch). Named on the `hydrate` channel first, then thrown so the nearest
54
+ addressed boundary can discard-and-rebuild. */
55
+ function assertStaticRun(claimed: Node[], expected: NodeListOf<ChildNode>): void {
56
+ const short = claimed.length !== expected.length
57
+ for (let index = 0; index < expected.length; index += 1) {
58
+ const want = (expected[index] as Node).nodeName
59
+ if (short || (claimed[index] as Node).nodeName !== want) {
60
+ reportHydrationDivergence('static structure desync', {
61
+ expected: want,
62
+ got: short ? '(missing)' : (claimed[index] as Node).nodeName,
63
+ })
64
+ throw new Error(
65
+ `[abide] hydration desync at ${CURRENT_PATH.current || '(root)'}: expected a static ${want} node here, but the server DOM had a different node — SSR markup and the client build disagree on structure at this position.`,
66
+ )
67
+ }
68
+ }
69
+ }
@@ -62,6 +62,12 @@ export function each<T>(
62
62
  /* Each row's scope, registered with the owner so every live row disposes on owner
63
63
  teardown (the effect's own disposer only unsubscribes it from `items()`). */
64
64
  const group = scopeGroup()
65
+ /* Persistent reconcile scratch, reused across passes so a steady-state reconcile
66
+ allocates nothing: `keys` holds each desired key (one `keyOf` call per item), `resolved`
67
+ its surviving row (or undefined → build). Both are sized to the current list each pass
68
+ and their stale tail is cleared, so a shrinking list retains no disposed-row refs. */
69
+ const keys: string[] = []
70
+ const resolved: (EachRow | undefined)[] = []
65
71
 
66
72
  /* Build a row's range. Hydrate mode (only while the claim cursor is active —
67
73
  read fresh, since a row built by a post-hydration reconcile must create, not
@@ -177,22 +183,30 @@ export function each<T>(
177
183
  withoutHydration(() => {
178
184
  /* Undefined source → empty list (see the hydration loop, ADR-0032 D3). */
179
185
  const list = source == null ? [] : Array.isArray(source) ? source : [...source]
180
- const keys = list.map(keyOf)
186
+ const count = list.length
181
187
  generation += 1
182
188
  const pass = generation
183
- /* Resolve each desired key to its surviving row once, here, stamping it with
184
- this pass — so prune below is an int compare (no `Set`) and the placement
185
- walk reuses the resolved row instead of a second `rows.get`. Halving the map
186
- lookups per reconcile is a real win once the harness's O(n) `nextSibling` no
187
- longer swamps it. `undefined` marks a key with no surviving row — a build. */
188
- const resolved: (EachRow | undefined)[] = new Array(keys.length)
189
- for (let index = 0; index < keys.length; index += 1) {
190
- const row = rows.get(keys[index] as string)
189
+ /* Fill the reused scratch and resolve each desired key to its surviving row once,
190
+ here, stamping it with this pass — so prune below is an int compare (no `Set`)
191
+ and the placement walk reuses the resolved row instead of a second `rows.get`.
192
+ Halving the map lookups per reconcile is a real win once the harness's O(n)
193
+ `nextSibling` no longer swamps it. `undefined` marks a key with no surviving row
194
+ — a build. One `keyOf` call per item, cached in `keys` for the placement walk. */
195
+ for (let index = 0; index < count; index += 1) {
196
+ const key = keyOf(list[index] as T)
197
+ keys[index] = key
198
+ const row = rows.get(key)
191
199
  if (row !== undefined) {
192
200
  row.gen = pass
193
201
  }
194
202
  resolved[index] = row
195
203
  }
204
+ /* Drop the previous pass's longer tail so a shrunk list retains no disposed-row
205
+ refs and the scratch stays sized to the live list. */
206
+ if (keys.length > count) {
207
+ keys.length = count
208
+ resolved.length = count
209
+ }
196
210
  /* Prune departed rows first (those the resolve loop didn't reach this pass) so
197
211
  their ranges don't sit between survivors and throw off the in-place sibling
198
212
  checks below. */
@@ -5,9 +5,9 @@ import { RANGE_CLOSE, RANGE_OPEN } from '../runtime/RANGE_MARKER.ts'
5
5
  `+1` opening a range, `-1` closing one, `0` for any other comment. A control-flow
6
6
  block's rendered content sits between an OPEN and CLOSE comment — `[`…`]` for each
7
7
  rows / if / switch / slot ranges, named `abide:…`…`/abide:…` for await / try /
8
- snippet / html; the skeleton's own anchor (`a`) sits OUTSIDE any range, so it scores
9
- `0`. Keeping the rule here means a new marker family updates one place, so the two
10
- scans can never disagree on what nests. */
8
+ snippet / html / component-mount (`abide:c:`, ADR-0049); the skeleton's own anchor
9
+ (`a`) sits OUTSIDE any range, so it scores `0`. Keeping the rule here means a new
10
+ marker family updates one place, so the two scans can never disagree on what nests. */
11
11
  export function markerDepthDelta(data: string): number {
12
12
  if (data === RANGE_CLOSE || data.startsWith('/abide:')) {
13
13
  return -1
@@ -3,8 +3,10 @@ import { claimChild } from '../runtime/claimChild.ts'
3
3
  import { OWNER } from '../runtime/OWNER.ts'
4
4
  import { RANGE_OPEN } from '../runtime/RANGE_MARKER.ts'
5
5
  import { RENDER } from '../runtime/RENDER.ts'
6
+ import { reportHydrationDivergence } from '../runtime/reportHydrationDivergence.ts'
6
7
  import type { UiComponent } from '../runtime/types/UiComponent.ts'
7
8
  import { withOptionalPath } from '../runtime/withOptionalPath.ts'
9
+ import { adoptRange } from './adoptRange.ts'
8
10
  import { commentData } from './commentData.ts'
9
11
  import { discardAndRebuild } from './discardAndRebuild.ts'
10
12
  import { mountRange } from './mountRange.ts'
@@ -20,8 +22,12 @@ The server decides per render whether a HOISTABLE child (ADR-0039) inlined (fast
20
22
  streamed (still-pending), and the client build can't know which — nor whether this child was
21
23
  hoistable at all — so hydrate mode probes the cursor:
22
24
 
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:c:CHILDPATH-->` next → the server INLINED the child in its ADDRESSED boundary
26
+ (ADR-0049 — every non-streamed child); claim the boundary open, adopt the range in place,
27
+ claim the close. A structural desync INSIDE the child (a client-true / server-false `{#if}`
28
+ gating an element's presence) throws through the build — recover by discarding just this
29
+ boundary and remounting the child fresh (`discardAndRebuild`), so the desync costs one
30
+ component instead of reaching the router and discarding the whole page.
25
31
  - `<!--abide:await:CHILDPATH-->` next → the server STREAMED it; `__abideSwap` has already
26
32
  spliced the child's `<!--[-->…<!--]-->` between the boundary markers before the bundle
27
33
  ran, so claim the boundary open, adopt the inner range in place, claim the boundary close.
@@ -46,27 +52,56 @@ export function mountChild(
46
52
  ordinal?: number,
47
53
  ): void {
48
54
  const run = <T>(build: () => T): T => withOptionalPath(ordinal, build)
49
- const mount = (): { dispose: () => void } =>
50
- run(() => mountRange(parent, factory.build, props, before))
55
+ /* Both boundary ids are the child's render-path, computed here exactly as the server wrote it
56
+ (`renderPath(ordinal)`), so SSR and client agree with no counter. */
57
+ const childPath = run(() => CURRENT_PATH.current)
58
+ const addressed = { open: `abide:c:${childPath}`, close: `/abide:c:${childPath}` }
59
+ /* Mount the child's range in its ADDRESSED boundary (ADR-0049) — the create path and the
60
+ inline-recovery rebuild, so a client-only mount is byte-congruent with the SSR markup and a
61
+ desync has a named close to discard to. */
62
+ const mountAddressed = (where: Node | null): { dispose: () => void } =>
63
+ run(() => mountRange(parent, factory.build, props, where, addressed))
64
+ /* Mount into an anonymous `[ … ]` range — the swapped-in inner range of a STREAMED child, whose
65
+ own address is the outer `abide:await` pair. */
66
+ const mountAnon = (where: Node | null): { dispose: () => void } =>
67
+ run(() => mountRange(parent, factory.build, props, where))
51
68
 
52
69
  const hydration = RENDER.hydration
53
70
  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()
71
+ /* Call mountAddressed() first, THEN register its dispose — pushing the call result inline
72
+ would short-circuit (never mounting) when there is no owner. */
73
+ const handle = mountAddressed(before)
57
74
  OWNER.current?.push(handle.dispose)
58
75
  return
59
76
  }
60
77
 
61
- /* Probe the cursor without consuming it. Streamed iff the next node is this child's boundary. */
62
- const childPath = run(() => CURRENT_PATH.current)
78
+ /* Probe the cursor without consuming it, and classify what the server emitted here by the
79
+ marker data. */
63
80
  const cursor = claimChild(hydration, parent)
64
- const streamed = cursor !== null && commentData(cursor) === `abide:await:${childPath}`
81
+ const marker = cursor !== null ? commentData(cursor) : undefined
65
82
 
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)
83
+ if (marker !== `abide:await:${childPath}`) {
84
+ /* INLINE (ADR-0049) — claim the addressed open, adopt the child's range in place. A
85
+ structural desync inside the child throws through the build; recover by discarding just
86
+ THIS boundary and remounting fresh, so the desync costs one component, not the page. The
87
+ partial scope a throwing build stranded is already disposed (`withScope`/`scope`), so the
88
+ cold rebuild starts clean. */
89
+ const open = openMarker(parent, addressed.open)
90
+ try {
91
+ const handle = run(() =>
92
+ adoptRange(parent, factory.build, props, open, addressed.close),
93
+ )
94
+ OWNER.current?.push(handle.dispose)
95
+ } catch (error) {
96
+ reportHydrationDivergence('component boundary recovered — remounting', {
97
+ path: childPath,
98
+ error,
99
+ })
100
+ const handle = discardAndRebuild(hydration, parent, open, addressed.close, (after) =>
101
+ mountAddressed(after),
102
+ )
103
+ OWNER.current?.push(handle.dispose)
104
+ }
70
105
  return
71
106
  }
72
107
 
@@ -75,7 +110,7 @@ export function mountChild(
75
110
  const inner = claimChild(hydration, parent)
76
111
  const warm = inner !== null && commentData(inner) === RANGE_OPEN
77
112
  if (warm) {
78
- const handle = mount()
113
+ const handle = mountAnon(before)
79
114
  openMarker(parent, `/abide:await:${childPath}`)
80
115
  OWNER.current?.push(handle.dispose)
81
116
  return
@@ -88,7 +123,7 @@ export function mountChild(
88
123
  parent,
89
124
  open,
90
125
  `/abide:await:${childPath}`,
91
- (after) => run(() => mountRange(parent, factory.build, props, after)),
126
+ (after) => mountAnon(after),
92
127
  )
93
128
  OWNER.current?.push(handle.dispose)
94
129
  }
@@ -1,11 +1,9 @@
1
1
  import { RANGE_CLOSE, RANGE_OPEN } from '../runtime/RANGE_MARKER.ts'
2
2
  import { RENDER } from '../runtime/RENDER.ts'
3
- import { scope } from '../runtime/scope.ts'
4
3
  import type { UiProps } from '../runtime/types/UiProps.ts'
5
- import { disposeRange } from './disposeRange.ts'
4
+ import { adoptRange } from './adoptRange.ts'
6
5
  import { fillRange } from './fillRange.ts'
7
6
  import { openMarker } from './openMarker.ts'
8
- import { withScope } from './withScope.ts'
9
7
 
10
8
  /*
11
9
  Mounts a nested child component as a marker-bounded range — the wrapper-free
@@ -22,6 +20,12 @@ marker — mirrors `mountSlot`/`when`). `before` (a skeleton-located node, the b
22
20
  `anchorCursor`) places the range among static siblings on create; hydrate ignores it
23
21
  (the claim cursor drives placement). Returns the markers + a disposer so the hot path
24
22
  can rebuild in place.
23
+
24
+ `bracket` names the range's boundary markers. It defaults to the anonymous `[`/`]` a
25
+ control-flow re-fill and the swapped-in inner range of a streamed child use; `mountChild`
26
+ passes the ADDRESSED `abide:c:PATH` pair (ADR-0049) so a component's range carries its own
27
+ render-path — the same markers on create and SSR (congruent) and a named close a desync can
28
+ discard to.
25
29
  */
26
30
  // @documentation plumbing
27
31
  export function mountRange(
@@ -29,17 +33,16 @@ export function mountRange(
29
33
  build: (host: Node, props?: UiProps) => void,
30
34
  props: UiProps | undefined,
31
35
  before: Node | null = null,
36
+ bracket: { open: string; close: string } = DEFAULT_BRACKET,
32
37
  ): { start: Comment; end: Comment; dispose: () => void } {
33
38
  const hydration = RENDER.hydration
34
- const start = openMarker(parent, RANGE_OPEN, before)
39
+ const start = openMarker(parent, bracket.open, before)
35
40
  if (hydration === undefined) {
36
- const end = openMarker(parent, RANGE_CLOSE, before)
41
+ const end = openMarker(parent, bracket.close, before)
37
42
  return fillRange(start, end, build, props)
38
43
  }
39
- /* Hydrate: adopt the server range in place. Establish the child's lexical scope
40
- and render pass (same as `fillRange`), build claiming the existing nodes, then
41
- claim the end marker the build's content stops before. */
42
- const scoped = withScope(() => scope(() => build(parent, props)))
43
- const end = openMarker(parent, RANGE_CLOSE)
44
- return { start, end, dispose: disposeRange(scoped, start, end) }
44
+ /* Hydrate: adopt the server range in place, claiming `bracket.close` as the end marker. */
45
+ return adoptRange(parent, build, props, start, bracket.close)
45
46
  }
47
+
48
+ const DEFAULT_BRACKET = { open: RANGE_OPEN, close: RANGE_CLOSE }
@@ -70,7 +70,22 @@ function bindKeys(
70
70
  if (skip.has(key)) {
71
71
  continue
72
72
  }
73
- const value = object[key]
73
+ /* Reading the value can itself throw a `SuspenseSignal`: when `source()` is a
74
+ restProps proxy (the `<Button {...rest}>` case), its `get` invokes the underlying
75
+ prop thunk, so a key whose expression reads a still-pending blocking `await` suspends
76
+ HERE — after the `source()` enumeration guard already passed. Classify it as an
77
+ attribute; `attr`'s own effect swallows the suspend and fills the value in on settle.
78
+ An event handler never suspends — its thunk returns a function without reading a cell. */
79
+ let value: unknown
80
+ try {
81
+ value = object[key]
82
+ } catch (signal) {
83
+ if (!(signal instanceof SuspenseSignal)) {
84
+ throw signal
85
+ }
86
+ attr(element, key, () => source()[key])
87
+ continue
88
+ }
74
89
  if (key.startsWith('on') && typeof value === 'function') {
75
90
  on(element, key.slice(2), value as EventListener)
76
91
  } else if (typeof value !== 'function') {
@@ -1,13 +1,7 @@
1
1
  import type { FlightPromise } from './flight.ts'
2
- import { RANGE_CLOSE, RANGE_OPEN } from './runtime/RANGE_MARKER.ts'
3
2
  import type { ResumeEntry } from './runtime/RESUME.ts'
4
3
  import type { SsrAwait, SsrRender } from './runtime/types/SsrRender.ts'
5
4
 
6
- /* Comment-wrapped range markers — imported from the shared constant (not a local literal) so the
7
- inline branch below can never drift from `generateSSR`'s own range emit. */
8
- const OPEN = `<!--${RANGE_OPEN}-->`
9
- const CLOSE = `<!--${RANGE_CLOSE}-->`
10
-
11
5
  /* One hoistable child render staged by generateSSR: the reserved index in its output array, the
12
6
  array itself, the child's render-path (the streamed boundary id), and its in-flight render. */
13
7
  export type StagedChild = {
@@ -24,9 +18,9 @@ hoistable child was started as an isolated `$$flight` in the prefix and its outp
24
18
  (an empty `$out` slot); here we fill that slot per child:
25
19
 
26
20
  - A flight that has already SETTLED (a synchronous / warm-cache child, or a fast read) inlines its
27
- html into the reserved slot as `<!--[-->…<!--]-->` — BYTE-IDENTICAL to the pre-ADR-0039 inline
28
- `await Child.render()` path, so an all-fast page's wire and hydration are unchanged. Its awaits /
29
- resume merge exactly as the inline path did.
21
+ html into the reserved slot in the child's addressed `abide:c:CHILDPATH` boundary (ADR-0049) —
22
+ the same shape `generateSSR`'s non-hoistable inline path emits, so an all-fast page adopts and
23
+ recovers uniformly. Its awaits / resume merge exactly as the inline path did.
30
24
  - A flight settled REJECTED rethrows here — same as a rejecting inline await (500 before flush).
31
25
  - A still-PENDING flight (genuine I/O) STREAMS: the slot gets an empty `abide:await:CHILDPATH`
32
26
  boundary, and an html-only SsrAwait is pushed so `renderToStream` flushes the shell now and streams
@@ -53,7 +47,12 @@ export async function finalizeStreamedChildren(
53
47
  for (const staged of slots) {
54
48
  if (staged.flight.settled && staged.flight.error === undefined) {
55
49
  const rendered = staged.flight.value as SsrRender
56
- staged.out[staged.slot] = OPEN + rendered.html + CLOSE
50
+ /* Inline the settled html in the child's ADDRESSED boundary (ADR-0049) — the same
51
+ `abide:c:CHILDPATH` shape `generateSSR`'s non-hoistable path emits, so the client's
52
+ `mountChild` adopts a hoistable-settled and a non-hoistable child identically (it can't
53
+ know which the server chose) and either recovers at this boundary on a desync. */
54
+ staged.out[staged.slot] =
55
+ `<!--abide:c:${staged.id}-->${rendered.html}<!--/abide:c:${staged.id}-->`
57
56
  mergeChildRender(rendered, awaits, resume)
58
57
  } else if (staged.flight.settled) {
59
58
  throw staged.flight.error
@@ -7,7 +7,9 @@
7
7
 
8
8
  A control-flow block's rendered content sits between an OPEN (`[`) and CLOSE (`]`)
9
9
  comment; a snippet interpolation between `abide:snippet` / `/abide:snippet` (matching
10
- `skeleton`'s `abide:` / `/abide:` named-boundary convention, like `OUTLET_MARKER`).
10
+ `skeleton`'s `abide:` / `/abide:` named-boundary convention, like `OUTLET_MARKER`); a
11
+ component mount between the ADDRESSED pair `abide:c:PATH` / `/abide:c:PATH` (PATH the
12
+ child's render-path — ADR-0049 — so a desync inside recovers at that one boundary).
11
13
  The skeleton's own positioning anchor (`a`) sits OUTSIDE any such range. */
12
14
  export const RANGE_OPEN = '['
13
15
  export const RANGE_CLOSE = ']'
@@ -7,6 +7,7 @@ import { cacheStoreSlot } from '../shared/cacheStoreSlot.ts'
7
7
  import { createCacheStore } from '../shared/createCacheStore.ts'
8
8
  import { pageSlot } from '../shared/pageSlot.ts'
9
9
  import { SOCKET_SEED } from '../shared/SOCKET_SEED.ts'
10
+ import { SSR_SCRIPT_ID } from '../shared/SSR_SCRIPT_ID.ts'
10
11
  import { sharedCacheStoreSlot } from '../shared/sharedCacheStoreSlot.ts'
11
12
  import type { SsrPayload } from '../shared/types/SsrPayload.ts'
12
13
  import type { StreamedResolution } from '../shared/types/StreamedResolution.ts'
@@ -32,6 +33,39 @@ navigation — importing each further page's chunk on first visit and probing th
32
33
  destination through the server's app.handle so auth/redirect gating still applies.
33
34
  Returns a disposer. `target` defaults to `#app`; pass one explicitly in tests.
34
35
  */
36
+ /*
37
+ Reads the server's SSR payload. Production ships it as an inert `<script
38
+ type="application/json">` (ADR-0051) parsed once here in the deferred bundle — off the
39
+ critical path, and through the fast JSON grammar rather than a compiled multi-MB JS object
40
+ literal. A pre-set `globalThis.__SSR__` wins first: the uiStartClient tests stamp it
41
+ directly, and it preserves any pre-bundle inline stamp. The parsed payload is republished
42
+ onto `globalThis.__SSR__` for devtools / inspector visibility.
43
+ */
44
+ function readSsrPayload(): Partial<SsrPayload> {
45
+ const globalScope = globalThis as { __SSR__?: Partial<SsrPayload> }
46
+ const preset = globalScope.__SSR__
47
+ if (preset !== undefined) {
48
+ return preset
49
+ }
50
+ const element = typeof document !== 'undefined' ? document.getElementById(SSR_SCRIPT_ID) : null
51
+ const text = element?.textContent
52
+ if (!text) {
53
+ return {}
54
+ }
55
+ /* Degrade a mangled payload to a cold boot rather than throwing out of startClient (a
56
+ proxy/extension could corrupt the script text). The old inline `window.__SSR__ = {…}`
57
+ statement failed the same way — its throw left the global undefined and the `?? {}`
58
+ fallback booted cold — so keep that resilience instead of failing the whole mount. */
59
+ let parsed: Partial<SsrPayload>
60
+ try {
61
+ parsed = JSON.parse(text) as Partial<SsrPayload>
62
+ } catch {
63
+ return {}
64
+ }
65
+ globalScope.__SSR__ = parsed
66
+ return parsed
67
+ }
68
+
35
69
  // @documentation plumbing
36
70
  export function startClient(
37
71
  routes: Record<string, RouteLoader>,
@@ -43,7 +77,7 @@ export function startClient(
43
77
  if (target === null) {
44
78
  throw new Error('[abide] startClient: missing #app target')
45
79
  }
46
- const ssr = (globalThis as { __SSR__?: Partial<SsrPayload> }).__SSR__ ?? {}
80
+ const ssr = readSsrPayload()
47
81
  /* Seed the per-page __SSR__ stamps into their shared slots before mount: the mount
48
82
  base, app name (default log channel), health payload (so health()'s first probe is
49
83
  warm), and the env-configured RPC timeout (ABIDE_CLIENT_TIMEOUT, shipped per