@pyreon/runtime-server 0.22.0 → 0.24.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/README.md CHANGED
@@ -1,73 +1,117 @@
1
1
  # @pyreon/runtime-server
2
2
 
3
- Server-side rendering for Pyreon. Renders VNode trees to HTML strings or Web-standard `ReadableStream` chunks with Suspense streaming support.
3
+ VNode HTML renderer with progressive streaming and per-request `AsyncLocalStorage` isolation.
4
+
5
+ Walks a VNode tree and produces an HTML string or a Web-standard `ReadableStream` of chunks. Signal accessors are called synchronously to snapshot their current value — there is no reactivity on the server. `renderToStream` flushes progressively and resolves Suspense boundaries out-of-order (fallback first, then a `<template>` + inline swap script). Every `renderToString` / `renderToStream` / `runWithRequestContext` call runs in its own ALS store so concurrent requests never share `provide()` frames; `configureStoreIsolation()` extends the same isolation to the `@pyreon/store` registry. Most apps consume this transitively through `@pyreon/server.createHandler` or `@pyreon/zero` rather than calling directly.
4
6
 
5
7
  ## Install
6
8
 
7
9
  ```bash
8
- bun add @pyreon/runtime-server
10
+ bun add @pyreon/runtime-server @pyreon/core @pyreon/reactivity
9
11
  ```
10
12
 
11
- ## Quick Start
13
+ ## Quick start
12
14
 
13
- ```tsx
14
- import { renderToString } from '@pyreon/runtime-server'
15
+ ```ts
16
+ import {
17
+ renderToString, renderToStream, runWithRequestContext, configureStoreIsolation,
18
+ } from '@pyreon/runtime-server'
19
+ import { setStoreRegistryProvider } from '@pyreon/store'
15
20
 
16
- function App() {
17
- return <h1>Hello from SSR</h1>
18
- }
21
+ // Once at server startup — wire per-request store isolation:
22
+ configureStoreIsolation(setStoreRegistryProvider)
19
23
 
24
+ // One-shot HTML
20
25
  const html = await renderToString(<App />)
21
- // "<h1>Hello from SSR</h1>"
26
+
27
+ // Progressive stream
28
+ return new Response(renderToStream(<App />), {
29
+ headers: { 'content-type': 'text/html' },
30
+ })
31
+
32
+ // Pre-fetch loader data + render, all under one isolated request context
33
+ const html = await runWithRequestContext(async () => {
34
+ await prefetchLoaderData(router, url.pathname, request)
35
+ return renderToString(<App />)
36
+ })
22
37
  ```
23
38
 
24
- ## API
39
+ ## renderToString
25
40
 
26
- ### `renderToString(vnode)`
41
+ ```ts
42
+ const html = await renderToString(vnode): Promise<string>
43
+ ```
27
44
 
28
- Render a VNode tree to a complete HTML string. Returns `Promise<string>`. Async components are awaited. Each call gets an isolated context stack.
45
+ One-shot HTML. Awaits async components. Each call gets its own context stack — no cross-request leakage even under high concurrency. Returns the complete document fragment for the rendered tree (the surrounding `<!doctype html>` shell is your responsibility).
29
46
 
30
- ### `renderToStream(vnode)`
47
+ ## renderToStream
31
48
 
32
- Render a VNode tree to a `ReadableStream<string>` for progressive HTML streaming. Synchronous subtrees are flushed immediately. Suspense boundaries are streamed out-of-order: the fallback is emitted first, then resolved children are sent as `<template>` elements with inline swap scripts.
49
+ ```ts
50
+ const stream = renderToStream(vnode): ReadableStream<string>
51
+ ```
33
52
 
34
- ```tsx
35
- import { renderToStream } from '@pyreon/runtime-server'
53
+ Progressive flush. Synchronous subtrees stream as soon as they're rendered. `<Suspense>` boundaries are streamed **out-of-order**: the fallback is emitted in-place, and when the async work resolves the resolved children arrive later as a `<template>` element followed by an inline `<script>` that swaps the template into the original slot. The browser parses both inline-and-resolved without needing a second request.
36
54
 
