@abide/abide 0.32.0 → 0.32.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/AGENTS.md CHANGED
@@ -181,6 +181,7 @@ Same selector grammar as `cache.invalidate`; also accept a `Subscribable`. See C
181
181
  Valid HTML with `<script>` + native `<template>` control flow + scoped `<style>`.
182
182
  - **Bindings:** `{expr}` text, `name={expr}` attr, `onclick={fn}`, `bind:value={…}` / `bind:checked` / `bind:group`, `attach={fn}` (node-lifetime attachment — the dual of `on`; the `use:`-action / `{@attach}` equivalent, lowered to `ui/dom/attach`).
183
183
  - **Control flow (native `<template>`):** `if`/`else`, `each={list} as="x" key="x.id"`, `await={p}`/`then`/`catch`, `switch`/`case`/`default`.
184
+ - **A branch is a lexical scope:** any control-flow branch may host its own nested `<script>` and `<style>`. The nested `<script>` declares branch-local **plain** signals (`state`/`derived`/`linked`/`prop`) — owned by the branch's render scope, re-seeded each mount (not the serializable top-level `doc`) — with the branch's binding in scope (`then`/`catch`'s `as` value, the `each` `as`/`key` row), so it can derive from the awaited/iterated value; its bindings cover the branch subtree and later siblings auto-deref them. The nested `<style>` is scoped to that branch alone (its own `data-a-<hash>`), not the whole component.
184
185
  - **Components:** capitalised tags (`<Layout title=…>`); children fill `<slot/>`; props are reactive (passed as thunks). A component has no directives — every attribute is a prop under its written name (so `onclick=`/`bind:open=`/`attach=` pass through as props, e.g. callbacks, not the DOM-element directives those are on a lowercase tag) and is type-checked against the child's declared props. `prop('name')` reads a typed component prop (the parent-supplied thunk, reactive + read-only); route params come from the `page` proxy (`page.params.name`), not `prop()`.
185
186
  - **Snippets / named slots:** `<template name="x" args={…}>` declares a reusable named builder (the `snippet()` form), rendered like a function — covers named slots / `{@render}`.
186
187
  - **Reactivity:** write plain assignment (`count += 1`, `items.push(x)`); the compiler lowers it to patches. Deep-field edits wake only that field.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # abide
2
2
 
3
+ ## 0.32.1
4
+
5
+ ### Patch Changes
6
+
7
+ - [`660ce08`](https://github.com/briancray/abide/commit/660ce08109160156dc9697fcb223fbb335229243) - fold asset-serve request logging into a timedServe helper ([`17aa14a`](https://github.com/briancray/abide/commit/17aa14aa3f04abe76a2e06b0c2fb0a8d4e725603))
8
+
9
+ - [`660ce08`](https://github.com/briancray/abide/commit/660ce08109160156dc9697fcb223fbb335229243) - swallow the superseded each-async iterator's return rejection ([`5e2898f`](https://github.com/briancray/abide/commit/5e2898ff8a785508aa8fde70f3fc351dcdc8e014))
10
+
11
+ - [`9643066`](https://github.com/briancray/abide/commit/9643066cf19bb944a55509333fd1421a3a8cce7a) - fix(check): narrow `<template else>` against the condition's negation. The shadow type-checker emitted a nested `else` child inside the `if` block, so the else body inherited the if's positive narrowing — a literal-union compare read as "no overlap" and a `typeof`-narrowed branch saw the wrong member. It now pairs the `else` child as a real `if (…) {…} else {…}`, matching the runtime's pairing.
12
+
13
+ - [`9643066`](https://github.com/briancray/abide/commit/9643066cf19bb944a55509333fd1421a3a8cce7a) - fix(check): value-project a nested control-flow `<script>` like the leading one. The shadow type-checker emitted a branch-scoped `<script>` body raw, so its `state`/`derived` declarations kept their `State<T>`/`Derived<T>` types — every read of a nested signal in the branch's markup false-positived (`'Derived<string>' and 'string' have no overlap`, `Property 'length' does not exist on type 'Derived<…>'`). It now rewrites a nested script's reactive declarations to their value types, matching the runtime, which derefs nested-script signals through the rest of the branch.
14
+
15
+ - [`0c54e5a`](https://github.com/briancray/abide/commit/0c54e5a025eef96ebc7eece3f51b8a167241434d) - fix(ui): don't emit a void element as a component's mount wrapper. A component instance mounts into a wrapper tag derived from its name (`<Search>`→`<search>`); when the name lowercases to a void element (`<Input>`→`<input>`, `<Img>`→`<img>`) the wrapper self-closes and the HTML parser reparents the component's own markup as the wrapper's siblings, so on hydration `openChild` finds the wrapper empty, claims `null`, and `attr` throws `null is not an object (setAttribute)` — aborting hydration. Such names now map to a hyphenated custom-element tag (`abide-input`, never void) made layout-transparent with `display:contents`, so the child's real root still lays out as a direct child of the parent. Both the SSR string and the client build go through the shared `componentWrapperTag`, keeping them in agreement.
16
+
17
+ - [`9695bea`](https://github.com/briancray/abide/commit/9695bea26309b693d6b61a4dec1641559421c714) - perf(ui): carry the streamed await-block resume value in a `<script type="application/json">` child instead of a `data-resume` attribute. The attribute form HTML-escaped the JSON (`"` → `&quot;`, plus `&`/`<` passes), inflating the raw payload ~38% and costing two full-string regex passes per render (~2.6 ms/MB; ~10 ms for a 3.7 MB payload). Script content is raw text, so only `<` is neutralized as the JSON escape — ~130–150× cheaper to encode and no quote inflation. `applyResolved` and the inline `SSR_SWAP_SCRIPT` now read the value via `.textContent` and drop the script before swapping the resolved markup into its boundary.
18
+
3
19
  ## 0.32.0
4
20
 
5
21
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abide/abide",
3
- "version": "0.32.0",
3
+ "version": "0.32.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",
@@ -1,6 +1,7 @@
1
1
  /*
2
2
  The tiny inline script the abide-ui SSR stream ships in <head>. For each streamed
3
- `<abide-resolve data-id data-resume>` frame it registers the resolved value into
3
+ `<abide-resolve data-id>` frame it reads the leading `<script type=application/json>`,
4
+ registers the resolved value into
4
5
  `window.__abideResume` (the resume manifest hydration reads) and swaps the resolved
5
6
  markup into the matching `<!--abide:await:ID-->…<!--/abide:await:ID-->` boundary —
6
7
  so the pending shell paints instantly and each value lands as it arrives, before
@@ -9,8 +10,8 @@ minified to one line so it inlines cheaply ahead of the document body.
9
10
  */
10
11
  export const SSR_SWAP_SCRIPT =
11
12
  "function __abideSwap(){var f=document.querySelector('abide-resolve');while(f){" +
12
- "var id=f.getAttribute('data-id'),w=document.createTreeWalker(document.body,NodeFilter.SHOW_COMMENT),o=null,c;" +
13
- "try{(window.__abideResume=window.__abideResume||{})[id]=JSON.parse(f.getAttribute('data-resume')||'null');}catch(e){}" +
13
+ "var id=f.getAttribute('data-id'),p=f.firstChild,w=document.createTreeWalker(document.body,NodeFilter.SHOW_COMMENT),o=null,c;" +
14
+ "if(p&&p.nodeName==='SCRIPT'){try{(window.__abideResume=window.__abideResume||{})[id]=JSON.parse(p.textContent||'null');}catch(e){}p.remove();}" +
14
15
  "while((c=w.nextNode())){if(c.data==='abide:await:'+id){o=c;break;}}" +
15
16
  "if(o){var n=o.nextSibling;while(n&&!(n.nodeType===8&&n.data==='/abide:await:'+id)){var x=n.nextSibling;n.remove();n=x;}" +
16
17
  "while(f.firstChild){o.parentNode.insertBefore(f.firstChild,n);}}f.remove();f=document.querySelector('abide-resolve');}}"
@@ -238,6 +238,32 @@ export async function createServer({
238
238
  /* Request closing records are on by default — DEBUG=-abide is the off switch (negation, like the abide channel itself). */
239
239
  const logRequests = !isDebugNegated('abide')
240
240
 
241
+ /*
242
+ Time an asset serve and emit its closing record when logging is on. A miss
243
+ (undefined, from the public server) passes through unlogged so the request
244
+ can fall through to the 404 path.
245
+ */
246
+ const timedServe = async <T extends Response | undefined>(
247
+ serve: () => Promise<T>,
248
+ req: Request,
249
+ url: URL,
250
+ ): Promise<T> => {
251
+ if (!logRequests) {
252
+ return serve()
253
+ }
254
+ const start = Bun.nanoseconds()
255
+ const response = await serve()
256
+ if (response) {
257
+ logClosingRecord(
258
+ req.method,
259
+ `${url.pathname}${url.search}`,
260
+ response.status,
261
+ (Bun.nanoseconds() - start) / 1e6,
262
+ )
263
+ }
264
+ return response
265
+ }
266
+
241
267
  // App-configured headers extend the in-process forward allowlist for the process lifetime.
242
268
  extraForwardHeaders.set(app?.forwardHeaders ?? [])
243
269
 
@@ -546,19 +572,7 @@ export async function createServer({
546
572
  catches anything that goes wrong inside serveAppAsset.
547
573
  */
548
574
  if (url.pathname.startsWith('/_app/')) {
549
- if (!logRequests) {
550
- return serveAppAsset(req, url)
551
- }
552
- const start = Bun.nanoseconds()
553
- const response = await serveAppAsset(req, url)
554
- const ms = (Bun.nanoseconds() - start) / 1e6
555
- logClosingRecord(
556
- req.method,
557
- `${url.pathname}${url.search}`,
558
- response.status,
559
- ms,
560
- )
561
- return response
575
+ return timedServe(() => serveAppAsset(req, url), req, url)
562
576
  }
563
577
  /*
564
578
  Files under public/ are served at the site root, sidestepping
@@ -566,17 +580,8 @@ export async function createServer({
566
580
  undefined so the request falls through to the 404 / middleware
567
581
  path below.
568
582
  */
569
- const publicStart = Bun.nanoseconds()
570
- const publicResponse = await servePublicAsset(req, url)
583
+ const publicResponse = await timedServe(() => servePublicAsset(req, url), req, url)
571
584
  if (publicResponse) {
572
- if (logRequests) {
573
- logClosingRecord(
574
- req.method,
575
- `${url.pathname}${url.search}`,
576
- publicResponse.status,
577
- (Bun.nanoseconds() - publicStart) / 1e6,
578
- )
579
- }
580
585
  return publicResponse
581
586
  }
582
587
  /*
@@ -27,7 +27,7 @@ Response out.
27
27
 
28
28
  A page with no `await` block renders synchronously and ships buffered. A page with
29
29
  await blocks STREAMS: the pending shell flushes first, then each resolved fragment
30
- (`<abide-resolve data-resume>`) as its promise settles, swapped into its boundary
30
+ (`<abide-resolve>` carrying a JSON `<script>`) as its promise settles, swapped into its boundary
31
31
  by the inline SSR_SWAP_SCRIPT — which also registers the value into the resume
32
32
  manifest so client hydration adopts it without re-fetching (see abide/ui/awaitBlock).
33
33
 
@@ -184,6 +184,27 @@ function analyzeScript(scriptBody: string, scriptStart: number): ScriptAnalysis
184
184
  return { imports, types, scope, props }
185
185
  }
186
186
 
187
+ /* Value-projects a nested control-flow `<script>` body the way `analyzeScript`
188
+ projects the leading script's scope: reactive declarations become their value
189
+ type, every other statement stays verbatim. Returns the projected source text
190
+ (unmapped — nested scripts carry no source offset yet), so a branch's markup
191
+ reads a nested signal as its value type instead of the raw `State`/`Derived`. */
192
+ function projectNestedScript(code: string): string {
193
+ const file = ts.createSourceFile('nested.ts', code, ts.ScriptTarget.Latest, true)
194
+ const verbatim = (node: ts.Node): string => code.slice(node.getStart(file), node.getEnd())
195
+ /* No mapping: a zero-length segment the caller drops. */
196
+ const span = (): ShadowMapping => ({ shadowStart: 0, sourceStart: 0, length: 0 })
197
+ return file.statements
198
+ .flatMap((statement) => {
199
+ const reactive = reactiveDeclarations(statement)
200
+ if (reactive === undefined) {
201
+ return [verbatim(statement)]
202
+ }
203
+ return reactive.map((declaration) => scopeLineFor(declaration, [], verbatim, span).text)
204
+ })
205
+ .join('\n')
206
+ }
207
+
187
208
  /* The `state`/`derived`/`prop` declarations in a variable statement, or undefined
188
209
  if it isn't one declaring them (so the caller emits it verbatim). A statement
189
210
  mixing reactive and plain declarations is rare; treated as all-verbatim. */
@@ -264,51 +285,13 @@ function scopeLineFor(
264
285
  return { text: `let ${name} = props[${JSON.stringify(keyText)}];`, segments: [] }
265
286
  }
266
287
 
267
- /* Emits a sibling list. Walks with lookahead so an `if` and its trailing `else`
268
- (the next meaningful sibling — a `case` with no match) fuse into one
269
- `if (…) {…} else {…}`, giving the else branch the condition's negative narrowing
270
- instead of being checked bare against the un-narrowed type. Every other node is
271
- emitted standalone via `emitNode`. */
288
+ /* Emits a sibling list each node standalone via `emitNode`. */
272
289
  function emitNodes(nodes: TemplateNode[], builder: Builder): void {
273
- for (let index = 0; index < nodes.length; index += 1) {
274
- const node = nodes[index]
275
- if (node === undefined) {
276
- continue
277
- }
278
- if (node.kind !== 'if') {
290
+ for (const node of nodes) {
291
+ if (node !== undefined) {
279
292
  emitNode(node, builder)
280
- continue
281
- }
282
- builder.raw('if ')
283
- builder.expr(node.condition, node.loc)
284
- builder.raw(' {\n')
285
- emitNodes(node.children, builder)
286
- builder.raw('}')
287
- const elseIndex = nextMeaningful(nodes, index + 1)
288
- const elseNode = elseIndex === -1 ? undefined : nodes[elseIndex]
289
- if (elseNode?.kind === 'case' && elseNode.match === undefined) {
290
- builder.raw(' else {\n')
291
- emitNodes(elseNode.children, builder)
292
- builder.raw('}')
293
- index = elseIndex
294
- }
295
- builder.raw('\n')
296
- }
297
- }
298
-
299
- /* Index of the next node that isn't whitespace-only text (which separates the `if`
300
- and `else` tags in source but carries no checkable content); -1 if none remain. */
301
- function nextMeaningful(nodes: TemplateNode[], from: number): number {
302
- for (let index = from; index < nodes.length; index += 1) {
303
- const node = nodes[index]
304
- const blank =
305
- node?.kind === 'text' &&
306
- node.parts.every((part) => part.kind === 'static' && part.value.trim() === '')
307
- if (!blank) {
308
- return index
309
293
  }
310
294
  }
311
- return -1
312
295
  }
313
296
 
314
297
  /* Emits a template node's expressions into the shadow's render body. Control flow
@@ -353,15 +336,31 @@ function emitNode(node: TemplateNode, builder: Builder): void {
353
336
  emitNodes(node.children, builder)
354
337
  return
355
338
  }
356
- case 'if':
357
- /* Reached only for an `if` emitted outside a sibling list (none today);
358
- `emitNodes` owns the `if`/`else` fusion. Emit without an else. */
339
+ case 'if': {
340
+ /* The optional `<template else>` is a match-less `case` CHILD (the runtime
341
+ pairs it the same way — see `generateIf`); the rest are the then-content.
342
+ Emitting it as a real `else` gives its body the condition's NEGATIVE
343
+ narrowing — emitting it inside the `if` block (as a plain child) instead
344
+ gave it the positive narrowing, so a literal-union compare read as a
345
+ "no overlap" and a typeof-narrowed branch saw the wrong member. */
346
+ const elseChild = node.children.find(
347
+ (child): child is Extract<TemplateNode, { kind: 'case' }> =>
348
+ child.kind === 'case' && child.match === undefined,
349
+ )
350
+ const thenChildren = node.children.filter((child) => child !== elseChild)
359
351
  builder.raw('if ')
360
352
  builder.expr(node.condition, node.loc)
361
353
  builder.raw(' {\n')
362
- emitNodes(node.children, builder)
363
- builder.raw('}\n')
354
+ emitNodes(thenChildren, builder)
355
+ builder.raw('}')
356
+ if (elseChild !== undefined) {
357
+ builder.raw(' else {\n')
358
+ emitNodes(elseChild.children, builder)
359
+ builder.raw('}')
360
+ }
361
+ builder.raw('\n')
364
362
  return
363
+ }
365
364
  case 'each':
366
365
  /* `for await` over an async each's AsyncIterable, plain `for…of` otherwise —
367
366
  so the item binds to the element type under either iteration protocol. */
@@ -436,8 +435,8 @@ function emitNode(node: TemplateNode, builder: Builder): void {
436
435
  builder.raw('}\n')
437
436
  return
438
437
  case 'case':
439
- /* Reached only for a stray case outside a switch/if-else (none today); a
440
- `switch` emits its own cases and `emitNodes` consumes an `else`. */
438
+ /* Reached only for a stray case outside a switch/if (none today); a `switch`
439
+ emits its own cases and the `if` handler consumes its `else` child. */
441
440
  if (node.match !== undefined) {
442
441
  builder.stmt(node.match, node.loc)
443
442
  }
@@ -464,14 +463,16 @@ function emitNode(node: TemplateNode, builder: Builder): void {
464
463
  builder.raw('};\n')
465
464
  return
466
465
  case 'script':
467
- /* A scoped reactive `<script>`: emit its body INLINE in the current block,
468
- not a nested `{}` so its bindings are visible to the branch's later
469
- siblings (a nested if/each within the same branch), matching runtime
470
- scope where a nested script's declarations deref through the rest of the
471
- branch. A wrapping block trapped them, surfacing "Cannot find name" on a
472
- sibling. Leading `;` guards a preceding semicolon-less call from merging
473
- in (see the component case). Not yet position-mapped (rare). */
474
- builder.raw(`;\n${node.code}\n`)
466
+ /* A scoped reactive `<script>`: value-project its reactive declarations to
467
+ their value types (`derived()` the computed value, `state(…)` the
468
+ initial) exactly as the leading script's scope lines are, so the branch's
469
+ markup type-checks a nested signal as its value matching the runtime,
470
+ which derefs nested-script signals through the rest of the branch. Emitted
471
+ INLINE in the current block, not a nested `{…}`, so its bindings reach the
472
+ branch's later siblings (a nested if/each); a wrapping block trapped them,
473
+ surfacing "Cannot find name". Leading `;` guards a preceding semicolon-less
474
+ call from merging in (see the component case). Not yet position-mapped. */
475
+ builder.raw(`;\n${projectNestedScript(node.code)}\n`)
475
476
  return
476
477
  case 'style':
477
478
  /* CSS, not TypeScript — nothing for the shadow to type-check. */
@@ -0,0 +1,20 @@
1
+ import { VOID_TAGS } from './VOID_TAGS.ts'
2
+
3
+ /*
4
+ The element tag a component instance mounts into. Normally the component name
5
+ lowercased — readable in devtools, a real box like any abide wrapper. But a name
6
+ that lowercases to a VOID element (`Input`→`input`, `Img`→`img`) would yield a
7
+ wrapper the HTML parser self-closes, reparenting the component's own markup as the
8
+ wrapper's siblings — so on hydration `openChild` finds the wrapper empty, claims
9
+ `null`, and `attr` throws on it. Those names map to a hyphenated custom-element tag
10
+ (a custom element is never void) made layout-transparent with `display:contents`,
11
+ so the component's real root still lays out as a direct child of the parent the way
12
+ the (parse-broken) void wrapper effectively did. Both back-ends call this so the SSR
13
+ string and the client build agree on the wrapper.
14
+ */
15
+ export function componentWrapperTag(name: string): { tag: string; transparent: boolean } {
16
+ const lower = name.toLowerCase()
17
+ return VOID_TAGS.has(lower)
18
+ ? { tag: `abide-${lower}`, transparent: true }
19
+ : { tag: lower, transparent: false }
20
+ }
@@ -1,5 +1,6 @@
1
1
  import { OUTLET_TAG } from '../runtime/OUTLET_TAG.ts'
2
2
  import { bindListenEvent } from './bindListenEvent.ts'
3
+ import { componentWrapperTag } from './componentWrapperTag.ts'
3
4
  import { groupBindParts } from './groupBindParts.ts'
4
5
  import { lowerContext } from './lowerContext.ts'
5
6
  import { partitionSlots } from './partitionSlots.ts'
@@ -283,10 +284,14 @@ export function generateBuild(
283
284
  node: Extract<TemplateNode, { kind: 'component' }>,
284
285
  parentVar: string,
285
286
  ): string {
286
- return mountComponent(
287
+ const { tag, transparent } = componentWrapperTag(node.name)
288
+ const { code, varName } = mountComponent(
287
289
  node,
288
- `openChild(${parentVar}, ${JSON.stringify(node.name.toLowerCase())})`,
289
- ).code
290
+ `openChild(${parentVar}, ${JSON.stringify(tag)})`,
291
+ )
292
+ /* A void-name remap is layout-transparent so the child's root stays a direct
293
+ child of the parent (idempotent on a claimed SSR node that already has it). */
294
+ return transparent ? `${code}${varName}.setAttribute("style", "display:contents");\n` : code
290
295
  }
291
296
 
292
297
  /* An await block: pending → resolved(value) / error branches. Each branch is a
@@ -1,4 +1,5 @@
1
1
  import { OUTLET_TAG } from '../runtime/OUTLET_TAG.ts'
2
+ import { componentWrapperTag } from './componentWrapperTag.ts'
2
3
  import { groupBindParts } from './groupBindParts.ts'
3
4
  import { lowerContext } from './lowerContext.ts'
4
5
  import { partitionSlots } from './partitionSlots.ts'
@@ -154,7 +155,7 @@ export function generateSSR(
154
155
  the same wrapper the client mounts into, so SSR and client agree.
155
156
  Props pass as thunks; slot content passes as a string-returning
156
157
  `$children` the child invokes from its <slot>. */
157
- const tag = node.name.toLowerCase()
158
+ const { tag, transparent } = componentWrapperTag(node.name)
158
159
  const parts = node.props.map(
159
160
  (prop) => `${JSON.stringify(prop.name)}: () => (${lowerExpression(prop.code)})`,
160
161
  )
@@ -181,7 +182,7 @@ export function generateSSR(
181
182
  the enclosing render body, including from branch closures.) */
182
183
  const result = nextVar('$child')
183
184
  return (
184
- push(target, `<${tag}>`) +
185
+ push(target, `<${tag}${transparent ? ' style="display:contents"' : ''}>`) +
185
186
  `const ${result} = ${node.name}.render({ ${parts.join(', ')} });\n` +
186
187
  `${target}.push(${result}.html);\n` +
187
188
  `for (const $a of ${result}.awaits) { $awaits.push($a); }\n` +
@@ -2,8 +2,8 @@ import { RESUME } from '../runtime/RESUME.ts'
2
2
 
3
3
  /*
4
4
  Client consumer of an SSR stream fragment. Parses a streamed
5
- `<abide-resolve data-id="ID" data-resume="">…</abide-resolve>` frame, registers
6
- its serialized value in the resume manifest (for later hydration), finds the
5
+ `<abide-resolve data-id="ID"><script type="application/json">…</script>…</abide-resolve>`
6
+ frame, registers its serialized value in the resume manifest (for later hydration), finds the
7
7
  matching `<!--abide:await:ID-->…<!--/abide:await:ID-->` boundary in `root`, removes
8
8
  the pending nodes between the markers, and inserts the resolved content in their
9
9
  place. The pending shell painted instantly; this swaps in each value as it
@@ -21,14 +21,17 @@ export function applyResolved(root: Element, frame: string): void {
21
21
  if (id === null) {
22
22
  return
23
23
  }
24
- /* Record the resolved value so a later hydrate adopts this branch (no re-fetch). */
25
- const resume = resolved.getAttribute('data-resume')
26
- if (resume !== null) {
24
+ /* The resolved value rides in a leading <script type=application/json>; parse and
25
+ remove it so only the resolved markup moves into the boundary. Recording it lets a
26
+ later hydrate adopt this branch (no re-fetch). */
27
+ const payload = resolved.firstChild as Element | null
28
+ if (payload !== null && payload.nodeName === 'SCRIPT') {
27
29
  try {
28
- RESUME[Number(id)] = JSON.parse(resume)
30
+ RESUME[Number(id)] = JSON.parse(payload.textContent ?? 'null')
29
31
  } catch {
30
32
  /* malformed payload — leave unregistered, hydration re-runs the promise */
31
33
  }
34
+ payload.remove()
32
35
  }
33
36
  const open = `abide:await:${id}`
34
37
  const close = `/abide:await:${id}`
@@ -69,7 +69,7 @@ export function eachAsync<T>(
69
69
  effect(() => {
70
70
  generation += 1
71
71
  const generationAtStart = generation
72
- iterator?.return?.() // close the superseded run's iterator before re-streaming
72
+ iterator?.return?.(undefined)?.catch(() => undefined) // close the superseded run's iterator before re-streaming
73
73
  iterator = undefined
74
74
  clearError() // a fresh run drops a prior error branch
75
75
  const iterable = items() // read (subscribe) synchronously
@@ -128,7 +128,7 @@ export function eachAsync<T>(
128
128
  if (OWNER.current !== undefined) {
129
129
  OWNER.current.push(() => {
130
130
  generation += 1
131
- iterator?.return?.()
131
+ iterator?.return?.(undefined)?.catch(() => undefined)
132
132
  iterator = undefined
133
133
  clearError()
134
134
  for (const row of rows.values()) {
@@ -5,10 +5,11 @@ import type { SsrAwait, SsrRender } from './runtime/types/SsrRender.ts'
5
5
  Out-of-order SSR streaming. Yields the pending shell first (so the browser paints
6
6
  immediately), then one resolved fragment per await block as its promise settles —
7
7
  in completion order, not source order, so a slow read never blocks a fast one.
8
- Each resolved fragment is a `<abide-resolve data-id="ID" data-resume="">…</abide-resolve>`
9
- that `applyResolved` swaps into the matching `<!--abide:await:ID-->` boundary; the
10
- `data-resume` payload is the JSON-serialized value, registered for hydration so an
11
- `await` block adopts the resolved branch on resume instead of re-running.
8
+ Each resolved fragment is a `<abide-resolve data-id="ID"><script type="application/json">
9
+ …</script>…</abide-resolve>` that `applyResolved` swaps into the matching
10
+ `<!--abide:await:ID-->` boundary; the leading script holds the JSON-serialized value,
11
+ registered for hydration so an `await` block adopts the resolved branch on resume
12
+ instead of re-running.
12
13
 
13
14
  This is the await-block-streams half of the cache rule: a top-level `await` in the
14
15
  script would have blocked the shell (inlined), but an await *block* flushes its
@@ -46,7 +47,9 @@ export async function* renderToStream(render: () => SsrRender): AsyncGenerator<s
46
47
  const resolved = await Promise.race(inflight.values())
47
48
  inflight.delete(resolved.id)
48
49
  const resume = encodeResume(resolved.resume)
49
- yield `<abide-resolve data-id="${resolved.id}" data-resume="${resume}">${resolved.html}</abide-resolve>`
50
+ yield `<abide-resolve data-id="${resolved.id}">` +
51
+ `<script type="application/json">${resume}</script>` +
52
+ `${resolved.html}</abide-resolve>`
50
53
  }
51
54
  }
52
55
 
@@ -94,11 +97,11 @@ function settle(block: SsrAwait): Promise<Settled> {
94
97
  )
95
98
  }
96
99
 
97
- /* JSON for an HTML double-quoted attribute: escape `"` and `&` (and `<` for safety
98
- inside markup). `applyResolved`/the inline swap script decode it via the DOM. */
100
+ /* JSON for a `<script type="application/json">` data block: script content is raw
101
+ text, so only `<` needs neutralizing (emitted as a unicode escape) to keep a
102
+ literal `</script>` from closing the block early — quotes stay raw. Far cheaper
103
+ than attribute escaping (no full-string `"`/`&` passes) and JSON.parse decodes it
104
+ back. `applyResolved`/the inline swap script read it via `.textContent`. */
99
105
  function encodeResume(resume: ResumeEntry): string {
100
- return JSON.stringify(resume)
101
- .replace(/&/g, '&amp;')
102
- .replace(/"/g, '&quot;')
103
- .replace(/</g, '&lt;')
106
+ return JSON.stringify(resume).replace(/</g, '\\u003c')
104
107
  }