@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abide/abide",
3
- "version": "0.57.0",
3
+ "version": "0.57.1",
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
  }
@@ -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. */
@@ -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') {
@@ -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