@pylonsync/functions 0.3.248 → 0.3.250

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pylonsync/functions",
3
- "version": "0.3.248",
3
+ "version": "0.3.250",
4
4
  "description": "TypeScript function runtime for pylon — defines server-side queries, mutations, and actions.",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -21,7 +21,8 @@
21
21
  ],
22
22
  "scripts": {
23
23
  "typecheck": "tsc --noEmit",
24
- "build": "tsc"
24
+ "build": "tsc",
25
+ "test": "bun test"
25
26
  },
26
27
  "keywords": [
27
28
  "pylon",
@@ -31,7 +32,12 @@
31
32
  ],
32
33
  "license": "MIT OR Apache-2.0",
33
34
  "devDependencies": {
34
- "typescript": "^5.5"
35
+ "typescript": "^5.5",
36
+ "react": "^19.0.0",
37
+ "react-dom": "^19.0.0",
38
+ "@types/react": "^19.0.0",
39
+ "@types/react-dom": "^19.0.0",
40
+ "happy-dom": "^15.0.0"
35
41
  },
36
42
  "peerDependencies": {
37
43
  "bun-types": "*"
package/src/runtime.ts CHANGED
@@ -236,6 +236,24 @@ function dispatch(line: string): void {
236
236
  String(err),
237
237
  });
238
238
  });
