@abide/abide 0.57.0 → 0.57.1
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 +17 -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/dom/each.ts +23 -9
- package/src/lib/ui/dom/spreadAttrs.ts +16 -1
- package/src/lib/ui/startClient.ts +35 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
# abide
|
|
2
2
|
|
|
3
|
+
## 0.57.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 866c96d: degrade a mangled **SSR** payload to a cold boot (ADR-0051) ([`adfee02`](https://github.com/briancray/abide/commit/adfee024d570e8bf46230fac1fa2521fcaae056f))
|
|
8
|
+
- 1abc943: Reuse the keyed-list (`{#for}`) reconcile scratch across passes so a steady-state reconcile allocates nothing
|
|
9
|
+
|
|
10
|
+
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.
|
|
11
|
+
|
|
12
|
+
- e137242: `spreadAttrs` — swallow a per-key suspend on a `{...rest}` spread
|
|
13
|
+
|
|
14
|
+
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.
|
|
15
|
+
|
|
16
|
+
- 7367a23: SSR hydration payload ships as inert JSON instead of executable JavaScript (ADR-0051)
|
|
17
|
+
|
|
18
|
+
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.
|
|
19
|
+
|
|
3
20
|
## 0.57.0
|
|
4
21
|
|
|
5
22
|
### 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
|
}
|
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. */
|
|
@@ -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') {
|
|
@@ -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
|