@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 +25 -0
- package/package.json +1 -1
- package/src/lib/server/runtime/createUiPageRenderer.ts +6 -1
- package/src/lib/server/runtime/snapshotEntryFromCache.ts +16 -2
- package/src/lib/shared/SSR_SCRIPT_ID.ts +11 -0
- package/src/lib/shared/cacheEntryFromSnapshot.ts +15 -6
- package/src/lib/shared/types/CacheSnapshotEntry.ts +15 -1
- package/src/lib/ui/compile/generateSSR.ts +9 -3
- package/src/lib/ui/dom/adoptRange.ts +27 -0
- package/src/lib/ui/dom/cloneStatic.ts +32 -1
- package/src/lib/ui/dom/each.ts +23 -9
- package/src/lib/ui/dom/markerDepthDelta.ts +3 -3
- package/src/lib/ui/dom/mountChild.ts +51 -16
- package/src/lib/ui/dom/mountRange.ts +14 -11
- package/src/lib/ui/dom/spreadAttrs.ts +16 -1
- package/src/lib/ui/finalizeStreamedChildren.ts +9 -10
- package/src/lib/ui/runtime/RANGE_MARKER.ts +3 -1
- package/src/lib/ui/startClient.ts +35 -1
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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
-
/*
|
|
56
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
+
}
|
package/src/lib/ui/dom/each.ts
CHANGED
|
@@ -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
|
|
186
|
+
const count = list.length
|
|
181
187
|
generation += 1
|
|
182
188
|
const pass = generation
|
|
183
|
-
/*
|
|
184
|
-
this pass — so prune below is an int compare (no `Set`)
|
|
185
|
-
walk reuses the resolved row instead of a second `rows.get`.
|
|
186
|
-
lookups per reconcile is a real win once the harness's O(n)
|
|
187
|
-
longer swamps it. `undefined` marks a key with no surviving row
|
|
188
|
-
|
|
189
|
-
for (let index = 0; index <
|
|
190
|
-
const
|
|
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
|
|
9
|
-
`0`. Keeping the rule here means a new
|
|
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
|
-
- `<!--
|
|
24
|
-
|
|
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
|
-
|
|
50
|
-
|
|
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
|
|
55
|
-
short-circuit
|
|
56
|
-
const handle =
|
|
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
|
|
62
|
-
|
|
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
|
|
81
|
+
const marker = cursor !== null ? commentData(cursor) : undefined
|
|
65
82
|
|
|
66
|
-
if (
|
|
67
|
-
/* INLINE — the
|
|
68
|
-
|
|
69
|
-
|
|
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 =
|
|
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) =>
|
|
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 {
|
|
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,
|
|
39
|
+
const start = openMarker(parent, bracket.open, before)
|
|
35
40
|
if (hydration === undefined) {
|
|
36
|
-
const end = openMarker(parent,
|
|
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.
|
|
40
|
-
|
|
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
|
-
|
|
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
|
|
28
|
-
|
|
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
|
-
|
|
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 = (
|
|
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
|