239
+ } else if (msg.type === "handle_form") {
240
+ // route.ts form/method handler (#276) — lazy-imported like SSR so
241
+ // projects without route handlers pay nothing on startup.
242
+ import("./ssr-form-runtime")
243
+ .then((mod) =>
244
+ mod.handleForm(
245
+ msg as unknown as Parameters<typeof mod.handleForm>[0],
246
+ send,
247
+ ),
248
+ )
249
+ .catch((err) => {
250
+ send({
251
+ type: "error",
252
+ call_id: (msg as unknown as { call_id: string }).call_id,
253
+ code: "SSR_FORM_RUNTIME_CRASH",
254
+ message: err?.message || String(err),
255
+ });
256
+ });
239
257
  } else if (msg.type === "bundle_client") {
240
258
  // Hydration — build the client-side bundle once and report
241
259
  // the path back. Lazy-imported for the same reason as SSR.
@@ -426,7 +444,7 @@ export function buildDbReader(callId: string, unsafeOp = false): DbReader {
426
444
  return reader;
427
445
  }
428
446
 
429
- function buildDbWriter(callId: string, unsafeOp = false): DbWriter {
447
+ export function buildDbWriter(callId: string, unsafeOp = false): DbWriter {
430
448
  // Drop the reader's `unsafe` shortcut before spreading — the
431
449
  // writer needs its own (which we attach below). Without this
432
450
  // strip, `writer.unsafe` would be a DbReader and the type
@@ -510,6 +510,52 @@ function installNavHandlers() {
510
510
  window.addEventListener("popstate", () => {
511
511
  navigate(location.pathname + location.search, { push: false });
512
512
  });
513
+ // Progressive-enhancement form submit (#276): intercept <form data-pylon-form>
514
+ // so the page doesn't full-reload. fetch the same route.ts endpoint, follow
515
+ // the handler's redirect, and swap the destination in via navigate(). Without
516
+ // JS this listener never runs and the browser submits natively (POST →
517
+ // handler → 303 → GET) — identical result, just a full navigation.
518
+ document.addEventListener("submit", (e) => {
519
+ if (e.defaultPrevented) return;
520
+ const form = e.target;
521
+ if (!form || form.tagName !== "FORM") return;
522
+ if (!form.hasAttribute("data-pylon-form")) return;
523
+ const action = form.getAttribute("action");
524
+ if (!action) return;
525
+ // Off-origin actions / new-tab targets → let the browser submit.
526
+ if (action.startsWith("http://") || action.startsWith("https://") || action.startsWith("//")) return;
527
+ const tgt = form.getAttribute("target");
528
+ if (tgt && tgt !== "" && tgt !== "_self") return;
529
+ const method = (form.getAttribute("method") || "post").toUpperCase();
530
+ e.preventDefault();
531
+ // urlencoded body (matches the server's parser; files use the native path).
532
+ const body = new URLSearchParams();
533
+ const fd = new FormData(form);
534
+ fd.forEach((v, k) => {
535
+ if (typeof v === "string") body.append(k, v);
536
+ });
537
+ fetch(action, {
538
+ method,
539
+ body,
540
+ credentials: "same-origin",
541
+ headers: { Accept: "text/html" },
542
+ })
543
+ .then((res) => {
544
+ // fetch followed the handler's 303 → res.url is the destination page.
545
+ // Drive a client navigation to it (re-renders without a full reload).
546
+ const dest = new URL(res.url || action, location.href);
547
+ if (dest.origin === location.origin) {
548
+ navigate(dest.pathname + dest.search);
549
+ } else {
550
+ window.location.href = dest.href;
551
+ }
552
+ })
553
+ .catch(() => {
554
+ // Network/abort — fall back to a native submit so the user isn't stuck.
555
+ form.removeAttribute("data-pylon-form");
556
+ form.submit();
557
+ });
558
+ });
513
559
  }
514
560
 
515
561
  // Expose for <Link> component prefetch.
@@ -0,0 +1,188 @@
1
+ // route.ts form/method handler runtime (#276). Invoked by runtime.ts when the
2
+ // host sends a "handle_form" message. Imports the route.ts module, picks the
3
+ // handler by HTTP method, runs it with the parsed form + a read/write `db` +
4
+ // the SsrResponse controller, and replies over the SAME response_start /
5
+ // render_done protocol a render uses. A handler's common job is
6
+ // POST-redirect-GET: write something, then `response.redirect("/x?ok=1")`
7
+ // (303 by default here) so the no-JS browser follows with a GET.
8
+ import {
9
+ makeResponseController,
10
+ PylonRouteControl,
11
+ finalizeHeaders,
12
+ importModule,
13
+ type ResponseState,
14
+ } from "./ssr-runtime";
15
+ import { buildDbWriter } from "./runtime";
16
+
17
+ /** Matches HandleFormMessage in crates/functions/src/protocol.rs. */
18
+ export interface HandleFormMessage {
19
+ type: "handle_form";
20
+ call_id: string;
21
+ component: string;
22
+ route_path: string;
23
+ method: string;
24
+ url: string;
25
+ params: Record<string, string>;
26
+ search_params: Record<string, string>;
27
+ form: Record<string, string | string[]>;
28
+ headers: Record<string, string>;
29
+ cookies: Record<string, string>;
30
+ auth: {
31
+ user_id: string | null;
32
+ is_admin: boolean;
33
+ tenant_id: string | null;
34
+ roles: string[];
35
+ };
36
+ }
37
+
38
+ type Send = (msg: Record<string, unknown>) => void;
39
+
40
+ /** Parsed form fields. Mirrors URLSearchParams get/getAll/has semantics. */
41
+ export interface FormFields {
42
+ /** First value for `name`, or null. */
43
+ get(name: string): string | null;
44
+ /** All values for `name` (empty array if none). */
45
+ getAll(name: string): string[];
46
+ has(name: string): boolean;
47
+ /** Raw map: name → value | values. */
48
+ readonly fields: Record<string, string | string[]>;
49
+ }
50
+
51
+ function makeFormFields(raw: Record<string, string | string[]>): FormFields {
52
+ return {
53
+ get(name) {
54
+ const v = raw[name];
55
+ if (v == null) return null;
56
+ return Array.isArray(v) ? (v.length > 0 ? v[0] : null) : v;
57
+ },
58
+ getAll(name) {
59
+ const v = raw[name];
60
+ if (v == null) return [];
61
+ return Array.isArray(v) ? v : [v];
62
+ },
63
+ has(name) {
64
+ return Object.prototype.hasOwnProperty.call(raw, name);
65
+ },
66
+ fields: raw,
67
+ };
68
+ }
69
+
70
+ const HANDLER_METHODS = ["POST", "PUT", "PATCH", "DELETE"] as const;
71
+ const b64 = (s: string) => Buffer.from(s, "utf8").toString("base64");
72
+ const isDev = () =>
73
+ process.env.PYLON_DEV_MODE === "1" || process.env.PYLON_DEV_MODE === "true";
74
+
75
+ export async function handleForm(
76
+ msg: HandleFormMessage,
77
+ send: Send,
78
+ ): Promise<void> {
79
+ const cwd = process.cwd();
80
+ // Default 303 See Other — the correct POST-redirect-GET status for a form
81
+ // (307 would re-issue the POST to the redirect target).
82
+ const responseState: ResponseState = { status: 303, headers: {}, cookies: [] };
83
+ const response = makeResponseController(responseState, 303);
84
+ const req = {
85
+ form: makeFormFields(msg.form ?? {}),
86
+ params: msg.params,
87
+ searchParams: msg.search_params,
88
+ auth: msg.auth,
89
+ cookies: msg.cookies,
90
+ headers: msg.headers,
91
+ // Read+write DB handle (mutation-shaped; the host answers writes against a
92
+ // broadcast-capable store). serverData (read-only) isn't given — a handler
93
+ // writes via `db` and the developer enforces trust with `req.auth`.
94
+ db: buildDbWriter(msg.call_id),
95
+ response,
96
+ };
97
+
98
+ let mod: any;
99
+ try {
100
+ mod = await importModule(cwd, msg.component);
101
+ } catch (e: any) {
102
+ send({
103
+ type: "error",
104
+ call_id: msg.call_id,
105
+ code: "SSR_FORM_IMPORT_FAILED",
106
+ message: isDev() && e?.stack ? String(e.stack) : e?.message ?? String(e),
107
+ });
108
+ return;
109
+ }
110
+
111
+ const method = (msg.method || "POST").toUpperCase();
112
+ const handler = mod[method];
113
+ if (typeof handler !== "function") {
114
+ // 405 — advertise the methods this route.ts actually exports.
115
+ const allow = HANDLER_METHODS.filter(
116
+ (m) => typeof mod[m] === "function",
117
+ ).join(", ");
118
+ send({
119
+ type: "response_start",
120
+ call_id: msg.call_id,
121
+ status: 405,
122
+ headers: {
123
+ "content-type": "text/plain; charset=utf-8",
124
+ ...(allow ? { allow } : {}),
125
+ },
126
+ });
127
+ send({
128
+ type: "render_chunk",
129
+ call_id: msg.call_id,
130
+ data: b64(`Method ${method} not allowed`),
131
+ });
132
+ send({ type: "render_done", call_id: msg.call_id });
133
+ return;
134
+ }
135
+
136
+ try {
137
+ await handler(req);
138
+ // No redirect()/notFound() thrown → commit the handler's response: its
139
+ // status (default 303) + headers + cookies. A 303 with no explicit
140
+ // Location redirects back to the route path (POST-redirect-GET).
141
+ const extra: Record<string, string> = {};
142
+ if (responseState.status === 303 && !responseState.headers["location"]) {
143
+ extra["location"] = msg.route_path || "/";
144
+ }
145
+ send({
146
+ type: "response_start",
147
+ call_id: msg.call_id,
148
+ status: responseState.status,
149
+ headers: finalizeHeaders(responseState, extra),
150
+ });
151
+ send({ type: "render_done", call_id: msg.call_id });
152
+ } catch (err: any) {
153
+ // response.redirect()/notFound() throw PylonRouteControl — turn into a
154
+ // 3xx + Location / 404, carrying any cookies the handler set first.
155
+ if (err instanceof PylonRouteControl) {
156
+ if (err.kind === "redirect") {
157
+ send({
158
+ type: "response_start",
159
+ call_id: msg.call_id,
160
+ status: err.redirectStatus ?? 303,
161
+ headers: finalizeHeaders(responseState, { location: err.url ?? "/" }),
162
+ });
163
+ send({ type: "render_done", call_id: msg.call_id });
164
+ return;
165
+ }
166
+ send({
167
+ type: "response_start",
168
+ call_id: msg.call_id,
169
+ status: 404,
170
+ headers: finalizeHeaders(responseState),
171
+ });
172
+ send({
173
+ type: "render_chunk",
174
+ call_id: msg.call_id,
175
+ data: b64("Not found"),
176
+ });
177
+ send({ type: "render_done", call_id: msg.call_id });
178
+ return;
179
+ }
180
+ send({
181
+ type: "error",
182
+ call_id: msg.call_id,
183
+ code: err?.code ?? "SSR_FORM_FAILED",
184
+ message:
185
+ isDev() && err?.stack ? String(err.stack) : err?.message ?? String(err),
186
+ });
187
+ }
188
+ }
@@ -0,0 +1,166 @@
1
+ /**
2
+ * #278 Stage 2 — streaming-hydration regression guard (the prod-killer check).
3
+ *
4
+ * Proves the load-bearing claim behind multi-boundary streaming: hydrating the
5
+ * RESOLVED SSR DOM (which is what a streamed page's DOM becomes after React's
6
+ * $RC reveals run) with a `fulfilledThenable`-backed serverData shim produces
7
+ * clean output — the inner <Suspense> boundary renders its RESOLVED rows on the
8
+ * first committed pass, NOT a fallback, and React logs NO hydration mismatch.
9
+ *
10
+ * This is why Pylon needs NO inline per-boundary patch scripts: it withholds
11
+ * the bootstrap from renderToReadableStream and runs hydrateRoot ONCE, after
12
+ * the full ssrData blob — so `use()` reads a fulfilled value synchronously and
13
+ * never re-suspends. If a future change made `use()` suspend at hydration (a
14
+ * broken shim handing back a pending promise), the boundary would mismatch /
15
+ * lose its content — caught here.
16
+ *
17
+ * Isolated in its own file because it registers DOM globals (window/document)
18
+ * for react-dom/client; they're restored after each test.
19
+ */
20
+ import { afterEach, describe, expect, test } from "bun:test";
21
+ import { Window } from "happy-dom";
22
+ import React, { Suspense, use } from "react";
23
+ import { renderToReadableStream } from "react-dom/server.browser";
24
+
25
+ // Mirrors `fulfilledThenable` in ssr-client-bundler.ts's CLIENT_RUNTIME_SOURCE:
26
+ // a React-recognized fulfilled thenable so use() reads `.value` synchronously
27
+ // (status === "fulfilled") instead of suspending.
28
+ function fulfilledThenable<T>(value: T) {
29
+ return {
30
+ status: "fulfilled" as const,
31
+ value,
32
+ then(onFulfilled?: (v: T) => any) {
33
+ return onFulfilled ? onFulfilled(value) : (value as any);
34
+ },
35
+ };
36
+ }
37
+
38
+ function Rows({ p }: { p: PromiseLike<string[]> }) {
39
+ const rows = use(p as any) as string[];
40
+ return React.createElement(
41
+ "ul",
42
+ { id: "rows" },
43
+ rows.map((r) => React.createElement("li", { key: r }, r)),
44
+ );
45
+ }
46
+ function Page({ p }: { p: PromiseLike<string[]> }) {
47
+ return React.createElement(
48
+ "div",
49
+ { id: "app" },
50
+ React.createElement("h1", { id: "shell" }, "Shell"),
51
+ React.createElement(
52
+ Suspense,
53
+ { fallback: React.createElement("p", { id: "fallback" }, "Loading…") },
54
+ React.createElement(Rows, { p }),
55
+ ),
56
+ );
57
+ }
58
+
59
+ const dec = new TextDecoder();
60
+
61
+ // Server-render the page (data pre-resolved) to its final resolved HTML — the
62
+ // same DOM a streamed page settles to once React's reveal scripts have run.
63
+ async function renderResolvedHtml(rows: string[]): Promise<string> {
64
+ const stream = await renderToReadableStream(
65
+ React.createElement(Page, { p: Promise.resolve(rows) }),
66
+ );
67
+ await (stream as any).allReady;
68
+ const reader = stream.getReader();
69
+ let html = "";
70
+ for (;;) {
71
+ const { value, done } = await reader.read();
72
+ if (done) break;
73
+ html += dec.decode(value);
74
+ }
75
+ return html;
76
+ }
77
+
78
+ // Globals react-dom/client touches; saved + restored so this file's DOM
79
+ // registration never bleeds into sibling test files.
80
+ const DOM_GLOBAL_KEYS = [
81
+ "window",
82
+ "document",
83
+ "navigator",
84
+ "HTMLElement",
85
+ "Node",
86
+ "Event",
87
+ "MutationObserver",
88
+ "requestAnimationFrame",
89
+ "cancelAnimationFrame",
90
+ ];
91
+ let savedGlobals: Record<string, any> | null = null;
92
+
93
+ function registerDom(win: any) {
94
+ const g = globalThis as any;
95
+ savedGlobals = {};
96
+ for (const k of DOM_GLOBAL_KEYS) savedGlobals[k] = g[k];
97
+ g.window = win;
98
+ g.document = win.document;
99
+ g.navigator = win.navigator;
100
+ g.HTMLElement = win.HTMLElement;
101
+ g.Node = win.Node;
102
+ g.Event = win.Event;
103
+ g.MutationObserver = win.MutationObserver;
104
+ g.requestAnimationFrame = (cb: any) => setTimeout(() => cb(Date.now()), 0);
105
+ g.cancelAnimationFrame = (id: any) => clearTimeout(id);
106
+ }
107
+
108
+ afterEach(() => {
109
+ if (!savedGlobals) return;
110
+ const g = globalThis as any;
111
+ for (const k of DOM_GLOBAL_KEYS) {
112
+ if (savedGlobals[k] === undefined) delete g[k];
113
+ else g[k] = savedGlobals[k];
114
+ }
115
+ savedGlobals = null;
116
+ });
117
+
118
+ describe("streaming hydration (#278)", () => {
119
+ test("fulfilledThenable shim → boundary hydrates to RESOLVED rows, no fallback, no mismatch", async () => {
120
+ const rows = ["alpha", "beta"];
121
+ const serverHtml = await renderResolvedHtml(rows);
122
+ // Sanity: the SSR DOM has the resolved rows (not a fallback).
123
+ expect(serverHtml).toContain("<li>alpha</li>");
124
+
125
+ const win = new Window({ url: "http://localhost/" });
126
+ // Parse the server HTML into a container element via HTML parsing (this
127
+ // preserves React's <!--$--> boundary comment markers, which hydrateRoot
128
+ // reads to locate Suspense boundaries). insertAdjacentHTML is the parse
129
+ // entry point; the input is our own rendered markup, not untrusted data.
130
+ const root = win.document.createElement("div");
131
+ root.setAttribute("id", "root");
132
+ root.insertAdjacentHTML("afterbegin", serverHtml);
133
+ win.document.body.appendChild(root);
134
+ registerDom(win);
135
+
136
+ const errors: string[] = [];
137
+ const origErr = console.error;
138
+ console.error = (...a: any[]) => {
139
+ errors.push(a.map(String).join(" "));
140
+ };
141
+ try {
142
+ const { hydrateRoot } = await import("react-dom/client");
143
+ // Client tree: same component, but serverData yields a fulfilled thenable
144
+ // (the value the server already streamed) — exactly the client shim's job.
145
+ hydrateRoot(
146
+ root as any,
147
+ React.createElement(Page, { p: fulfilledThenable(rows) }),
148
+ );
149
+ await new Promise((r) => setTimeout(r, 50));
150
+ const html = (root as any).innerHTML as string;
151
+
152
+ // The boundary committed its RESOLVED content on hydration...
153
+ expect(html).toContain("alpha");
154
+ expect(html).toContain("beta");
155
+ // ...NOT the fallback (no flash / no stuck boundary)...
156
+ expect(html).not.toContain("Loading…");
157
+ // ...and React logged NO hydration mismatch.
158
+ const mismatch = errors.filter((e) =>
159
+ /hydrat|did not match|mismatch|Text content does not match/i.test(e),
160
+ );
161
+ expect(mismatch).toEqual([]);
162
+ } finally {
163
+ console.error = origErr;
164
+ }
165
+ });
166
+ });
@@ -75,7 +75,7 @@ type Send = (msg: Record<string, unknown>) => void;
75
75
  * it's thrown during the shell render. (Throw it OUTSIDE an error
76
76
  * boundary — an enclosing boundary would swallow the signal.)
77
77
  */
78
- class PylonRouteControl extends Error {
78
+ export class PylonRouteControl extends Error {
79
79
  kind: "redirect" | "notFound";
80
80
  url?: string;
81
81
  redirectStatus?: number;
@@ -146,7 +146,7 @@ function serializeCookie(
146
146
  return c;
147
147
  }
148
148
 
149
- interface ResponseState {
149
+ export interface ResponseState {
150
150
  status: number;
151
151
  headers: Record<string, string>;
152
152
  cookies: string[];
@@ -184,7 +184,14 @@ export interface SsrResponse {
184
184
  notFound(): never;
185
185
  }
186
186
 
187
- function makeResponseController(state: ResponseState): SsrResponse {
187
+ // `defaultRedirectStatus` is the status `response.redirect(url)` uses when the
188
+ // caller doesn't pass one: 307 for a page render (preserve the method on the
189
+ // rare redirecting GET), 303 for a route.ts form handler (POST-redirect-GET —
190
+ // the browser must follow with a GET, not re-POST).
191
+ export function makeResponseController(
192
+ state: ResponseState,
193
+ defaultRedirectStatus = 307,
194
+ ): SsrResponse {
188
195
  return {
189
196
  setStatus(code) {
190
197
  if (!Number.isInteger(code) || code < 100 || code > 599) {
@@ -204,7 +211,7 @@ function makeResponseController(state: ResponseState): SsrResponse {
204
211
  setCookie(name, value, opts) {
205
212
  state.cookies.push(serializeCookie(name, value, opts));
206
213
  },
207
- redirect(url, status = 307): never {
214
+ redirect(url, status = defaultRedirectStatus): never {
208
215
  assertNoControlChars(url, "redirect url");
209
216
  if (!Number.isInteger(status) || status < 300 || status > 399) {
210
217
  throw new Error(`pylon ssr: redirect() status must be 3xx, got ${status}`);
@@ -226,7 +233,7 @@ function makeResponseController(state: ResponseState): SsrResponse {
226
233
  * into one `Set-Cookie` header each (newline is forbidden inside a
227
234
  * cookie, so it can't be turned into header injection).
228
235
  */
229
- function finalizeHeaders(
236
+ export function finalizeHeaders(
230
237
  state: ResponseState,
231
238
  extra?: Record<string, string>,
232
239
  ): Record<string, string> {
@@ -373,7 +380,7 @@ export function renderMetadata(React: any, m: SsrMetadata | undefined): any {
373
380
  const MODULE_EXTS = [".tsx", ".ts", ".jsx", ".js"];
374
381
 
375
382
  /** Import a project-relative module, trying each common extension. */
376
- async function importModule(cwd: string, relPath: string): Promise<any> {
383
+ export async function importModule(cwd: string, relPath: string): Promise<any> {
377
384
  const base = `${cwd}/${relPath}`;
378
385
  let lastErr: unknown = null;
379
386
  for (const ext of MODULE_EXTS) {
@@ -1127,6 +1134,78 @@ function makeServerData(reader: any, valueCache: Record<string, any>): any {
1127
1134
  return sd;
1128
1135
  }
1129
1136
 
1137
+ /**
1138
+ * #278: does this route STREAM (vs buffer the whole document)? Streaming is
1139
+ * opt-in: a `loading.tsx` (route-level Suspense) or `export const streaming =
1140
+ * true` (inner-boundary). Pure for testing.
1141
+ */
1142
+ export function computeWantsStream(hasLoading: boolean, mod: any): boolean {
1143
+ return hasLoading || mod?.streaming === true;
1144
+ }
1145
+
1146
+ /**
1147
+ * #277: how long an opt-in page stays cacheable, in seconds — or null if it
1148
+ * never opted in. `export const revalidate = N` (N>0) → N; `dynamic:
1149
+ * "force-static"` → a year (only a deploy invalidates); else null. Pure.
1150
+ */
1151
+ export function computeRevalidateSecs(mod: any): number | null {
1152
+ if (typeof mod?.revalidate === "number" && mod.revalidate > 0) {
1153
+ return Math.floor(mod.revalidate);
1154
+ }
1155
+ if (mod?.dynamic === "force-static") return 31536000;
1156
+ return null;
1157
+ }
1158
+
1159
+ /**
1160
+ * #277 cache verdict — the security-critical predicate, extracted pure so the
1161
+ * leak class (a personalized/streaming render marked cacheable) is a TEST, not
1162
+ * a mental walkthrough. INVARIANT: result ⟹ !wantsStream (a streaming render
1163
+ * commits its head before auth/cookies/status are final, so it can never be
1164
+ * cached). Fail-closed: every condition must hold.
1165
+ */
1166
+ export function computeCacheVerdict(args: {
1167
+ revalidateSecs: number | null;
1168
+ forceDynamic: boolean;
1169
+ authTouched: boolean;
1170
+ cookieCount: number;
1171
+ strictPolicies: boolean;
1172
+ wantsStream: boolean;
1173
+ status: number;
1174
+ }): boolean {
1175
+ return (
1176
+ args.revalidateSecs != null &&
1177
+ !args.forceDynamic &&
1178
+ !args.authTouched &&
1179
+ args.cookieCount === 0 &&
1180
+ !args.strictPolicies &&
1181
+ !args.wantsStream &&
1182
+ args.status === 200
1183
+ );
1184
+ }
1185
+
1186
+ /**
1187
+ * #278: diff the response head committed at `response_start` against the final
1188
+ * state after EOF, to catch a late response.* mutation from a suspended subtree
1189
+ * that the already-sent head couldn't carry. Returns the dropped pieces, or
1190
+ * null if nothing was lost. Pure.
1191
+ */
1192
+ export function diffCommittedResponse(
1193
+ snapshot: { status: number; cookies: string[]; headerKeys: string[] },
1194
+ final: { status: number; cookies: string[]; headers: Record<string, string> },
1195
+ ): { droppedCookies: string[]; statusChanged: boolean; newHeaderKeys: string[] } | null {
1196
+ const droppedCookies = final.cookies.filter(
1197
+ (c) => !snapshot.cookies.includes(c),
1198
+ );
1199
+ const statusChanged = final.status !== snapshot.status;
1200
+ const newHeaderKeys = Object.keys(final.headers)
1201
+ .sort()
1202
+ .filter((k) => !snapshot.headerKeys.includes(k));
1203
+ if (droppedCookies.length || statusChanged || newHeaderKeys.length) {
1204
+ return { droppedCookies, statusChanged, newHeaderKeys };
1205
+ }
1206
+ return null;
1207
+ }
1208
+
1130
1209
  export async function handleRenderRoute(
1131
1210
  msg: RenderRouteMessage,
1132
1211
  send: Send,
@@ -1221,13 +1300,28 @@ export async function handleRenderRoute(
1221
1300
  ssrValueCache,
1222
1301
  );
1223
1302
 
1303
+ // #277 cache-safety proof. A render is shareable (CDN/disk cacheable) ONLY
1304
+ // if its output is auth-INDEPENDENT — so wrap props.auth in a Proxy that
1305
+ // flips `authTouched` the moment a page/layout reads it. Reading auth at
1306
+ // all (even for an anonymous request) opts the render OUT of caching,
1307
+ // because the output could differ by identity. The raw auth is restored
1308
+ // before serialization (so the hydration blob carries real values, and so
1309
+ // JSON.stringify doesn't trip the Proxy itself).
1310
+ let authTouched = false;
1311
+ const authProxy = new Proxy(msg.auth as Record<string, unknown>, {
1312
+ get(target, prop, receiver) {
1313
+ authTouched = true;
1314
+ return Reflect.get(target, prop, receiver);
1315
+ },
1316
+ });
1317
+
1224
1318
  props = {
1225
1319
  url: msg.url,
1226
1320
  params: msg.params,
1227
1321
  searchParams: msg.search_params,
1228
1322
  headers: msg.headers,
1229
1323
  cookies: msg.cookies,
1230
- auth: msg.auth,
1324
+ auth: authProxy,
1231
1325
  // Response controller — a page/layout calls response.setStatus /
1232
1326
  // setHeader / setCookie / redirect / notFound to shape the reply.
1233
1327
  response,
@@ -1285,6 +1379,16 @@ export async function handleRenderRoute(
1285
1379
  );
1286
1380
  }
1287
1381
 
1382
+ // Streaming decision (#278). Computed from STATIC module exports only —
1383
+ // knowable before any await, so the buffer/cache decision never reads
1384
+ // non-final render state. A page STREAMS (shell + each inner <Suspense>
1385
+ // fallback flush immediately, content reveals as data resolves) when it has
1386
+ // a loading.tsx (route-level boundary, #278 Stage 1) OR explicitly opts in
1387
+ // with `export const streaming = true` (inner-boundary streaming, Stage 2).
1388
+ // Every un-annotated page keeps the byte-identical BUFFERED path (allReady)
1389
+ // that 100% of today's prod traffic rides — this is opt-in, never default.
1390
+ const wantsStream = computeWantsStream(!!Loading, mod);
1391
+
1288
1392
  // Resolve the layout chain. Each layout module exports a default
1289
1393
  // function that accepts the same props + `children`. Walk leaf →
1290
1394
  // root: start with the page component as `tree`, then for each
@@ -1302,9 +1406,26 @@ export async function handleRenderRoute(
1302
1406
  {
1303
1407
  onError(err: unknown) {
1304
1408
  // React captures render errors during the streaming render
1305
- // and feeds them here. Phase 1 logs to stderr; Phase 1.5
1306
- // sends a structured signal so the host can truncate the
1307
- // body + emit a debug overlay.
1409
+ // and feeds them here. We log to stderr; we do NOT truncate or
1410
+ // rewrite the response here on a streamed render the HTTP head is
1411
+ // already committed, so a mid-stream error just closes the body
1412
+ // (partial HTML); the dev overlay (#275) only covers failures BEFORE
1413
+ // response_start (host-side err channel). Buffered renders surface
1414
+ // their error through the catch/boundary path below.
1415
+ if (err instanceof PylonRouteControl) {
1416
+ // A redirect()/notFound() thrown from BELOW a <Suspense> boundary:
1417
+ // the shell already committed the head, so React swallowed it and
1418
+ // it can't change the response. This is a known limitation on BOTH
1419
+ // the buffered and streamed paths (response.* must fire in the
1420
+ // synchronous shell). Surface it loudly instead of silently losing.
1421
+ // eslint-disable-next-line no-console
1422
+ console.error(
1423
+ `[ssr] response.${err.kind}() called below a <Suspense> boundary was ignored — ` +
1424
+ `the HTTP head was already sent. Call response.redirect()/notFound() in the ` +
1425
+ `synchronous shell render, before any await/<Suspense>.`,
1426
+ );
1427
+ return;
1428
+ }
1308
1429
  // eslint-disable-next-line no-console
1309
1430
  console.error("[ssr] renderToReadableStream onError:", err);
1310
1431
  },
@@ -1321,15 +1442,17 @@ export async function handleRenderRoute(
1321
1442
  // fallback). Pages with no async data have no boundaries, so `allReady`
1322
1443
  // resolves immediately — zero cost for the common case.
1323
1444
  //
1324
- // EXCEPTION (#278): when a loading.tsx wraps the page in a Suspense
1325
- // boundary, we DELIBERATELY skip the buffer and stream the shell +
1326
- // skeleton flush first, then React reveals the real content + its reveal
1327
- // script as the page's `use()` resolves. Hydration stays clean because
1328
- // there's exactly ONE route-level boundary and the tail `__PYLON_DATA__`
1329
- // (emitted below, after the stream drains to EOF) still carries a fully
1330
- // resolved `ssrData` map so the client's `use()` reads a fulfilled
1331
- // value and never re-suspends.
1332
- if (!Loading && (stream as any).allReady) {
1445
+ // EXCEPTION (#278): a STREAMING render (loading.tsx route-level boundary,
1446
+ // or `export const streaming = true` for inner boundaries) DELIBERATELY
1447
+ // skips the buffer the shell + each <Suspense> fallback flush first, then
1448
+ // React reveals each boundary's real content + its reveal script as that
1449
+ // boundary's `use()` resolves. Hydration stays clean for ANY number of
1450
+ // boundaries because Pylon runs hydrateRoot ONCE, post-EOF: the entry
1451
+ // <script> is appended AFTER the full `__PYLON_DATA__` blob (which carries
1452
+ // the fully-resolved `ssrData` map) and after all of React's $RC reveals,
1453
+ // so the client's `use()` reads a fulfilled value and never re-suspends —
1454
+ // there is no progressive hydration racing the stream.
1455
+ if (!wantsStream && (stream as any).allReady) {
1333
1456
  await (stream as any).allReady;
1334
1457
  }
1335
1458
 
@@ -1338,11 +1461,61 @@ export async function handleRenderRoute(
1338
1461
  // The shell rendered without a redirect()/notFound() throw, so the
1339
1462
  // page's chosen status (default 200) + headers + cookies go out now,
1340
1463
  // before the first body byte.
1464
+ //
1465
+ // #277 cache verdict (Stage 1, buffered path only — a streaming render
1466
+ // commits its head before the body resolves, so `authTouched` isn't final
1467
+ // yet). A render is shareable (CDN-cacheable via `public, s-maxage`) ONLY
1468
+ // when ALL hold: it opted in (`export const revalidate` / `dynamic:
1469
+ // "force-static"`), never read props.auth (authTouched), set no cookie,
1470
+ // isn't `force-dynamic`, and per-caller strict policies are OFF — in strict
1471
+ // mode serverData reads are auth-filtered, so the output isn't shareable.
1472
+ // We emit an INTERNAL `x-pylon-cacheable` header the host turns into a
1473
+ // public Cache-Control and STRIPS; its ABSENCE is the fail-closed default
1474
+ // (the host keeps no-cache / no-store). The 200-only guard avoids caching
1475
+ // an error/redirect.
1476
+ const revalidateSecs = computeRevalidateSecs(mod);
1477
+ const forceDynamic = (mod as any).dynamic === "force-dynamic";
1478
+ const strictPolicies = process.env.PYLON_STRICT_FN_POLICIES === "1";
1479
+ // INVARIANT: cacheable ⟹ !wantsStream. A streaming render commits its head
1480
+ // (response_start) BEFORE suspended subtrees finish, so `authTouched`,
1481
+ // `responseState.cookies`, and `.status` are NOT final here — caching it
1482
+ // could share a personalized/non-final body. So `!wantsStream` (NOT just
1483
+ // `!Loading`) is the gate: a `streaming = true` page has `Loading` null but
1484
+ // `wantsStream` true, and must still be excluded. Fail-closed. (See
1485
+ // computeCacheVerdict — pure + unit-tested for the leak class.)
1486
+ const cacheable = computeCacheVerdict({
1487
+ revalidateSecs,
1488
+ forceDynamic,
1489
+ authTouched,
1490
+ cookieCount: responseState.cookies.length,
1491
+ strictPolicies,
1492
+ wantsStream,
1493
+ status: responseState.status,
1494
+ });
1495
+ // Restore the raw auth before any serialization below (the Proxy was only
1496
+ // for the render-time auth-touch probe).
1497
+ if (props) props.auth = msg.auth;
1498
+ // #278: on a STREAMING render the head commits NOW, before suspended
1499
+ // subtrees run. Snapshot what's committed so we can detect (after EOF) a
1500
+ // late response.setStatus/setCookie/setHeader from a suspended subtree that
1501
+ // got silently dropped — and warn loudly instead of leaving the dev to
1502
+ // debug a missing Set-Cookie. Buffered renders need no snapshot (the whole
1503
+ // render is done before this point, so nothing can change after).
1504
+ const committedSnapshot = wantsStream
1505
+ ? {
1506
+ status: responseState.status,
1507
+ cookies: responseState.cookies.map((c) => String(c)),
1508
+ headerKeys: Object.keys(responseState.headers).sort(),
1509
+ }
1510
+ : null;
1341
1511
  send({
1342
1512
  type: "response_start",
1343
1513
  call_id: msg.call_id,
1344
1514
  status: responseState.status,
1345
- headers: finalizeHeaders(responseState),
1515
+ headers: finalizeHeaders(
1516
+ responseState,
1517
+ cacheable ? { "x-pylon-cacheable": String(revalidateSecs) } : {},
1518
+ ),
1346
1519
  });
1347
1520
 
1348
1521
  // Pre-load the manifest BEFORE the React stream starts emitting
@@ -1408,6 +1581,47 @@ export async function handleRenderRoute(
1408
1581
  };
1409
1582
  await streamWithHeadInjection(stream.getReader(), headBlob, sendChunk);
1410
1583
 
1584
+ // #278: detect a late response.* mutation from a suspended subtree that the
1585
+ // already-committed head couldn't carry, and warn loudly (a silently
1586
+ // dropped Set-Cookie reads as "logged out" / missing CSRF in prod). Only
1587
+ // for streamed renders — a buffered render finalized before response_start,
1588
+ // so nothing changes after. The fix for the dev is to move the call into
1589
+ // the synchronous shell (or drop `streaming = true`); we name what was lost.
1590
+ if (committedSnapshot) {
1591
+ // Same diff the unit tests exercise — call the pure helper so the tested
1592
+ // path IS the prod path (no drift).
1593
+ const dropped = diffCommittedResponse(committedSnapshot, {
1594
+ status: responseState.status,
1595
+ cookies: responseState.cookies,
1596
+ headers: responseState.headers,
1597
+ });
1598
+ if (dropped) {
1599
+ const parts: string[] = [];
1600
+ if (dropped.droppedCookies.length)
1601
+ parts.push(
1602
+ `Set-Cookie [${dropped.droppedCookies
1603
+ .map((c) => {
1604
+ const eq = c.indexOf("="); // serialized "name=value; …"
1605
+ return eq >= 0 ? c.slice(0, eq) : c;
1606
+ })
1607
+ .join(", ")}]`,
1608
+ );
1609
+ if (dropped.statusChanged)
1610
+ parts.push(
1611
+ `status ${committedSnapshot.status}→${responseState.status}`,
1612
+ );
1613
+ if (dropped.newHeaderKeys.length)
1614
+ parts.push(`headers [${dropped.newHeaderKeys.join(", ")}]`);
1615
+ // eslint-disable-next-line no-console
1616
+ console.error(
1617
+ `[ssr] response.* called below a <Suspense> boundary on a streaming ` +
1618
+ `route was DROPPED (the HTTP head already shipped): ${parts.join("; ")}. ` +
1619
+ `Set response status/cookies/headers in the synchronous shell render, ` +
1620
+ `before any await/<Suspense> — or remove \`export const streaming = true\`.`,
1621
+ );
1622
+ }
1623
+ }
1624
+
1411
1625
  // Hydration tail. After React's stream EOFs we append the
1412
1626
  // hydration markers so the browser can hydrate:
1413
1627
  // 1. `__PYLON_DATA__` — JSON-typed script with the props the
@@ -0,0 +1,264 @@
1
+ /**
2
+ * #278 Stage 2 — progressive streaming harness.
3
+ *
4
+ * Two layers:
5
+ * 1. PURE verdict logic (computeWantsStream / computeRevalidateSecs /
6
+ * computeCacheVerdict / diffCommittedResponse) — the security-critical
7
+ * "is this render cacheable / should it stream" gate, tested directly
8
+ * including the leak-class invariant `cacheable ⟹ !wantsStream`.
9
+ * 2. The actual React streaming MECHANISM through react-dom/server.browser —
10
+ * proving the buffered (allReady) path emits clean inline HTML, and the
11
+ * streamed path flushes the shell + Suspense fallback first then reveals.
12
+ *
13
+ * Hydration (the prod-killer "stuck on fallback" check) lives in the sibling
14
+ * ssr-hydration.test.ts (it mutates DOM globals, so it's isolated).
15
+ */
16
+ import { describe, expect, test } from "bun:test";
17
+ import React, { Suspense, use } from "react";
18
+ import { renderToReadableStream } from "react-dom/server.browser";
19
+ import {
20
+ buildHydrationTail,
21
+ computeCacheVerdict,
22
+ computeRevalidateSecs,
23
+ computeWantsStream,
24
+ diffCommittedResponse,
25
+ } from "./ssr-runtime";
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Pure verdict logic
29
+ // ---------------------------------------------------------------------------
30
+
31
+ describe("computeWantsStream", () => {
32
+ test("loading.tsx OR streaming=true opts in; nothing else does", () => {
33
+ expect(computeWantsStream(true, {})).toBe(true); // loading.tsx
34
+ expect(computeWantsStream(false, { streaming: true })).toBe(true); // opt-in
35
+ expect(computeWantsStream(true, { streaming: true })).toBe(true);
36
+ expect(computeWantsStream(false, {})).toBe(false); // default = buffered
37
+ expect(computeWantsStream(false, { streaming: false })).toBe(false);
38
+ expect(computeWantsStream(false, { streaming: 1 as any })).toBe(false); // strict ===
39
+ expect(computeWantsStream(false, null)).toBe(false);
40
+ });
41
+ });
42
+
43
+ describe("computeRevalidateSecs", () => {
44
+ test("revalidate>0 → floor; force-static → a year; else null", () => {
45
+ expect(computeRevalidateSecs({ revalidate: 60 })).toBe(60);
46
+ expect(computeRevalidateSecs({ revalidate: 12.9 })).toBe(12);
47
+ expect(computeRevalidateSecs({ revalidate: 0 })).toBeNull();
48
+ expect(computeRevalidateSecs({ revalidate: -5 })).toBeNull();
49
+ expect(computeRevalidateSecs({ dynamic: "force-static" })).toBe(31536000);
50
+ expect(computeRevalidateSecs({ dynamic: "force-dynamic" })).toBeNull();
51
+ expect(computeRevalidateSecs({})).toBeNull();
52
+ expect(computeRevalidateSecs(null)).toBeNull();
53
+ });
54
+ });
55
+
56
+ describe("computeCacheVerdict (the #277 leak-class gate)", () => {
57
+ const base = {
58
+ revalidateSecs: 60 as number | null,
59
+ forceDynamic: false,
60
+ authTouched: false,
61
+ cookieCount: 0,
62
+ strictPolicies: false,
63
+ wantsStream: false,
64
+ status: 200,
65
+ };
66
+
67
+ test("a clean opted-in buffered 200 is cacheable", () => {
68
+ expect(computeCacheVerdict(base)).toBe(true);
69
+ });
70
+
71
+ test("every single veto flips it to non-cacheable (fail-closed)", () => {
72
+ expect(computeCacheVerdict({ ...base, revalidateSecs: null })).toBe(false); // no opt-in
73
+ expect(computeCacheVerdict({ ...base, forceDynamic: true })).toBe(false);
74
+ expect(computeCacheVerdict({ ...base, authTouched: true })).toBe(false); // read auth
75
+ expect(computeCacheVerdict({ ...base, cookieCount: 1 })).toBe(false); // set a cookie
76
+ expect(computeCacheVerdict({ ...base, strictPolicies: true })).toBe(false);
77
+ expect(computeCacheVerdict({ ...base, wantsStream: true })).toBe(false); // STREAMING
78
+ expect(computeCacheVerdict({ ...base, status: 404 })).toBe(false);
79
+ expect(computeCacheVerdict({ ...base, status: 307 })).toBe(false);
80
+ });
81
+
82
+ test("INVARIANT: cacheable ⟹ !wantsStream over the full cross-product", () => {
83
+ const bools = [false, true];
84
+ const statuses = [200, 201, 302, 404, 500];
85
+ const revs: (number | null)[] = [null, 0 as any, 60, 31536000];
86
+ let checked = 0;
87
+ for (const forceDynamic of bools)
88
+ for (const authTouched of bools)
89
+ for (const strictPolicies of bools)
90
+ for (const wantsStream of bools)
91
+ for (const cookieCount of [0, 1])
92
+ for (const status of statuses)
93
+ for (const revalidateSecs of revs) {
94
+ const c = computeCacheVerdict({
95
+ revalidateSecs,
96
+ forceDynamic,
97
+ authTouched,
98
+ cookieCount,
99
+ strictPolicies,
100
+ wantsStream,
101
+ status,
102
+ });
103
+ // The load-bearing security invariant: a streaming render is
104
+ // NEVER cacheable (its head commits before auth/cookies/status
105
+ // are final).
106
+ if (c) expect(wantsStream).toBe(false);
107
+ checked++;
108
+ }
109
+ expect(checked).toBe(640); // 2^4 (bools) × 2 (cookies) × 5 (status) × 4 (revs)
110
+ });
111
+ });
112
+
113
+ describe("diffCommittedResponse (#278 late-response.* drop detector)", () => {
114
+ const snap = (over: any = {}) => ({
115
+ status: 200,
116
+ cookies: ["sid=committed"],
117
+ headerKeys: ["x-base"],
118
+ ...over,
119
+ });
120
+
121
+ test("no change → null", () => {
122
+ const r = diffCommittedResponse(snap(), {
123
+ status: 200,
124
+ cookies: ["sid=committed"],
125
+ headers: { "x-base": "1" },
126
+ });
127
+ expect(r).toBeNull();
128
+ });
129
+
130
+ test("a late Set-Cookie from a suspended subtree is reported", () => {
131
+ const r = diffCommittedResponse(snap(), {
132
+ status: 200,
133
+ cookies: ["sid=committed", "flash=late"],
134
+ headers: { "x-base": "1" },
135
+ });
136
+ expect(r).not.toBeNull();
137
+ expect(r!.droppedCookies).toEqual(["flash=late"]);
138
+ });
139
+
140
+ test("a late status change + new header are reported", () => {
141
+ const r = diffCommittedResponse(snap(), {
142
+ status: 201,
143
+ cookies: ["sid=committed"],
144
+ headers: { "x-base": "1", "x-late": "2" },
145
+ });
146
+ expect(r!.statusChanged).toBe(true);
147
+ expect(r!.newHeaderKeys).toEqual(["x-late"]);
148
+ });
149
+ });
150
+
151
+ // ---------------------------------------------------------------------------
152
+ // React streaming mechanism (real renderToReadableStream)
153
+ // ---------------------------------------------------------------------------
154
+
155
+ function makeDeferred<T>() {
156
+ let resolve!: (v: T) => void;
157
+ const promise = new Promise<T>((r) => (resolve = r));
158
+ return { promise, resolve };
159
+ }
160
+
161
+ function Rows({ p }: { p: Promise<string[]> }) {
162
+ const rows = use(p);
163
+ return React.createElement(
164
+ "ul",
165
+ { id: "rows" },
166
+ rows.map((r) => React.createElement("li", { key: r }, r)),
167
+ );
168
+ }
169
+
170
+ // A page with an inner <Suspense> reading async data — the /notes shape.
171
+ function Page({ p }: { p: Promise<string[]> }) {
172
+ return React.createElement(
173
+ "div",
174
+ { id: "app" },
175
+ React.createElement("h1", { id: "shell" }, "Shell"),
176
+ React.createElement(
177
+ Suspense,
178
+ { fallback: React.createElement("p", { id: "fallback" }, "Loading…") },
179
+ React.createElement(Rows, { p }),
180
+ ),
181
+ );
182
+ }
183
+
184
+ const dec = new TextDecoder();
185
+
186
+ describe("react streaming mechanism", () => {
187
+ test("BUFFERED (await allReady, then drain) → clean inline, no reveal scripts", async () => {
188
+ // Mirrors the runtime's `if (!wantsStream) await stream.allReady` path.
189
+ const d = makeDeferred<string[]>();
190
+ setTimeout(() => d.resolve(["a", "b"]), 5);
191
+ const stream = await renderToReadableStream(
192
+ React.createElement(Page, { p: d.promise }),
193
+ );
194
+ await (stream as any).allReady;
195
+ const reader = stream.getReader();
196
+ let html = "";
197
+ for (;;) {
198
+ const { value, done } = await reader.read();
199
+ if (done) break;
200
+ html += dec.decode(value);
201
+ }
202
+ // Resolved rows inline; NO fallback, NO pending marker, NO $RC reveal /
203
+ // <template>. This is the byte-identical buffered contract today's prod
204
+ // traffic rides — the regression lock.
205
+ expect(html).toContain("<li>a</li>");
206
+ expect(html).toContain("<li>b</li>");
207
+ expect(html).not.toContain("Loading…");
208
+ expect(html).not.toContain("<!--$?-->"); // no PENDING boundary marker
209
+ expect(html).not.toContain("<template");
210
+ expect(/\$RC|completeBoundary/.test(html)).toBe(false);
211
+ });
212
+
213
+ test("STREAMING (drain progressively) → shell+fallback first, rows+reveal later", async () => {
214
+ const d = makeDeferred<string[]>();
215
+ const stream = await renderToReadableStream(
216
+ React.createElement(Page, { p: d.promise }),
217
+ );
218
+ const reader = stream.getReader();
219
+ // First flush = the shell with the fallback (data still pending).
220
+ const first = await reader.read();
221
+ const firstHtml = dec.decode(first.value);
222
+ expect(firstHtml).toContain("Shell");
223
+ expect(firstHtml).toContain("Loading…"); // fallback streamed
224
+ expect(firstHtml).not.toContain("<li>a</li>"); // rows NOT here yet
225
+ expect(firstHtml).toContain("<template"); // boundary placeholder
226
+ // Now the data resolves → React reveals the boundary in a later chunk.
227
+ d.resolve(["a", "b"]);
228
+ let rest = "";
229
+ for (;;) {
230
+ const { value, done } = await reader.read();
231
+ if (done) break;
232
+ rest += dec.decode(value);
233
+ }
234
+ expect(rest).toContain("<li>a</li>");
235
+ expect(rest).toContain("<li>b</li>");
236
+ expect(/\$RC|completeBoundary|<template/.test(rest)).toBe(true); // reveal
237
+ });
238
+ });
239
+
240
+ describe("hydration tail ordering (#278: data blob before entry script)", () => {
241
+ test("__PYLON_DATA__ carries ssrData and the entry <script type=module> is LAST", () => {
242
+ const ssrData = { 'list:["Note"]': [{ id: "1", body: "hi" }] };
243
+ const tail = buildHydrationTail({
244
+ component: "app/notes/page",
245
+ layouts: [],
246
+ props: { url: "/notes", params: {}, searchParams: {} },
247
+ ssrData,
248
+ manifestRoute: { file: "notes.js", imports: ["chunk.js"], css: [] },
249
+ publicPrefix: "/_pylon/build/",
250
+ manifestErr: null,
251
+ });
252
+ const dataIdx = tail.indexOf('id="__PYLON_DATA__"');
253
+ const entryIdx = tail.indexOf("notes.js");
254
+ expect(dataIdx).toBeGreaterThanOrEqual(0);
255
+ expect(entryIdx).toBeGreaterThanOrEqual(0);
256
+ // The entry script MUST come after the data blob so hydrateRoot (which the
257
+ // entry triggers) sees a fully-seeded ssrData — the whole reason multi-
258
+ // boundary streaming hydrates cleanly without inline patch scripts.
259
+ expect(entryIdx).toBeGreaterThan(dataIdx);
260
+ // ssrData round-trips into the blob.
261
+ expect(tail).toContain("Note");
262
+ expect(tail).toContain('"body":"hi"');
263
+ });
264
+ });