@abide/abide 0.57.1 → 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,13 @@
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
+
3
11
  ## 0.57.1
4
12
 
5
13
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abide/abide",
3
- "version": "0.57.1",
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",
@@ -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
+ }
@@ -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 }
@@ -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 = ']'