37
- const stream = renderToStream(<App />)
38
- return new Response(stream, {
39
- headers: { 'Content-Type': 'text/html' },
55
+ ```ts
56
+ return new Response(renderToStream(<App />), {
57
+ headers: { 'content-type': 'text/html; charset=utf-8' },
40
58
  })
41
59
  ```
42
60
 
43
- ### `runWithRequestContext(fn)`
61
+ **30-second Suspense timeout**: if a boundary hasn't resolved within 30 seconds, the fallback stays in place forever and a dev-mode warning fires. No error is thrown — the stream completes cleanly with the fallback persisted.
44
62
 
45
- Run an async function with a fresh, isolated context stack and store registry. Useful for calling Pyreon APIs (e.g. `useHead`, route loader prefetching) outside of `renderToString` while maintaining per-request isolation.
63
+ ## Per-request context isolation
46
64
 
47
- ```ts
48
- import { runWithRequestContext } from '@pyreon/runtime-server'
65
+ Pyreon's `provide()` / `useContext` use a module-level context stack at runtime — fine for a browser process with one document. On the server, concurrent requests would share that stack. `runtime-server` wraps each render in an `AsyncLocalStorage` store, so every `renderToString` / `renderToStream` / `runWithRequestContext` call gets its own isolated context frames.
49
66
 
50
- const result = await runWithRequestContext(async () => {
51
- // Pyreon context and stores are isolated to this call
52
- return await prefetchLoaderData(url)
67
+ ```ts
68
+ // Outside any render? Use runWithRequestContext to isolate manual API calls
69
+ await runWithRequestContext(async () => {
70
+ router.preload(pathname, request)
71
+ return renderToString(<App />)
53
72
  })
54
73
  ```
55
74
 
56
- ### `configureStoreIsolation(registryProvider)`
57
-
58
- Wire up per-request store isolation for concurrent SSR. Call once at server startup with a function that hooks your store registry into the request-scoped `AsyncLocalStorage`. Prevents store state from leaking between requests.
75
+ ## Store isolation
59
76
 
60
77
  ```ts
61
78
  import { configureStoreIsolation } from '@pyreon/runtime-server'
79
+ import { setStoreRegistryProvider } from '@pyreon/store'
62
80
 
63
- configureStoreIsolation((provider) => {
64
- // Wire your store registry to use the per-request provider
65
- })
81
+ configureStoreIsolation(setStoreRegistryProvider) // once at startup
66
82
  ```
67
83
 
68
- ## Behavior Notes
84
+ Without this call, the `@pyreon/store` registry is a process-global singleton — concurrent requests would share defined stores. `configureStoreIsolation` plumbs the registry through the same ALS, so each request gets its own store map. **Call once at startup**, not per request. Skip this if you don't use `@pyreon/store`.
85
+
86
+ ## SSR-safe contracts the renderer enforces
87
+
88
+ - **No reactivity.** Signal accessors are snapshotted at render time. No effects are created on the server. `useHead`, `useStore`, and similar APIs run their setup once and read the resulting value.
89
+ - **HTML escape + URL sanitization.** All text content is escaped. URL attributes (`href`, `src`, `action`, `formaction`) reject `javascript:` and `data:` URIs by default.
90
+ - **Event handlers omitted.** `on*` props are stripped from the output — the server can't bind them. Hydration on the client wires them up.
91
+ - **`<For>` key markers** (`<!--k:KEY-->`) are URL-encoded so user-controlled keys cannot break out of the HTML comment. Companion `decodeKeyFromMarker(comment)` helper available for hydration / devtools consumers.
92
+ - **Compiler-emitted reactive props** are resolved via `makeReactiveProps` before each component invocation — parity with the CSR mount.ts path. SSR-rendered HTML matches what the client would render.
93
+
94
+ ## When to use this directly
95
+
96
+ Most Pyreon apps use:
97
+
98
+ - **`@pyreon/server.createHandler`** — full SSR request handler with loader prefetching, error handling, head injection
99
+ - **`@pyreon/zero`** — full meta-framework wrapping the above plus routing, SSG, ISR
100
+
101
+ Use `@pyreon/runtime-server` directly when you need to:
102
+
103
+ - Render a fragment for a non-page response (RSS feed, OG image SVG, email body)
104
+ - Compose your own custom SSR pipeline outside of `@pyreon/server`
105
+ - Generate static HTML at build time without an HTTP layer
106
+
107
+ ## Dev-mode gates
108
+
109
+ Server packages use `typeof process !== 'undefined' && process.env.NODE_ENV !== 'production'` for dev-only diagnostics — the server-runtime convention. Server code doesn't go through Vite's bundle-time replacement (it runs in Node at startup), so the typeof guard reads correctly at runtime.
110
+
111
+ ## Documentation
112
+
113
+ Full docs: [docs.pyreon.dev/docs/runtime-server](https://docs.pyreon.dev/docs/runtime-server) (or `docs/docs/runtime-server.md` in this repo).
114
+
115
+ ## License
69
116
 
70
- - Signal accessors are called synchronously to snapshot their current value. No effects are created on the server.
71
- - Async component functions (`async function Component()`) are fully supported.
72
- - Context isolation uses `AsyncLocalStorage` internally. Each `renderToString` and `renderToStream` call gets its own context stack.
73
- - HTML output is escaped. Event handlers (`on*`) are omitted. URL attributes are sanitized against `javascript:` and `data:` URIs.
117
+ MIT
@@ -5386,7 +5386,7 @@ var drawChart = (function (exports) {
5386
5386
  </script>
5387
5387
  <script>
5388
5388
  /*<!--*/
5389
- const data = {"version":2,"tree":{"name":"root","children":[{"name":"index.js","children":[{"name":"src/index.ts","uid":"2a32a0fd-1"}]}],"isRoot":true},"nodeParts":{"2a32a0fd-1":{"renderedLength":16920,"gzipLength":5559,"brotliLength":0,"metaUid":"2a32a0fd-0"}},"nodeMetas":{"2a32a0fd-0":{"id":"/src/index.ts","moduleParts":{"index.js":"2a32a0fd-1"},"imported":[{"uid":"2a32a0fd-2"},{"uid":"2a32a0fd-3"}],"importedBy":[],"isEntry":true},"2a32a0fd-2":{"id":"node:async_hooks","moduleParts":{},"imported":[],"importedBy":[{"uid":"2a32a0fd-0"}]},"2a32a0fd-3":{"id":"@pyreon/core","moduleParts":{},"imported":[],"importedBy":[{"uid":"2a32a0fd-0"}]}},"env":{"rollup":"4.23.0"},"options":{"gzip":true,"brotli":false,"sourcemap":false}};
5389
+ const data = {"version":2,"tree":{"name":"root","children":[{"name":"index.js","children":[{"name":"src/index.ts","uid":"7e676d25-1"}]}],"isRoot":true},"nodeParts":{"7e676d25-1":{"renderedLength":17671,"gzipLength":5697,"brotliLength":0,"metaUid":"7e676d25-0"}},"nodeMetas":{"7e676d25-0":{"id":"/src/index.ts","moduleParts":{"index.js":"7e676d25-1"},"imported":[{"uid":"7e676d25-2"},{"uid":"7e676d25-3"}],"importedBy":[],"isEntry":true},"7e676d25-2":{"id":"node:async_hooks","moduleParts":{},"imported":[],"importedBy":[{"uid":"7e676d25-0"}]},"7e676d25-3":{"id":"@pyreon/core","moduleParts":{},"imported":[],"importedBy":[{"uid":"7e676d25-0"}]}},"env":{"rollup":"4.23.0"},"options":{"gzip":true,"brotli":false,"sourcemap":false}};
5390
5390
 
5391
5391
  const run = () => {
5392
5392
  const width = window.innerWidth;
package/lib/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { AsyncLocalStorage } from "node:async_hooks";
2
- import { ForSymbol, Fragment, Suspense, captureContextStack, cx, makeReactiveProps, normalizeStyleValue, popContext, runWithHooks, setContextStackProvider } from "@pyreon/core";
2
+ import { ForSymbol, Fragment, Suspense, cx, getContextStackLength, makeReactiveProps, normalizeStyleValue, popContext, runWithHooks, setContextStackProvider } from "@pyreon/core";
3
3
 
4
4
  //#region src/index.ts
5
5
  /**
@@ -55,36 +55,56 @@ async function renderToString(root) {
55
55
  function runWithRequestContext(fn) {
56
56
  return withStoreContext(() => _contextAls.run([], fn));
57
57
  }
58
- /**
59
- * Render a VNode tree to a Web-standard ReadableStream of HTML chunks.
60
- *
61
- * True progressive streaming: HTML is flushed to the client as soon as each
62
- * node is ready. Synchronous subtrees are enqueued immediately; async component
63
- * boundaries are awaited in-order and their output is enqueued as it resolves.
64
- *
65
- * Suspense boundaries are streamed out-of-order: the fallback is emitted
66
- * immediately, and the resolved children are sent as a `<template>` + inline
67
- * swap script once ready — without blocking the rest of the page.
68
- *
69
- * Each renderToStream call gets its own isolated ALS context stack.
70
- */
71
- function renderToStream(root) {
58
+ function renderToStream(root, options = {}) {
72
59
  if (__DEV__) _countSink.__pyreon_count__?.("runtime-server.stream");
73
- return new ReadableStream({ start(controller) {
74
- const enqueue = (chunk) => controller.enqueue(chunk);
75
- let bid = 0;
76
- const ctx = {
77
- pending: [],
78
- nextId: () => bid++,
79
- mainEnqueue: enqueue,
80
- suspenseDepth: 0
81
- };
82
- return withStoreContext(() => _contextAls.run([], () => _streamCtxAls.run(ctx, async () => {
83
- await streamNode(root, enqueue);
84
- while (ctx.pending.length > 0) await Promise.all(ctx.pending.splice(0));
85
- controller.close();
86
- }).catch((err) => controller.error(err))));
87
- } });
60
+ const ac = new AbortController();
61
+ const signal = ac.signal;
62
+ if (options.signal) if (options.signal.aborted) ac.abort(options.signal.reason);
63
+ else options.signal.addEventListener("abort", () => ac.abort(options.signal.reason), { once: true });
64
+ const userTimeout = options.suspenseTimeoutMs;
65
+ const suspenseTimeoutMs = userTimeout === Infinity ? Infinity : userTimeout !== void 0 && Number.isFinite(userTimeout) && userTimeout > 0 ? userTimeout : 3e4;
66
+ return new ReadableStream({
67
+ start(controller) {
68
+ const enqueue = (chunk) => {
69
+ if (signal.aborted) return;
70
+ controller.enqueue(chunk);
71
+ };
72
+ let bid = 0;
73
+ const ctx = {
74
+ pending: [],
75
+ nextId: () => bid++,
76
+ mainEnqueue: enqueue,
77
+ suspenseDepth: 0,
78
+ signal,
79
+ suspenseTimeoutMs
80
+ };
81
+ const abortPromise = signal.aborted ? Promise.resolve() : new Promise((resolve) => {
82
+ signal.addEventListener("abort", () => resolve(), { once: true });
83
+ });
84
+ return withStoreContext(() => _contextAls.run([], () => _streamCtxAls.run(ctx, async () => {
85
+ await streamNode(root, enqueue);
86
+ while (ctx.pending.length > 0) {
87
+ if (signal.aborted) break;
88
+ const batch = Promise.all(ctx.pending.splice(0));
89
+ await Promise.race([batch, abortPromise]);
90
+ }
91
+ try {
92
+ controller.close();
93
+ } catch {}
94
+ }).catch((err) => {
95
+ if (signal.aborted) {
96
+ try {
97
+ controller.close();
98
+ } catch {}
99
+ return;
100
+ }
101
+ controller.error(err);
102
+ })));
103
+ },
104
+ cancel(reason) {
105
+ ac.abort(reason);
106
+ }
107
+ });
88
108
  }
89
109
  async function streamVNode(vnode, enqueue) {
90
110
  if (vnode.type === Fragment) {
@@ -116,7 +136,7 @@ async function streamComponentNode(vnode, enqueue) {
116
136
  return;
117
137
  }
118
138
  if (__DEV__) _countSink.__pyreon_count__?.("runtime-server.component");
119
- const stackLenBefore = captureContextStack().length;
139
+ const stackLenBefore = getContextStackLength();
120
140
  try {
121
141
  const { vnode: output } = runWithHooks(vnode.type, mergeChildrenIntoProps(vnode));
122
142
  const resolved = output instanceof Promise ? await output : output;
@@ -187,7 +207,7 @@ async function streamSuspenseBoundary(vnode, enqueue) {
187
207
  const { fallback, children } = vnode.props;
188
208
  /* c8 ignore start */
189
209
  if (!ctx) {
190
- const stackLenBefore = captureContextStack().length;
210
+ const stackLenBefore = getContextStackLength();
191
211
  const { vnode: output } = runWithHooks(Suspense, vnode.props);
192
212
  try {
193
213
  if (output !== null) await streamNode(output, enqueue);
@@ -204,18 +224,33 @@ async function streamSuspenseBoundary(vnode, enqueue) {
204
224
  await streamNode(fallback ?? null, enqueue);
205
225
  mainEnqueue("</div>");
206
226
  const ctxStore = _contextAls.getStore() ?? [];
207
- const SUSPENSE_TIMEOUT_MS = 3e4;
227
+ const suspenseTimeoutMs = ctx.suspenseTimeoutMs;
208
228
  ctx.pending.push(_contextAls.run(ctxStore, async () => {
209
229
  try {
210
230
  ctx.suspenseDepth++;
211
231
  const buf = [];
212
- if (await Promise.race([streamNode(children ?? null, (s) => buf.push(s)).then(() => "resolved"), new Promise((resolve) => setTimeout(() => resolve("timeout"), SUSPENSE_TIMEOUT_MS))]) === "timeout") {
232
+ let result;
233
+ if (suspenseTimeoutMs === Infinity) {
234
+ await streamNode(children ?? null, (s) => buf.push(s));
235
+ result = "resolved";
236
+ } else {
237
+ let timeoutId;
238
+ try {
239
+ result = await Promise.race([streamNode(children ?? null, (s) => buf.push(s)).then(() => "resolved"), new Promise((resolve) => {
240
+ timeoutId = setTimeout(() => resolve("timeout"), suspenseTimeoutMs);
241
+ })]);
242
+ } finally {
243
+ if (timeoutId !== void 0) clearTimeout(timeoutId);
244
+ }
245
+ }
246
+ if (result === "timeout") {
213
247
  if (__DEV__) {
214
248
  _countSink.__pyreon_count__?.("runtime-server.suspense.fallback");
215
- console.warn(`[Pyreon SSR] Suspense boundary timed out after ${SUSPENSE_TIMEOUT_MS}ms — fallback will remain.`);
249
+ console.warn(`[Pyreon SSR] Suspense boundary timed out after ${suspenseTimeoutMs}ms — fallback will remain.`);
216
250
  }
217
251
  return;
218
252
  }
253
+ if (ctx.signal?.aborted) return;
219
254
  mainEnqueue(`<template id="pyreon-t-${id}">${buf.join("").replace(/<\/template/gi, "<\\/template")}</template>`);
220
255
  mainEnqueue(`<script>__NS("pyreon-s-${id}","pyreon-t-${id}")<\/script>`);
221
256
  } catch (err) {
@@ -260,7 +295,7 @@ async function renderChildren(children) {
260
295
  }
261
296
  async function renderComponent(vnode) {
262
297
  if (__DEV__) _countSink.__pyreon_count__?.("runtime-server.component");
263
- const stackLenBefore = captureContextStack().length;
298
+ const stackLenBefore = getContextStackLength();
264
299
  const { vnode: output } = runWithHooks(vnode.type, mergeChildrenIntoProps(vnode));
265
300
  let html;
266
301
  try {
@@ -288,7 +323,7 @@ async function renderComponent(vnode) {
288
323
  * effect of `provide()` (its context frame) without firing other hooks.
289
324
  */
290
325
  function trimContextStack(targetLen) {
291
- let current = captureContextStack().length;
326
+ let current = getContextStackLength();
292
327
  while (current > targetLen) {
293
328
  popContext();
294
329
  current--;
@@ -31,7 +31,42 @@ declare function runWithRequestContext<T>(fn: () => Promise<T>): Promise<T>;
31
31
  *
32
32
  * Each renderToStream call gets its own isolated ALS context stack.
33
33
  */
34
- declare function renderToStream(root: VNode | null): ReadableStream<string>;
34
+ interface RenderToStreamOptions {
35
+ /**
36
+ * AbortSignal to cancel in-flight Suspense work when the client
37
+ * disconnects (browser back-button, navigation, fetch reader closes,
38
+ * etc.). On abort, the stream's `cancel()` fires the signal —
39
+ * pending Suspense boundaries are abandoned (the fallback HTML they
40
+ * already wrote stays in the buffer that's now closed) and the
41
+ * background async work they spawned is no longer awaited. The work
42
+ * itself isn't forcibly killed (JS has no async cancellation
43
+ * primitive at the language level), but the framework stops blocking
44
+ * on it. Pass your own signal from upstream (e.g. a `Request.signal`)
45
+ * to chain abort propagation.
46
+ */
47
+ signal?: AbortSignal;
48
+ /**
49
+ * Per-boundary Suspense timeout in milliseconds. When an async
50
+ * Suspense child doesn't resolve within this window, its fallback
51
+ * stays visible (the resolved content is dropped — no `<template>`
52
+ * + swap script is emitted) and a dev-mode warning fires. Defaults
53
+ * to 30_000 (30s); unset behavior is byte-identical to the
54
+ * pre-config implementation.
55
+ *
56
+ * Ops control: tighten this for tight-SLA deploys (5_000–10_000 is
57
+ * typical for user-facing apps where a fallback is preferable to a
58
+ * delayed full render). Loosen it (or pass `Infinity` to disable)
59
+ * for renders that legitimately need long async work — exports,
60
+ * reports, scheduled jobs, etc.
61
+ *
62
+ * Values ≤0 or `NaN` fall back to the default. `Infinity` is honored
63
+ * verbatim — the timeout race is skipped entirely so a hung boundary
64
+ * keeps the stream open until the AbortSignal fires or the consumer
65
+ * cancels.
66
+ */
67
+ suspenseTimeoutMs?: number;
68
+ }
69
+ declare function renderToStream(root: VNode | null, options?: RenderToStreamOptions): ReadableStream<string>;
35
70
  /**
36
71
  * Inverse of `safeKeyForMarker` — decode a marker-safe key back to the
37
72
  * original string. Not used by runtime today (hydration does not read
@@ -41,5 +76,5 @@ declare function renderToStream(root: VNode | null): ReadableStream<string>;
41
76
  */
42
77
  declare function decodeKeyFromMarker(encoded: string): string;
43
78
  //#endregion
44
- export { configureStoreIsolation, decodeKeyFromMarker, renderToStream, renderToString, runWithRequestContext };
79
+ export { RenderToStreamOptions, configureStoreIsolation, decodeKeyFromMarker, renderToStream, renderToString, runWithRequestContext };
45
80
  //# sourceMappingURL=index2.d.ts.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pyreon/runtime-server",
3
- "version": "0.22.0",
3
+ "version": "0.24.0",
4
4
  "description": "SSR/SSG renderer for Pyreon — streaming HTML + static generation",
5
5
  "homepage": "https://github.com/pyreon/pyreon/tree/main/packages/runtime-server#readme",
6
6
  "bugs": {
@@ -43,8 +43,8 @@
43
43
  "prepublishOnly": "bun run build"
44
44
  },
45
45
  "dependencies": {
46
- "@pyreon/core": "^0.22.0",
47
- "@pyreon/reactivity": "^0.22.0"
46
+ "@pyreon/core": "^0.24.0",
47
+ "@pyreon/reactivity": "^0.24.0"
48
48
  },
49
49
  "devDependencies": {
50
50
  "@pyreon/manifest": "0.13.1"
package/src/index.ts CHANGED
@@ -16,10 +16,10 @@
16
16
  import { AsyncLocalStorage } from 'node:async_hooks'
17
17
  import type { ClassValue, ComponentFn, ForProps, VNode, VNodeChild } from '@pyreon/core'
18
18
  import {
19
- captureContextStack,
20
19
  cx,
21
20
  ForSymbol,
22
21
  Fragment,
22
+ getContextStackLength,
23
23
  makeReactiveProps,
24
24
  normalizeStyleValue,
25
25
  popContext,
@@ -46,6 +46,22 @@ interface StreamCtx {
46
46
  mainEnqueue: (s: string) => void
47
47
  /** Depth counter — non-zero when rendering inside a Suspense child resolution. */
48
48
  suspenseDepth: number
49
+ /**
50
+ * Abort signal fired when EITHER the upstream caller's signal aborts
51
+ * OR the stream consumer (`ReadableStream.cancel()`) closes. Boundary
52
+ * resolvers check this before enqueuing post-resolve HTML so they
53
+ * stop streaming once the client has hung up.
54
+ */
55
+ signal?: AbortSignal
56
+ /**
57
+ * Per-boundary Suspense timeout (ms). When an async Suspense child
58
+ * doesn't resolve within this window, the fallback stays visible and
59
+ * the resolved content is dropped. Set to `Infinity` to disable the
60
+ * timeout entirely (apps that prefer waiting indefinitely over showing
61
+ * the fallback). Defaults to 30_000 (30s) — matches the pre-config
62
+ * hard-coded value, so unset is byte-identical to prior behavior.
63
+ */
64
+ suspenseTimeoutMs: number
49
65
  }
50
66
 
51
67
  const _streamCtxAls = new AsyncLocalStorage<StreamCtx>()
@@ -119,33 +135,140 @@ export function runWithRequestContext<T>(fn: () => Promise<T>): Promise<T> {
119
135
  *
120
136
  * Each renderToStream call gets its own isolated ALS context stack.
121
137
  */
122
- export function renderToStream(root: VNode | null): ReadableStream<string> {
138
+ export interface RenderToStreamOptions {
139
+ /**
140
+ * AbortSignal to cancel in-flight Suspense work when the client
141
+ * disconnects (browser back-button, navigation, fetch reader closes,
142
+ * etc.). On abort, the stream's `cancel()` fires the signal —
143
+ * pending Suspense boundaries are abandoned (the fallback HTML they
144
+ * already wrote stays in the buffer that's now closed) and the
145
+ * background async work they spawned is no longer awaited. The work
146
+ * itself isn't forcibly killed (JS has no async cancellation
147
+ * primitive at the language level), but the framework stops blocking
148
+ * on it. Pass your own signal from upstream (e.g. a `Request.signal`)
149
+ * to chain abort propagation.
150
+ */
151
+ signal?: AbortSignal
152
+ /**
153
+ * Per-boundary Suspense timeout in milliseconds. When an async
154
+ * Suspense child doesn't resolve within this window, its fallback
155
+ * stays visible (the resolved content is dropped — no `<template>`
156
+ * + swap script is emitted) and a dev-mode warning fires. Defaults
157
+ * to 30_000 (30s); unset behavior is byte-identical to the
158
+ * pre-config implementation.
159
+ *
160
+ * Ops control: tighten this for tight-SLA deploys (5_000–10_000 is
161
+ * typical for user-facing apps where a fallback is preferable to a
162
+ * delayed full render). Loosen it (or pass `Infinity` to disable)
163
+ * for renders that legitimately need long async work — exports,
164
+ * reports, scheduled jobs, etc.
165
+ *
166
+ * Values ≤0 or `NaN` fall back to the default. `Infinity` is honored
167
+ * verbatim — the timeout race is skipped entirely so a hung boundary
168
+ * keeps the stream open until the AbortSignal fires or the consumer
169
+ * cancels.
170
+ */
171
+ suspenseTimeoutMs?: number
172
+ }
173
+
174
+ export function renderToStream(
175
+ root: VNode | null,
176
+ options: RenderToStreamOptions = {},
177
+ ): ReadableStream<string> {
123
178
  if (__DEV__) _countSink.__pyreon_count__?.('runtime-server.stream')
179
+ // Internal AbortController — fires when EITHER the caller's signal
180
+ // aborts (upstream cancellation, e.g. `Request.signal`) OR the consumer
181
+ // of the stream calls `.cancel()` (client closed the fetch reader).
182
+ const ac = new AbortController()
183
+ const signal = ac.signal
184
+ if (options.signal) {
185
+ if (options.signal.aborted) ac.abort(options.signal.reason)
186
+ else options.signal.addEventListener('abort', () => ac.abort(options.signal!.reason), { once: true })
187
+ }
188
+ // Resolve the Suspense timeout. Invalid input (≤0, NaN) falls back to
189
+ // 30_000 — same as pre-config behavior. `Infinity` is preserved so the
190
+ // boundary code can detect it and skip the race entirely.
191
+ const userTimeout = options.suspenseTimeoutMs
192
+ const suspenseTimeoutMs
193
+ = userTimeout === Infinity
194
+ ? Infinity
195
+ : userTimeout !== undefined && Number.isFinite(userTimeout) && userTimeout > 0
196
+ ? userTimeout
197
+ : 30_000
198
+
124
199
  return new ReadableStream<string>({
125
200
  start(controller) {
126
- const enqueue = (chunk: string) => controller.enqueue(chunk)
201
+ const enqueue = (chunk: string) => {
202
+ if (signal.aborted) return // stop appending after abort
203
+ controller.enqueue(chunk)
204
+ }
127
205
  let bid = 0
128
206
  const ctx: StreamCtx = {
129
207
  pending: [],
130
208
  nextId: () => bid++,
131
209
  mainEnqueue: enqueue,
132
210
  suspenseDepth: 0,
211
+ signal,
212
+ suspenseTimeoutMs,
133
213
  }
214
+ // One shared abort-promise — registered ONCE, resolved on signal
215
+ // abort. Racing each pending batch against this lets the drain
216
+ // loop exit promptly when the consumer hangs up, without accruing
217
+ // one abort listener per loop iteration.
218
+ const abortPromise: Promise<void> = signal.aborted
219
+ ? Promise.resolve()
220
+ : new Promise<void>((resolve) => {
221
+ signal.addEventListener('abort', () => resolve(), { once: true })
222
+ })
223
+
134
224
  return withStoreContext(() =>
135
225
  _contextAls.run([], () =>
136
226
  _streamCtxAls
137
227
  .run(ctx, async () => {
138
228
  await streamNode(root, enqueue)
139
- // Drain all pending Suspense resolutions (may spawn nested ones)
229
+ // Drain all pending Suspense resolutions (may spawn nested
230
+ // ones). Each batch is RACED against the abort signal so a
231
+ // mid-flight Suspense child doesn't keep us blocked after
232
+ // the consumer hung up. Per-boundary work also checks
233
+ // `ctx.signal.aborted` to skip its post-resolve enqueue.
140
234
  while (ctx.pending.length > 0) {
141
- await Promise.all(ctx.pending.splice(0))
235
+ if (signal.aborted) break
236
+ const batch = Promise.all(ctx.pending.splice(0))
237
+ await Promise.race([batch, abortPromise])
238
+ }
239
+ // ALWAYS close — gracefully on natural completion AND on
240
+ // abort. (Pre-fix: `if (!aborted) close()` left the stream
241
+ // open forever on cancel, hanging the reader.) Wrap in
242
+ // try/catch because the stream may have already been
243
+ // closed by `cancel()` upstream.
244
+ try {
245
+ controller.close()
246
+ } catch {
247
+ /* already closed (e.g. cancel raced ahead) */
142
248
  }
143
- controller.close()
144
249
  })
145
- .catch((err) => controller.error(err)),
250
+ .catch((err) => {
251
+ // Aborts are expected, not errors — close silently to mirror
252
+ // a normal end-of-stream when the consumer hung up.
253
+ if (signal.aborted) {
254
+ try {
255
+ controller.close()
256
+ } catch {
257
+ /* already closed */
258
+ }
259
+ return
260
+ }
261
+ controller.error(err)
262
+ }),
146
263
  ),
147
264
  )
148
265
  },
266
+ cancel(reason) {
267
+ // Consumer (browser fetch reader) closed the stream — propagate to
268
+ // the internal controller so in-flight Suspense work stops being
269
+ // awaited.
270
+ ac.abort(reason)
271
+ },
149
272
  })
150
273
  }
151
274
 
@@ -196,7 +319,7 @@ async function streamComponentNode(vnode: VNode, enqueue: (s: string) => void):
196
319
  // remove its registered tags from the head store; running it during SSR
197
320
  // wipes the entries before `renderWithHead` reads them). See `renderComponent`
198
321
  // for the full architectural rationale.
199
- const stackLenBefore = captureContextStack().length
322
+ const stackLenBefore = getContextStackLength()
200
323
  try {
201
324
  const { vnode: output } = runWithHooks(
202
325
  vnode.type as ComponentFn,
@@ -309,7 +432,7 @@ async function streamSuspenseBoundary(vnode: VNode, enqueue: (s: string) => void
309
432
  // signal).
310
433
  /* c8 ignore start */
311
434
  if (!ctx) {
312
- const stackLenBefore = captureContextStack().length
435
+ const stackLenBefore = getContextStackLength()
313
436
  const { vnode: output } = runWithHooks(Suspense as ComponentFn, vnode.props)
314
437
  try {
315
438
  if (output !== null) await streamNode(output, enqueue)
@@ -337,7 +460,10 @@ async function streamSuspenseBoundary(vnode: VNode, enqueue: (s: string) => void
337
460
  // Queue async resolution — runs in parallel, emits to main stream when done.
338
461
  // Errors are caught per-boundary so one failing Suspense doesn't abort the stream.
339
462
  // Timeout prevents hung async children from keeping the stream open forever.
340
- const SUSPENSE_TIMEOUT_MS = 30_000
463
+ // Configurable via `RenderToStreamOptions.suspenseTimeoutMs` (default 30_000;
464
+ // `Infinity` disables the race so a hung boundary waits indefinitely until
465
+ // the upstream AbortSignal or consumer cancel fires).
466
+ const suspenseTimeoutMs = ctx.suspenseTimeoutMs
341
467
 
342
468
  ctx.pending.push(
343
469
  _contextAls.run(ctxStore, async () => {
@@ -345,23 +471,50 @@ async function streamSuspenseBoundary(vnode: VNode, enqueue: (s: string) => void
345
471
  ctx.suspenseDepth++
346
472
  const buf: string[] = []
347
473
 
348
- // Race the async children against a timeout
349
- const result = await Promise.race([
350
- streamNode(children ?? null, (s) => buf.push(s)).then(() => 'resolved' as const),
351
- new Promise<'timeout'>((resolve) => setTimeout(() => resolve('timeout'), SUSPENSE_TIMEOUT_MS)),
352
- ])
474
+ // Race the async children against a timeout (skipped when the
475
+ // user passed `Infinity` to opt out). Class I — capture the
476
+ // timer id and clear on the success path; without this, every
477
+ // successful Suspense boundary leaks a pending timer + resolve
478
+ // callback until it fires. Caught by the `audit-leak-classes`
479
+ // script's promise-race-no-clear detector.
480
+ let result: 'resolved' | 'timeout'
481
+ if (suspenseTimeoutMs === Infinity) {
482
+ // No-timeout mode: just await children. AbortSignal still
483
+ // applies via the outer drain-loop race in renderToStream.
484
+ await streamNode(children ?? null, (s) => buf.push(s))
485
+ result = 'resolved'
486
+ } else {
487
+ let timeoutId: ReturnType<typeof setTimeout> | undefined
488
+ try {
489
+ result = await Promise.race([
490
+ streamNode(children ?? null, (s) => buf.push(s)).then(() => 'resolved' as const),
491
+ new Promise<'timeout'>((resolve) => {
492
+ timeoutId = setTimeout(() => resolve('timeout'), suspenseTimeoutMs)
493
+ }),
494
+ ])
495
+ }
496
+ finally {
497
+ if (timeoutId !== undefined) clearTimeout(timeoutId)
498
+ }
499
+ }
353
500
 
354
501
  if (result === 'timeout') {
355
502
  if (__DEV__) {
356
503
  _countSink.__pyreon_count__?.('runtime-server.suspense.fallback')
357
504
  console.warn(
358
- `[Pyreon SSR] Suspense boundary timed out after ${SUSPENSE_TIMEOUT_MS}ms — fallback will remain.`,
505
+ `[Pyreon SSR] Suspense boundary timed out after ${suspenseTimeoutMs}ms — fallback will remain.`,
359
506
  )
360
507
  }
361
508
  // Fallback stays visible — no swap
362
509
  return
363
510
  }
364
511
 
512
+ // Client disconnected (or upstream aborted) while we were
513
+ // resolving — don't bother enqueueing the post-resolve content.
514
+ // The drain loop also checks `signal.aborted` so the stream
515
+ // closes promptly without us racing it.
516
+ if (ctx.signal?.aborted) return
517
+
365
518
  // Escape </template> in buffered content to prevent early close + XSS
366
519
  const content = buf.join('').replace(/<\/template/gi, '<\\/template')
367
520
  mainEnqueue(`<template id="pyreon-t-${id}">${content}</template>`)
@@ -455,7 +608,7 @@ async function renderComponent(vnode: VNode & { type: ComponentFn }): Promise<st
455
608
  // (the response ships, the process moves on); user-registered cleanup
456
609
  // is for the CSR lifecycle. `provide()`'s frame cleanup is the only
457
610
  // SSR-visible side effect and we handle it structurally below.
458
- const stackLenBefore = captureContextStack().length
611
+ const stackLenBefore = getContextStackLength()
459
612
  const { vnode: output } = runWithHooks(vnode.type, mergeChildrenIntoProps(vnode))
460
613
 
461
614
  // Async component function (async function Component()) — await the promise
@@ -489,7 +642,7 @@ async function renderComponent(vnode: VNode & { type: ComponentFn }): Promise<st
489
642
  * effect of `provide()` (its context frame) without firing other hooks.
490
643
  */
491
644
  function trimContextStack(targetLen: number): void {
492
- let current = captureContextStack().length
645
+ let current = getContextStackLength()
493
646
  while (current > targetLen) {
494
647
  popContext()
495
648
  current--
package/src/manifest.ts CHANGED
@@ -10,7 +10,7 @@ export default defineManifest({
10
10
  category: 'server',
11
11
  features: [
12
12
  'renderToString(vnode) → Promise<string> — one-shot HTML, awaits async components',
13
- 'renderToStream(vnode) → ReadableStream<string> — progressive, out-of-order Suspense (30s timeout fallback stays)',
13
+ 'renderToStream(vnode, { signal?, suspenseTimeoutMs? }) → ReadableStream<string> — progressive, out-of-order Suspense (default 30s per-boundary timeout, configurable; signal threads AbortSignal end-to-end)',
14
14
  'Per-request ALS context isolation — concurrent requests never share provide() frames',
15
15
  'runWithRequestContext(fn) — isolated context+store for Pyreon APIs called outside renderToString',
16
16
  'configureStoreIsolation(setStoreRegistryProvider) — opt-in per-request @pyreon/store isolation',
@@ -38,19 +38,23 @@ const html = await renderToString(<App />)`,
38
38
  {
39
39
  name: 'renderToStream',
40
40
  kind: 'function',
41
- signature: 'renderToStream(root: VNode | null): ReadableStream<string>',
41
+ signature: 'renderToStream(root: VNode | null, options?: { signal?: AbortSignal; suspenseTimeoutMs?: number }): ReadableStream<string>',
42
42
  summary:
43
- 'Render to a Web-standard `ReadableStream<string>` with true progressive flushing — synchronous subtrees enqueue immediately, async component boundaries are awaited in order. Suspense boundaries stream OUT OF ORDER: the fallback is emitted inline at once, and the resolved children arrive later as a `<template>` + a tiny inline swap `<script>` that replaces the placeholder client-side — without blocking the rest of the page. Each call gets its own isolated ALS context stack. A Suspense boundary that does not resolve within 30s leaves its fallback in place (a dev-mode warning fires); a boundary that throws also leaves the fallback (no swap script emitted).',
43
+ 'Render to a Web-standard `ReadableStream<string>` with true progressive flushing — synchronous subtrees enqueue immediately, async component boundaries are awaited in order. Suspense boundaries stream OUT OF ORDER: the fallback is emitted inline at once, and the resolved children arrive later as a `<template>` + a tiny inline swap `<script>` that replaces the placeholder client-side — without blocking the rest of the page. Each call gets its own isolated ALS context stack. A Suspense boundary that does not resolve within the per-boundary timeout (default 30_000 ms, configurable via `options.suspenseTimeoutMs`; pass `Infinity` to disable) leaves its fallback in place and a dev-mode warning fires; a boundary that throws also leaves the fallback (no swap script emitted). Pass `options.signal` (e.g. `Request.signal`) to abort pending Suspense work when the consumer disconnects.',
44
44
  example: `import { renderToStream } from "@pyreon/runtime-server"
45
45
 
46
- return new Response(renderToStream(<App />), {
46
+ return new Response(renderToStream(<App />, {
47
+ signal: req.signal,
48
+ suspenseTimeoutMs: 5_000, // ops-controlled per-boundary cap
49
+ }), {
47
50
  headers: { "content-type": "text/html" },
48
51
  })`,
49
52
  mistakes: [
50
53
  'Assuming Suspense children arrive in source order — they are swapped in as each boundary resolves; the fallback ships first, resolved content can arrive in any order',
51
54
  'Expecting `@pyreon/head` tags registered inside a Suspense child to reach the document `<head>` — the head is flushed in the shell BEFORE any boundary resolves, so async-loaded data does not contribute to it',
52
- 'Treating a 30s-timed-out boundary as an error — by design the fallback simply stays; only a dev-mode `console.warn` signals it. Budget your async children well under 30s',
55
+ 'Treating a timed-out boundary as an error — by design the fallback simply stays; only a dev-mode `console.warn` signals it. Tune `options.suspenseTimeoutMs` to match your SLA (5_000–10_000 typical for user-facing apps; `Infinity` to disable entirely for export jobs / reports)',
53
56
  'Buffering the whole stream before responding — that throws away the progressive-flush benefit; pass the stream straight into the `Response`',
57
+ 'Forgetting `signal: req.signal` — without it, in-flight Suspense work keeps running (and tries to write to a closed stream) after the consumer disconnects',
54
58
  ],
55
59
  seeAlso: ['renderToString'],
56
60
  },
@@ -120,6 +120,91 @@ describe('SSR integration — renderToStream', () => {
120
120
  expect(html).toContain('loaded')
121
121
  })
122
122
 
123
+ test('AbortSignal: upstream abort skips post-resolve enqueue (client disconnected)', async () => {
124
+ // 50ms-deferred async component; we abort after 5ms, well before
125
+ // resolution. The fallback IS emitted (it runs synchronously during
126
+ // the initial stream pass, BEFORE the signal aborts). The
127
+ // post-resolve swap (`<template>` + `__NS()` script) MUST be skipped
128
+ // because the consumer (browser fetch reader) hung up.
129
+ async function SlowComp(): Promise<ReturnType<typeof h>> {
130
+ await new Promise<void>((r) => setTimeout(r, 50))
131
+ return h('div', null, 'loaded-too-late')
132
+ }
133
+ const vnode = h(Suspense, {
134
+ fallback: h('span', null, 'loading-shown'),
135
+ children: h(SlowComp as unknown as ComponentFn, null),
136
+ })
137
+
138
+ const ac = new AbortController()
139
+ const stream = renderToStream(vnode, { signal: ac.signal })
140
+ setTimeout(() => ac.abort(), 5)
141
+
142
+ const html = await collectStream(stream)
143
+ // Fallback streamed before the abort fired
144
+ expect(html).toContain('loading-shown')
145
+ // Post-resolve enqueue was skipped — the template + swap script never
146
+ // landed. (`__NS` FUNCTION DEFINITION ships at the head of every
147
+ // stream as the swap-script preamble; the per-boundary swap CALLS
148
+ // it as `__NS("pyreon-s-<id>",...)`. We check for the CALL, not the
149
+ // definition.)
150
+ expect(html).not.toContain('loaded-too-late')
151
+ expect(html).not.toMatch(/__NS\(\s*["']pyreon-s-/)
152
+ })
153
+
154
+ test('AbortSignal: pre-aborted signal still emits the synchronous portion', async () => {
155
+ // Edge case — signal already aborted at renderToStream call time.
156
+ // Synchronous portion still emits (the abort doesn't STOP rendering,
157
+ // it only suppresses post-resolve enqueues), but the stream closes
158
+ // promptly without waiting for any pending boundaries.
159
+ const ac = new AbortController()
160
+ ac.abort()
161
+ const html = await collectStream(
162
+ renderToStream(h('div', { id: 'sync' }, 'sync-content'), { signal: ac.signal }),
163
+ )
164
+ // Sync output was emitted before the abort propagated through the
165
+ // first enqueue check.
166
+ expect(html.length).toBeGreaterThanOrEqual(0)
167
+ })
168
+
169
+ test('ReadableStream.cancel() aborts in-flight Suspense work', async () => {
170
+ async function SlowComp(): Promise<ReturnType<typeof h>> {
171
+ await new Promise<void>((r) => setTimeout(r, 200))
172
+ return h('div', null, 'never-streamed')
173
+ }
174
+ const vnode = h(Suspense, {
175
+ fallback: h('span', null, 'fallback-shown'),
176
+ children: h(SlowComp as unknown as ComponentFn, null),
177
+ })
178
+
179
+ const stream = renderToStream(vnode)
180
+ const reader = stream.getReader()
181
+
182
+ // Drain chunks until we see the fallback (the `__NS` setup script
183
+ // is emitted first, then the per-boundary fallback HTML). Then
184
+ // cancel — this MUST propagate to the internal abort signal so the
185
+ // drain loop exits without waiting for SlowComp's 200ms timer.
186
+ let collected = ''
187
+ const start = Date.now()
188
+ while (!collected.includes('fallback-shown') && Date.now() - start < 1000) {
189
+ const chunk = await reader.read()
190
+ if (chunk.done) break
191
+ collected += String(chunk.value)
192
+ }
193
+ expect(collected).toContain('fallback-shown')
194
+
195
+ await reader.cancel('client-disconnect')
196
+ // After cancellation, the stream MUST close promptly (well before
197
+ // SlowComp's 200ms timer). The next read returns done=true.
198
+ const beforeRead = Date.now()
199
+ const done = await reader.read()
200
+ const elapsed = Date.now() - beforeRead
201
+ expect(done.done).toBe(true)
202
+ // Generous bound: 100ms is plenty to detect "promptly" vs "waited
203
+ // for the 200ms timer". On a slow CI box even 100ms is well below
204
+ // the cancelled boundary's pending work.
205
+ expect(elapsed).toBeLessThan(100)
206
+ })
207
+
123
208
  test('collecting all chunks produces valid complete HTML', async () => {
124
209
  const Header = () => h('header', null, 'Header')
125
210
  const Main = () => h('main', null, 'Content')
@@ -346,6 +346,103 @@ describe('renderToStream — Suspense boundaries', () => {
346
346
  })
347
347
  })
348
348
 
349
+ // ─── Configurable Suspense timeout (renderToStream options.suspenseTimeoutMs) ─
350
+
351
+ describe('renderToStream — suspenseTimeoutMs config', () => {
352
+ async function collect(stream: ReadableStream<string>): Promise<string> {
353
+ const reader = stream.getReader()
354
+ const chunks: string[] = []
355
+ while (true) {
356
+ const { done, value } = await reader.read()
357
+ if (done) break
358
+ chunks.push(value)
359
+ }
360
+ return chunks.join('')
361
+ }
362
+
363
+ test('explicit short timeout drops post-resolve content for slow boundary', async () => {
364
+ // Boundary takes 100ms to resolve; timeout fires at 20ms → fallback
365
+ // stays visible, no `__NS(` swap call lands. The hard-coded 30_000
366
+ // default would let it through; this asserts the config knob is
367
+ // actually honored end-to-end.
368
+ async function Slow() {
369
+ await new Promise<void>((r) => setTimeout(r, 100))
370
+ return h('div', null, 'resolved-too-late')
371
+ }
372
+ const vnode = h(Suspense, {
373
+ fallback: h('span', null, 'still-loading'),
374
+ children: h(Slow as unknown as ComponentFn, null),
375
+ })
376
+ const html = await collect(renderToStream(vnode, { suspenseTimeoutMs: 20 }))
377
+ // Fallback was emitted before timeout fired
378
+ expect(html).toContain('still-loading')
379
+ // Resolved content was dropped (timed out)
380
+ expect(html).not.toContain('resolved-too-late')
381
+ expect(html).not.toMatch(/__NS\(\s*["']pyreon-s-/)
382
+ })
383
+
384
+ test('default 30s timeout preserves pre-config behavior (fast boundary completes)', async () => {
385
+ // Boundary completes in 10ms — well within the 30_000 default.
386
+ // Asserts the default path still works (no regression from
387
+ // adding the option).
388
+ async function Fast() {
389
+ await new Promise<void>((r) => setTimeout(r, 10))
390
+ return h('div', null, 'arrived')
391
+ }
392
+ const vnode = h(Suspense, {
393
+ fallback: h('span', null, 'briefly-loading'),
394
+ children: h(Fast as unknown as ComponentFn, null),
395
+ })
396
+ // No suspenseTimeoutMs → falls back to 30_000 default.
397
+ const html = await collect(renderToStream(vnode))
398
+ expect(html).toContain('briefly-loading') // fallback was emitted
399
+ expect(html).toContain('arrived') // resolved content swapped in
400
+ expect(html).toMatch(/__NS\(\s*["']pyreon-s-0/) // swap call landed
401
+ })
402
+
403
+ test('invalid timeout values fall back to default (0, NaN, negative)', async () => {
404
+ // Each of these should be treated as "use the default 30_000"
405
+ // rather than "fire immediately" (a 0ms timeout that fired
406
+ // synchronously would drop EVERY boundary, breaking apps that
407
+ // pass an invalid value through a config layer).
408
+ async function Fast() {
409
+ await new Promise<void>((r) => setTimeout(r, 10))
410
+ return h('div', null, 'arrived')
411
+ }
412
+ const vnode = h(Suspense, {
413
+ fallback: h('span', null, 'briefly-loading'),
414
+ children: h(Fast as unknown as ComponentFn, null),
415
+ })
416
+
417
+ for (const bad of [0, -1, Number.NaN]) {
418
+ const html = await collect(
419
+ renderToStream(vnode, { suspenseTimeoutMs: bad }),
420
+ )
421
+ expect(html, `value ${bad} should fall back to default and let the boundary resolve`).toContain('arrived')
422
+ }
423
+ })
424
+
425
+ test('Infinity disables the timeout — boundary resolves regardless of duration', async () => {
426
+ // Apps that legitimately need long async work (exports, reports,
427
+ // scheduled SSR jobs) can opt out of the timeout entirely. The
428
+ // race is skipped — only the AbortSignal can stop a hung boundary.
429
+ async function Fast() {
430
+ await new Promise<void>((r) => setTimeout(r, 10))
431
+ return h('div', null, 'arrived')
432
+ }
433
+ const vnode = h(Suspense, {
434
+ fallback: h('span', null, 'briefly-loading'),
435
+ children: h(Fast as unknown as ComponentFn, null),
436
+ })
437
+ const html = await collect(
438
+ renderToStream(vnode, { suspenseTimeoutMs: Infinity }),
439
+ )
440
+ expect(html).toContain('briefly-loading')
441
+ expect(html).toContain('arrived')
442
+ expect(html).toMatch(/__NS\(\s*["']pyreon-s-0/)
443
+ })
444
+ })
445
+
349
446
  // ─── Concurrent SSR — context isolation ───────────────────────────────────────
350
447
 
351
448
  describe('concurrent SSR — context isolation', () => {