@pylonsync/functions 0.3.246 → 0.3.248

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.246",
3
+ "version": "0.3.248",
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",
package/src/runtime.ts CHANGED
@@ -223,11 +223,17 @@ function dispatch(line: string): void {
223
223
  ),
224
224
  )
225
225
  .catch((err) => {
226
+ const devMode =
227
+ process.env.PYLON_DEV_MODE === "1" ||
228
+ process.env.PYLON_DEV_MODE === "true";
226
229
  send({
227
230
  type: "error",
228
231
  call_id: (msg as unknown as { call_id: string }).call_id,
229
232
  code: "SSR_RUNTIME_CRASH",
230
- message: err?.message || String(err),
233
+ // Dev: full stack for the host's error overlay. Prod: message only.
234
+ message:
235
+ (devMode && err?.stack ? String(err.stack) : err?.message) ||
236
+ String(err),
231
237
  });
232
238
  });
233
239
  } else if (msg.type === "bundle_client") {
@@ -130,6 +130,22 @@ function discoverRoutes(
130
130
  layouts: nextLayouts,
131
131
  });
132
132
  }
133
+ // Boundary modules (not-found.tsx / error.tsx) are hydrated like pages
134
+ // (#279) so onClick/useState/reset() work — that means each needs its own
135
+ // client entry + manifest key, keyed by component path exactly like a page.
136
+ // They wrap in the layouts ABOVE them (nextLayouts), same as a page here.
137
+ for (const base of ["not-found", "error"]) {
138
+ const bHere = [`${base}.tsx`, `${base}.ts`, `${base}.jsx`, `${base}.js`]
139
+ .map((n: string) => path.join(dir, n))
140
+ .find((p: string) => fs.existsSync(p));
141
+ if (bHere) {
142
+ pages.push({
143
+ segments: [...segments],
144
+ component: path.relative(cwd, bHere).replace(/\.(tsx?|jsx?)$/, ""),
145
+ layouts: nextLayouts,
146
+ });
147
+ }
148
+ }
133
149
  for (const e of entries) {
134
150
  if (!e.isDirectory()) continue;
135
151
  if (e.name.startsWith(".") || e.name === "node_modules") continue;
@@ -271,10 +287,18 @@ function makeNoopResponse() {
271
287
 
272
288
  // Rehydrate the live, server-only props (serverData + response) that were
273
289
  // stripped before serialization, so the client tree matches the server's.
290
+ // For a hydrated error boundary (#279), synthesize the reset() the server
291
+ // rendered as a no-op: re-fetch + re-render the current URL (a transient
292
+ // error clears to the page; a deterministic one re-shows the boundary).
274
293
  function withClientProps(data) {
275
294
  const props = { ...(data.props || {}) };
276
295
  props.serverData = makeClientServerData(data.ssrData);
277
296
  props.response = makeNoopResponse();
297
+ if (data.kind === "error") {
298
+ props.reset = function () {
299
+ navigate(location.pathname + location.search, { replace: true });
300
+ };
301
+ }
278
302
  return props;
279
303
  }
280
304
 
@@ -382,6 +406,27 @@ export function hydrate(component, Page, Layouts) {
382
406
  // only need to populate the cache (already done above).
383
407
  }
384
408
 
409
+ // Swap the page's SEO/social <head> tags on a client-side navigation.
410
+ // The SSR runtime marks every page-metadata <meta>/<link> with
411
+ // data-pylon-meta; we drop the current set and import the incoming page's
412
+ // set, so description / canonical / og:* / twitter:* / icons track the new
413
+ // route. The layout's charset/viewport and Pylon's injected stylesheet
414
+ // links carry no marker, so they survive untouched (no FOUC). The page
415
+ // component never renders these tags on the client, so React doesn't own
416
+ // them — this manual swap can't fight hydration.
417
+ function syncHeadMeta(doc) {
418
+ const head = document.head;
419
+ if (!head) return;
420
+ const current = head.querySelectorAll("[data-pylon-meta]");
421
+ for (let i = 0; i < current.length; i++) current[i].remove();
422
+ const incoming = doc.head
423
+ ? doc.head.querySelectorAll("[data-pylon-meta]")
424
+ : [];
425
+ for (let i = 0; i < incoming.length; i++) {
426
+ head.appendChild(document.importNode(incoming[i], true));
427
+ }
428
+ }
429
+
385
430
  async function navigate(href, opts) {
386
431
  const push = !opts || opts.push !== false;
387
432
  const url = new URL(href, location.href);
@@ -426,11 +471,19 @@ async function navigate(href, opts) {
426
471
  return;
427
472
  }
428
473
  document.title = doc.title || document.title;
474
+ syncHeadMeta(doc);
429
475
  const tree = buildTree(route.Page, route.Layouts, withClientProps(data));
430
476
  activeRoot.render(tree);
431
- if (push) {
432
- history.pushState({ component: data.component }, "", url.pathname + url.search);
477
+ const target = url.pathname + url.search;
478
+ if (opts && opts.replace) {
479
+ history.replaceState({ component: data.component }, "", target);
480
+ } else if (push) {
481
+ history.pushState({ component: data.component }, "", target);
433
482
  }
483
+ // Notify the router hooks (useSearchParams / usePathname) so deep children
484
+ // re-read location after a Link click or a router.push(). popstate already
485
+ // covers back/forward, but pushState/replaceState fire no event.
486
+ window.dispatchEvent(new Event("pylon:navigation"));
434
487
  // After a successful nav, scroll to top (Next.js default).
435
488
  window.scrollTo(0, 0);
436
489
  }
@@ -635,7 +688,14 @@ async function buildTailwind(
635
688
  // Mix in the discovered routes so adding/removing pages changes
636
689
  // the hash (Tailwind v4 auto-discovers `@source` paths; we still
637
690
  // want the cache to bust on layout changes).
638
- const stylesName = `styles-${hash.toString(36)}.css`;
691
+ //
692
+ // Pad the base36 hash to 8 chars: the runtime's `is_hashed_name`
693
+ // (frontend.rs) only sends `Cache-Control: immutable` for hashes ≥8
694
+ // chars (Bun's JS chunk convention). A 32-bit base36 hash is ≤7 chars,
695
+ // so WITHOUT the pad the content-hashed CSS was served `no-cache` —
696
+ // browsers + any CDN refetched it on every page load (defeats the cache
697
+ // and, behind Cloudflare, needlessly wakes an autostopped origin).
698
+ const stylesName = `styles-${hash.toString(36).padStart(8, "0")}.css`;
639
699
  const outPath = path.join(outdir, stylesName);
640
700
 
641
701
  // Spawn the CLI. Bun is already running; reuse it as the
@@ -4,7 +4,36 @@ import { afterEach, describe, expect, test } from "bun:test";
4
4
  import * as fs from "node:fs";
5
5
  import * as os from "node:os";
6
6
  import * as path from "node:path";
7
- import { applyAutoIcons, applyAutoSocialImages } from "./ssr-runtime";
7
+ import {
8
+ applyAutoIcons,
9
+ applyAutoSocialImages,
10
+ renderMetadata,
11
+ buildHydrationTail,
12
+ errorDigest,
13
+ } from "./ssr-runtime";
14
+
15
+ // Pull the JSON out of the `__PYLON_DATA__` <script> a hydration tail emits.
16
+ function extractPylonData(tail: string): any {
17
+ const m = tail.match(
18
+ /<script id="__PYLON_DATA__" type="application\/json">([\s\S]*?)<\/script>/,
19
+ );
20
+ if (!m) throw new Error("no __PYLON_DATA__ in tail");
21
+ return JSON.parse(m[1]); // JSON.parse natively decodes the < escaping
22
+ }
23
+
24
+ // `react` isn't a dependency of @pylonsync/functions — the SSR runtime
25
+ // imports it dynamically from the host project at render time. For unit
26
+ // tests we hand renderMetadata a fake React that records each created
27
+ // element's shape, which is all renderMetadata touches.
28
+ const FAKE_FRAGMENT = Symbol("Fragment");
29
+ const fakeReact = {
30
+ Fragment: FAKE_FRAGMENT,
31
+ createElement: (type: any, props: any, ...children: any[]) => ({
32
+ type,
33
+ props: props ?? {},
34
+ children,
35
+ }),
36
+ };
8
37
 
9
38
  // A minimal PNG: 8-byte signature + an IHDR chunk carrying width/height.
10
39
  // `readSocialImageMeta` only reads the first 32 bytes, so a full valid
@@ -180,3 +209,139 @@ describe("icon / apple-icon / favicon file convention", () => {
180
209
  expect(applyAutoIcons("app/page", input)).toEqual(input);
181
210
  });
182
211
  });
212
+
213
+ describe("renderMetadata head-tag marking (client-nav sync)", () => {
214
+ test("every <meta>/<link> carries data-pylon-meta; <title> does not", () => {
215
+ const frag = renderMetadata(fakeReact, {
216
+ title: "Hello",
217
+ description: "A page",
218
+ canonical: "https://x.test/p",
219
+ openGraph: { title: "OG", image: "https://x.test/og.png" },
220
+ twitter: { card: "summary" },
221
+ icons: { icon: { url: "/icon.png" } },
222
+ });
223
+ expect(frag.type).toBe(FAKE_FRAGMENT);
224
+ const kids: any[] = frag.children;
225
+ const metaLink = kids.filter((k) => k.type === "meta" || k.type === "link");
226
+ const titles = kids.filter((k) => k.type === "title");
227
+ expect(metaLink.length).toBeGreaterThan(0);
228
+ // The marker is what the client runtime swaps on navigation — without it,
229
+ // SEO/social tags go stale on client-side nav. Every meta/link must carry
230
+ // it; <title> must NOT (the client syncs document.title separately).
231
+ for (const el of metaLink) {
232
+ expect(el.props["data-pylon-meta"]).toBe("");
233
+ }
234
+ expect(titles.length).toBe(1);
235
+ expect(titles[0].props["data-pylon-meta"]).toBeUndefined();
236
+ expect(titles[0].children).toEqual(["Hello"]);
237
+ });
238
+
239
+ test("returns null when there's nothing to emit", () => {
240
+ expect(renderMetadata(fakeReact, undefined)).toBeNull();
241
+ expect(renderMetadata(fakeReact, {})).toBeNull();
242
+ });
243
+ });
244
+
245
+ describe("buildHydrationTail — boundary hydration (#279) + strip (#270)", () => {
246
+ const manifestRoute = { file: "app__error-x.js", imports: [], css: [] };
247
+
248
+ test("error boundary serializes {message,digest}; raw error/stack/cookies NEVER cross the wire", () => {
249
+ const tail = buildHydrationTail({
250
+ component: "app/error",
251
+ layouts: ["app/layout"],
252
+ props: {
253
+ url: "/boom",
254
+ auth: { user_id: "u1", is_admin: false, tenant_id: null, roles: [] },
255
+ // live, non-serializable + sensitive handles that MUST be stripped:
256
+ error: new Error("DB exploded at secretHost:5432"),
257
+ serverData: { get() {} },
258
+ response: { setStatus() {} },
259
+ reset: () => {},
260
+ headers: { cookie: "pylon_session=SUPERSECRET" },
261
+ cookies: { pylon_session: "SUPERSECRET" },
262
+ },
263
+ ssrData: {},
264
+ manifestRoute,
265
+ publicPrefix: "/_pylon/build/",
266
+ manifestErr: null,
267
+ kind: "error",
268
+ errorForClient: { message: "Something went wrong", digest: "deadbeef" },
269
+ });
270
+ const data = extractPylonData(tail);
271
+ expect(data.kind).toBe("error");
272
+ expect(data.component).toBe("app/error");
273
+ // The client error.tsx gets ONLY the safe projection.
274
+ expect(data.props.error).toEqual({
275
+ message: "Something went wrong",
276
+ digest: "deadbeef",
277
+ });
278
+ // Live handles stripped; headers/cookies emptied (shape preserved).
279
+ expect(data.props.serverData).toBeUndefined();
280
+ expect(data.props.response).toBeUndefined();
281
+ expect(data.props.reset).toBeUndefined();
282
+ expect(data.props.headers).toEqual({});
283
+ expect(data.props.cookies).toEqual({});
284
+ // The session cookie + the raw error message/stack are nowhere in the blob.
285
+ expect(tail).not.toContain("SUPERSECRET");
286
+ expect(tail).not.toContain("secretHost");
287
+ expect(tail).not.toContain("stack");
288
+ // The per-boundary entry script is appended.
289
+ expect(tail).toContain('src="/_pylon/build/app__error-x.js"');
290
+ });
291
+
292
+ test("not-found boundary carries kind but no error/reset", () => {
293
+ const tail = buildHydrationTail({
294
+ component: "app/not-found",
295
+ layouts: [],
296
+ props: { url: "/missing", auth: {}, response: {}, serverData: {} },
297
+ ssrData: {},
298
+ manifestRoute,
299
+ publicPrefix: "/_pylon/build/",
300
+ manifestErr: null,
301
+ kind: "not-found",
302
+ });
303
+ const data = extractPylonData(tail);
304
+ expect(data.kind).toBe("not-found");
305
+ expect(data.props.error).toBeUndefined();
306
+ expect(data.props.reset).toBeUndefined();
307
+ });
308
+
309
+ test("a page (no kind) hydrates without a kind field", () => {
310
+ const tail = buildHydrationTail({
311
+ component: "app/page",
312
+ layouts: ["app/layout"],
313
+ props: { url: "/", auth: {}, response: {}, serverData: {} },
314
+ ssrData: { "list:Note": [] },
315
+ manifestRoute,
316
+ publicPrefix: "/_pylon/build/",
317
+ manifestErr: null,
318
+ });
319
+ const data = extractPylonData(tail);
320
+ expect(data.kind).toBeUndefined();
321
+ expect(data.ssrData).toEqual({ "list:Note": [] });
322
+ });
323
+
324
+ test("no manifest entry → hydration-disabled warning, not an entry script", () => {
325
+ const tail = buildHydrationTail({
326
+ component: "app/page",
327
+ layouts: [],
328
+ props: { url: "/" },
329
+ ssrData: {},
330
+ manifestRoute: null,
331
+ publicPrefix: "/_pylon/build/",
332
+ manifestErr: "manifest crashed",
333
+ });
334
+ expect(tail).toContain("hydration disabled");
335
+ expect(tail).not.toContain('type="module" src=');
336
+ });
337
+
338
+ test("errorDigest is deterministic, stack-free, 8 hex chars", () => {
339
+ const e = new Error("boom");
340
+ const d1 = errorDigest(e);
341
+ const d2 = errorDigest(e);
342
+ expect(d1).toBe(d2);
343
+ expect(d1).toMatch(/^[0-9a-f]{8}$/);
344
+ // A different error yields a different digest.
345
+ expect(errorDigest(new Error("other"))).not.toBe(d1);
346
+ });
347
+ });
@@ -306,9 +306,19 @@ export interface SsrMetadata {
306
306
  * host's </head> splice preserves them. React escapes all text/attrs, so
307
307
  * there's no manual XSS handling. Returns null when there's nothing to emit.
308
308
  */
309
- function renderMetadata(React: any, m: SsrMetadata | undefined): any {
309
+ export function renderMetadata(React: any, m: SsrMetadata | undefined): any {
310
310
  if (!m) return null;
311
- const el = React.createElement;
311
+ // Mark every emitted <meta>/<link> with `data-pylon-meta` so the client
312
+ // runtime can swap exactly these tags on a client-side navigation — and
313
+ // leave the layout's charset/viewport and Pylon's injected stylesheet
314
+ // links untouched. <title> is excluded (the client syncs document.title
315
+ // directly). The metadata fragment is server-only (the client renders the
316
+ // page component alone), so React on the client never owns these nodes;
317
+ // this manual marking is what makes the nav-time swap safe.
318
+ const el = (type: any, props: any, ...children: any[]) =>
319
+ type === "meta" || type === "link"
320
+ ? React.createElement(type, { "data-pylon-meta": "", ...props }, ...children)
321
+ : React.createElement(type, props, ...children);
312
322
  const kids: any[] = [];
313
323
  if (m.title != null) kids.push(el("title", { key: "t" }, m.title));
314
324
  if (m.description != null) {
@@ -782,11 +792,116 @@ async function collectBoundaryHeadBlob(): Promise<string> {
782
792
  }
783
793
 
784
794
  /**
785
- * Render a boundary (not-found/error) tree and stream it as the response
786
- * body at `status`. Boundaries render server-side only (no hydration
787
- * payload) they're informational pages, consistent with the keystone's
788
- * fixed 404 body that this replaces but they DO get the app's global
789
- * stylesheet injected so they match the rest of the site.
795
+ * Build the hydration tail appended after React's stream EOFs: the
796
+ * `__PYLON_DATA__` JSON blob (props + ssrData) + the per-route entry
797
+ * `<script>` that hydrates it, + (dev) the live-reload snippet. Shared by the
798
+ * page render AND the now-hydrated boundary render (#279) so a boundary
799
+ * hydrates through the EXACT same path as a page.
800
+ *
801
+ * `kind` marks an error/not-found boundary so the client knows whether to
802
+ * synthesize a `reset()`. For an error boundary, `errorForClient` is the SAFE
803
+ * projection ({message, digest}) — the raw `Error` (and its stack) is NEVER
804
+ * serialized (the dev overlay owns dev stacks; preserves the #270 posture).
805
+ */
806
+ export function buildHydrationTail(args: {
807
+ component: string;
808
+ layouts: string[];
809
+ props: any;
810
+ ssrData: Record<string, any>;
811
+ manifestRoute: { file: string; imports: string[]; css: string[] } | null;
812
+ publicPrefix: string;
813
+ manifestErr: string | null;
814
+ kind?: "error" | "not-found";
815
+ errorForClient?: { message: string; digest?: string };
816
+ }): string {
817
+ // Strip live, non-serializable handles (serverData / response / reset) + the
818
+ // request headers/cookies (SECURITY: never expose the session cookie to
819
+ // client JS — see #270). The raw `error` Error is dropped too; an error
820
+ // boundary's client-visible error rides in `errorForClient` instead.
821
+ const {
822
+ serverData: _sd,
823
+ response: _resp,
824
+ reset: _reset,
825
+ headers: _h,
826
+ cookies: _c,
827
+ error: _err,
828
+ ...restProps
829
+ } = args.props ?? {};
830
+ const serializableProps: any = { ...restProps, headers: {}, cookies: {} };
831
+ if (args.errorForClient) serializableProps.error = args.errorForClient;
832
+ const hydrationPayload: any = {
833
+ component: args.component,
834
+ layouts: args.layouts ?? [],
835
+ props: serializableProps,
836
+ ssrData: args.ssrData,
837
+ };
838
+ if (args.kind) hydrationPayload.kind = args.kind;
839
+ // Escape `<` (closes a </script> breakout) + U+2028/U+2029 (JSON-valid but
840
+ // JS statement terminators). Regex form keeps the separators visible in
841
+ // source rather than as invisible literals.
842
+ const json = JSON.stringify(hydrationPayload)
843
+ .replace(/</g, "\\u003c")
844
+ .replace(/\u2028/g, "\\u2028")
845
+ .replace(/\u2029/g, "\\u2029");
846
+ let tail = `<script id="__PYLON_DATA__" type="application/json">${json}</script>`;
847
+ if (args.manifestRoute) {
848
+ tail += `<script type="module" src="${args.publicPrefix}${args.manifestRoute.file}"></script>`;
849
+ } else {
850
+ tail += `<script>console.warn(${JSON.stringify(`[pylon ssr] hydration disabled: ${args.manifestErr}`)})</script>`;
851
+ }
852
+ if (process.env.PYLON_DEV_MODE) tail += DEV_LIVE_RELOAD_SNIPPET;
853
+ return tail;
854
+ }
855
+
856
+ /**
857
+ * The layout chain for a component, walked top-down from `app/` to the
858
+ * component's own directory — IDENTICAL to the bundler's `discoverRoutes`
859
+ * accumulation (and the SDK's `discoverAppRoutes`). A boundary's hydration
860
+ * needs the SERVER tree wrapped in the SAME layouts the bundler baked into
861
+ * its client entry; the catch path otherwise has only the failing PAGE's
862
+ * layouts, which would mismatch a root boundary covering a nested page.
863
+ */
864
+ function resolveLayoutChain(componentRelPath: string, cwd: string): string[] {
865
+ const fs = require("node:fs");
866
+ const path = require("node:path");
867
+ const rel = componentRelPath.replace(/\\/g, "/");
868
+ const dir = rel.includes("/") ? rel.slice(0, rel.lastIndexOf("/")) : "";
869
+ const parts = dir.split("/").filter(Boolean);
870
+ const layouts: string[] = [];
871
+ let acc = "";
872
+ for (const part of parts) {
873
+ acc = acc ? `${acc}/${part}` : part;
874
+ for (const ext of MODULE_EXTS) {
875
+ if (fs.existsSync(path.join(cwd, acc, `layout${ext}`))) {
876
+ layouts.push(`${acc}/layout`);
877
+ break;
878
+ }
879
+ }
880
+ }
881
+ return layouts;
882
+ }
883
+
884
+ /**
885
+ * A short, non-reversible correlation id for an error — surfaced to the
886
+ * client error boundary as `error.digest` (matching server logs) WITHOUT
887
+ * carrying any stack content. FNV-1a over message+stack, 8 hex chars.
888
+ */
889
+ export function errorDigest(err: any): string {
890
+ const s = `${err?.message ?? ""}\n${err?.stack ?? ""}`;
891
+ let h = 0x811c9dc5;
892
+ for (let i = 0; i < s.length; i++) {
893
+ h ^= s.charCodeAt(i);
894
+ h = Math.imul(h, 0x01000193);
895
+ }
896
+ return (h >>> 0).toString(16).padStart(8, "0");
897
+ }
898
+
899
+ /**
900
+ * Render a boundary (not-found/error) tree, stream it at `status`, and —
901
+ * when the boundary has a client bundle entry (#279) — append the hydration
902
+ * tail so onClick/useState/`reset()` work. With no manifest entry the
903
+ * boundary still renders (server-only, styled) — CSS/hydration must never
904
+ * block the error path.
790
905
  */
791
906
  async function renderBoundaryToClient(
792
907
  React: any,
@@ -796,6 +911,14 @@ async function renderBoundaryToClient(
796
911
  callId: string,
797
912
  status: number,
798
913
  headers: Record<string, string>,
914
+ tail?: {
915
+ component: string;
916
+ layouts: string[];
917
+ props: any;
918
+ ssrData: Record<string, any>;
919
+ kind: "error" | "not-found";
920
+ errorForClient?: { message: string; digest?: string };
921
+ },
799
922
  ): Promise<void> {
800
923
  const stream: ReadableStream<Uint8Array> = await renderToReadableStream(tree, {
801
924
  onError(e: unknown) {
@@ -803,7 +926,35 @@ async function renderBoundaryToClient(
803
926
  console.error("[ssr] boundary render error:", e);
804
927
  },
805
928
  });
806
- const headBlob = await collectBoundaryHeadBlob();
929
+ // Resolve the boundary's own client entry (keyed by its component path) so
930
+ // the head gets ITS css/modulepreloads and the body-tail loads ITS script.
931
+ let manifestRoute:
932
+ | { file: string; imports: string[]; css: string[] }
933
+ | null = null;
934
+ let publicPrefix = "/_pylon/build/";
935
+ let headBlob = "";
936
+ if (tail) {
937
+ try {
938
+ const { getManifest } = await import("./ssr-client-bundler");
939
+ const manifest = await getManifest();
940
+ publicPrefix = manifest.public_prefix || publicPrefix;
941
+ manifestRoute = manifest.routes[tail.component] ?? null;
942
+ } catch {
943
+ manifestRoute = null;
944
+ }
945
+ }
946
+ if (manifestRoute) {
947
+ for (const css of manifestRoute.css) {
948
+ headBlob += `<link rel="stylesheet" href="${publicPrefix}${css}">`;
949
+ }
950
+ for (const chunk of manifestRoute.imports) {
951
+ headBlob += `<link rel="modulepreload" href="${publicPrefix}${chunk}">`;
952
+ }
953
+ } else {
954
+ // No per-boundary entry → fall back to the global stylesheet union so the
955
+ // page is at least styled (static).
956
+ headBlob = await collectBoundaryHeadBlob();
957
+ }
807
958
  // renderToReadableStream resolved without throwing → safe to commit the
808
959
  // head now, then drain the (already-rendered) shell, injecting CSS.
809
960
  send({ type: "response_start", call_id: callId, status, headers });
@@ -816,6 +967,20 @@ async function renderBoundaryToClient(
816
967
  });
817
968
  };
818
969
  await streamWithHeadInjection(stream.getReader(), headBlob, sendChunk);
970
+ if (tail && manifestRoute) {
971
+ const tailHtml = buildHydrationTail({
972
+ component: tail.component,
973
+ layouts: tail.layouts,
974
+ props: tail.props,
975
+ ssrData: tail.ssrData,
976
+ manifestRoute,
977
+ publicPrefix,
978
+ manifestErr: null,
979
+ kind: tail.kind,
980
+ errorForClient: tail.errorForClient,
981
+ });
982
+ sendChunk(tailHtml);
983
+ }
819
984
  send({ type: "render_done", call_id: callId });
820
985
  }
821
986
 
@@ -838,7 +1003,7 @@ async function tryRenderBoundary(
838
1003
  headers: Record<string, string>;
839
1004
  },
840
1005
  ): Promise<boolean> {
841
- const { React, renderToReadableStream, cwd, componentPath, fileName, layouts, props, send, callId, status, headers } =
1006
+ const { React, renderToReadableStream, cwd, componentPath, fileName, props, send, callId, status, headers } =
842
1007
  opts;
843
1008
  if (!React || !renderToReadableStream || !props) return false;
844
1009
  const rel = findBoundary(componentPath, fileName);
@@ -847,9 +1012,45 @@ async function tryRenderBoundary(
847
1012
  const mod = await importModule(cwd, rel);
848
1013
  const Comp = mod.default ?? mod.Component ?? mod.NotFound ?? mod.Error;
849
1014
  if (typeof Comp !== "function") return false;
850
- let tree = React.createElement(Comp, props);
851
- tree = await buildLayoutTree(cwd, tree, layouts, props, React);
852
- await renderBoundaryToClient(React, renderToReadableStream, tree, send, callId, status, headers);
1015
+ // The boundary hydrates through its OWN layout chain (walked from app/ to
1016
+ // the boundary's dir) NOT the failing page's chain — so the server tree
1017
+ // matches the client entry the bundler baked for this boundary (#279).
1018
+ const boundaryLayouts = resolveLayoutChain(rel, cwd);
1019
+ // For an error boundary, project the thrown Error to the SAFE client shape
1020
+ // ({message, digest}) and give BOTH server + client the SAME value (zero
1021
+ // hydration mismatch) + a no-op reset() server-side. The raw Error/stack
1022
+ // never reaches the client (the dev overlay owns dev stacks; #270).
1023
+ let errorForClient: { message: string; digest?: string } | undefined;
1024
+ let compProps = props;
1025
+ if (fileName === "error") {
1026
+ const rawErr = props.error;
1027
+ errorForClient = {
1028
+ message: rawErr?.message ?? String(rawErr ?? "Error"),
1029
+ digest: errorDigest(rawErr),
1030
+ };
1031
+ compProps = { ...props, error: errorForClient, reset: () => {} };
1032
+ }
1033
+ let tree = React.createElement(Comp, compProps);
1034
+ tree = await buildLayoutTree(cwd, tree, boundaryLayouts, compProps, React);
1035
+ await renderBoundaryToClient(
1036
+ React,
1037
+ renderToReadableStream,
1038
+ tree,
1039
+ send,
1040
+ callId,
1041
+ status,
1042
+ headers,
1043
+ {
1044
+ component: rel,
1045
+ layouts: boundaryLayouts,
1046
+ props: compProps,
1047
+ // Catch-path boundaries don't set up serverData/use() (the by-name
1048
+ // not-found dispatch through handleRenderRoute does); empty ssrData.
1049
+ ssrData: {},
1050
+ kind: fileName === "error" ? "error" : "not-found",
1051
+ errorForClient,
1052
+ },
1053
+ );
853
1054
  return true;
854
1055
  } catch (e) {
855
1056
  // Boundary render itself failed — no tertiary fallback; let the caller
@@ -1049,6 +1250,41 @@ export async function handleRenderRoute(
1049
1250
  metadata = applyAutoIcons(msg.component, metadata);
1050
1251
  const metaFragment = renderMetadata(React, metadata);
1051
1252
 
1253
+ // loading.tsx (#278): the nearest `loading` module — walked up from the
1254
+ // page dir, like not-found/error — becomes ONE route-level Suspense
1255
+ // fallback wrapping the page. When present, the shell (layouts) + this
1256
+ // skeleton flush immediately and React reveals the real page content when
1257
+ // the page's top-level `use()` resolves, instead of buffering the whole
1258
+ // document (see the `allReady` gate below). A page with no loading.tsx
1259
+ // keeps the byte-identical buffered single-flush path.
1260
+ //
1261
+ // The skeleton is SERVER-ONLY and must not read `serverData` (a read would
1262
+ // suspend the FALLBACK itself, delaying the shell). It gets the page props
1263
+ // for url/params/searchParams/auth.
1264
+ const loadingRel = findBoundary(msg.component, "loading");
1265
+ let Loading: any = null;
1266
+ if (loadingRel) {
1267
+ try {
1268
+ const lMod = await importModule(cwd, loadingRel);
1269
+ const L = lMod.default ?? lMod.Loading ?? lMod.loading;
1270
+ if (typeof L === "function") Loading = L;
1271
+ } catch {
1272
+ // A broken loading.tsx must never block the page — fall back to the
1273
+ // buffered path.
1274
+ Loading = null;
1275
+ }
1276
+ }
1277
+
1278
+ // The page leaf, optionally wrapped in the single Suspense boundary.
1279
+ let pageLeaf: any = React.createElement(Component, props);
1280
+ if (Loading) {
1281
+ pageLeaf = React.createElement(
1282
+ React.Suspense,
1283
+ { fallback: React.createElement(Loading, props) },
1284
+ pageLeaf,
1285
+ );
1286
+ }
1287
+
1052
1288
  // Resolve the layout chain. Each layout module exports a default
1053
1289
  // function that accepts the same props + `children`. Walk leaf →
1054
1290
  // root: start with the page component as `tree`, then for each
@@ -1057,13 +1293,8 @@ export async function handleRenderRoute(
1057
1293
  // the page. The metadata fragment is the FIRST child so React hoists
1058
1294
  // its <title>/<meta> into the <head> a layout renders.
1059
1295
  let tree: any = metaFragment
1060
- ? React.createElement(
1061
- React.Fragment,
1062
- null,
1063
- metaFragment,
1064
- React.createElement(Component, props),
1065
- )
1066
- : React.createElement(Component, props);
1296
+ ? React.createElement(React.Fragment, null, metaFragment, pageLeaf)
1297
+ : pageLeaf;
1067
1298
  tree = await buildLayoutTree(cwd, tree, msg.layouts, props, React);
1068
1299
  const element = tree;
1069
1300
  const stream: ReadableStream<Uint8Array> = await renderToReadableStream(
@@ -1089,7 +1320,16 @@ export async function handleRenderRoute(
1089
1320
  // whole-document hydration (which leaves the boundary stuck on its
1090
1321
  // fallback). Pages with no async data have no boundaries, so `allReady`
1091
1322
  // resolves immediately — zero cost for the common case.
1092
- if ((stream as any).allReady) {
1323
+ //
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) {
1093
1333
  await (stream as any).allReady;
1094
1334
  }
1095
1335
 
@@ -1186,44 +1426,29 @@ export async function handleRenderRoute(
1186
1426
  // and `getManifest` parses with mtime-keyed caching. Falls back
1187
1427
  // to a no-hydration warning if the manifest can't be loaded
1188
1428
  // (rare — usually means the bundler crashed).
1189
- if (!isBoundaryComponent) {
1190
- // Strip live, non-serializable handles from the props that seed
1191
- // hydration: `serverData` (an RPC handle the client rebuilds its
1192
- // own from `ssrData`) and `response` (a server-only controller — the
1193
- // client gets a no-op). Their resolved data rides along in `ssrData`.
1194
- const { serverData: _sd, response: _resp, ...serializableProps } = props;
1195
- const hydrationPayload = {
1429
+ // Pages always hydrate. A boundary dispatched BY NAME here (the host
1430
+ // rendering `app/not-found` at 404) now hydrates too (#279) when it has a
1431
+ // client entry - only stay server-only (no tail) when there's no entry to
1432
+ // load. `buildHydrationTail` does the props strip (serverData/response +
1433
+ // the security headers/cookies strip) + the </script> + U+2028/2029
1434
+ // escaping. The CSS/modulepreload links were already injected into <head>.
1435
+ const wantsHydration = !isBoundaryComponent || !!preloadManifestRoute;
1436
+ if (wantsHydration) {
1437
+ const tail = buildHydrationTail({
1196
1438
  component: msg.component,
1197
1439
  layouts: msg.layouts ?? [],
1198
- props: serializableProps,
1440
+ props,
1199
1441
  ssrData: ssrValueCache,
1200
- };
1201
- const json = JSON.stringify(hydrationPayload).replaceAll("<", "\\u003c");
1202
-
1203
- let tail = `<script id="__PYLON_DATA__" type="application/json">${json}</script>`;
1204
- if (preloadManifestRoute) {
1205
- // Per-route entry script comes last — it needs the inline
1206
- // `__PYLON_DATA__` above to have been parsed before it runs.
1207
- // CSS + modulepreload links were already injected into `<head>`
1208
- // above so they could start fetching as early as possible.
1209
- tail += `<script type="module" src="${preloadPublicPrefix}${preloadManifestRoute.file}"></script>`;
1210
- } else {
1211
- tail += `<script>console.warn(${JSON.stringify(`[pylon ssr] hydration disabled: ${preloadManifestErr}`)})</script>`;
1212
- }
1213
- // Dev-only browser live-reload. `pylon dev` (PYLON_DEV_MODE=1) re-execs
1214
- // the whole process on every file edit; each process serves a fresh
1215
- // boot id from /_pylon/dev/live. This client subscribes via
1216
- // EventSource and reloads the tab when the boot id changes — so saving
1217
- // a page, a component, or app/globals.css refreshes the browser with
1218
- // no manual F5. Stripped entirely in production builds.
1219
- if (process.env.PYLON_DEV_MODE) {
1220
- tail += DEV_LIVE_RELOAD_SNIPPET;
1221
- }
1222
- send({
1223
- type: "render_chunk",
1224
- call_id: msg.call_id,
1225
- data: Buffer.from(tail, "utf8").toString("base64"),
1442
+ manifestRoute: preloadManifestRoute,
1443
+ publicPrefix: preloadPublicPrefix,
1444
+ manifestErr: preloadManifestErr,
1445
+ kind: isBoundaryComponent
1446
+ ? /(^|\/)error$/.test(msg.component)
1447
+ ? "error"
1448
+ : "not-found"
1449
+ : undefined,
1226
1450
  });
1451
+ sendChunk(tail);
1227
1452
  }
1228
1453
 
1229
1454
  send({ type: "render_done", call_id: msg.call_id });
@@ -1299,11 +1524,17 @@ export async function handleRenderRoute(
1299
1524
  ) {
1300
1525
  return;
1301
1526
  }
1527
+ // In dev, send the full stack as the message so the host can paint a
1528
+ // useful error overlay instead of an opaque 500. In prod, send only the
1529
+ // message (the host shows a generic page; the stack stays in logs).
1530
+ const devMode =
1531
+ process.env.PYLON_DEV_MODE === "1" || process.env.PYLON_DEV_MODE === "true";
1302
1532
  send({
1303
1533
  type: "error",
1304
1534
  call_id: msg.call_id,
1305
1535
  code: err?.code ?? "SSR_RENDER_FAILED",
1306
- message: err?.message ?? String(err),
1536
+ message:
1537
+ devMode && err?.stack ? String(err.stack) : err?.message ?? String(err),
1307
1538
  });
1308
1539
  }
1309
1540
  }