@pylonsync/functions 0.4.10 → 0.4.12

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.
@@ -14,6 +14,16 @@ export type BoundaryFile = "not-found" | "error" | "loading";
14
14
  * the shared chunk instead.
15
15
  */
16
16
  export declare function nearestBoundaryComponent(component: string, fileName: BoundaryFile, routeKeys: Iterable<string>): string | null;
17
+ /**
18
+ * The URL-space directory a module sits in: its parent path with `(group)`
19
+ * segments removed, since a group contributes no URL segment.
20
+ *
21
+ * `web/app/(marketing)/pricing/page` → `web/app/pricing`, and
22
+ * `web/app/(marketing)/not-found` → `web/app`. That is what makes a group's
23
+ * boundary resolve for `/` — matching the server, which walks the real
24
+ * directories but treats group dirs as transparent the same way.
25
+ */
26
+ export declare function boundaryScope(componentPath: string): string;
17
27
  /** Runtime internals the boundary needs, injected so the module stays
18
28
  * browser-safe AND unit-testable. */
19
29
  export interface BoundaryDeps {
@@ -0,0 +1,26 @@
1
+ export interface NavPayloadCacheDeps {
2
+ /** Fetch a page, resolving to its HTML or null when it shouldn't be reused. */
3
+ fetchPage: (target: string) => Promise<string | null>;
4
+ /** Injected so tests drive expiry without sleeping. */
5
+ now: () => number;
6
+ /** Long enough to cover hover-to-click, short enough that nobody reads a
7
+ * page rendered from data this old. */
8
+ ttlMs?: number;
9
+ /** Cap on retained payloads; the oldest is evicted first. Pages are whole
10
+ * HTML documents, so this bounds memory on a link-dense page. */
11
+ max?: number;
12
+ }
13
+ export interface NavPayloadCache {
14
+ /** Warm `target`, unless a fresh entry is already present or in flight. */
15
+ prefetch(target: string): void;
16
+ /** Hand over `target`'s payload, or null. Single-use: a prefetch
17
+ * accelerates the NEXT click, and holding it past that would serve
18
+ * navigations from an increasingly stale render. */
19
+ take(target: string): Promise<string | null> | null;
20
+ /** Drop everything — called when a navigation commits, since entries were
21
+ * rendered against the page the user just left. */
22
+ clear(): void;
23
+ /** Retained entry count (tests + diagnostics). */
24
+ size(): number;
25
+ }
26
+ export declare function createNavPayloadCache(deps: NavPayloadCacheDeps): NavPayloadCache;
@@ -26,18 +26,27 @@ export declare function matchRoute(manifest: MatchableManifest | null | undefine
26
26
  export interface PrefetchTargets {
27
27
  /** The destination route's own entry chunk, or "" when no page route matches. */
28
28
  file: string;
29
- /** Shared chunks to warm alongside it. */
29
+ /** Chunks to warm alongside it. */
30
30
  imports: string[];
31
31
  }
32
32
  /**
33
33
  * The chunks a click on `pathname` will need before anything can render: the
34
- * destination route's entry, plus the shared chunks (React, the client
35
- * runtime, common layouts) that every route pulls in.
34
+ * destination route's entry, plus the chunks (React, the client runtime,
35
+ * common layouts) it pulls in.
36
36
  *
37
37
  * Warming the page payload alone leaves the entry chunk to be fetched after
38
38
  * the click, and the route cannot render until it lands — so the prefetch
39
- * covers only the half that was already fast. An href matching no page route
40
- * (an API path, a route this build doesn't serve) still yields the shared set;
41
- * `file` is "" and the caller skips it.
39
+ * covers only the half that was already fast.
40
+ *
41
+ * Size is deliberately NOT a factor. Build output is content-hashed and served
42
+ * immutable, so warming a heavy route costs its bytes once per browser and
43
+ * makes every later visit to it instant; skipping it would trade a permanent
44
+ * win for a one-time saving, and would skip exactly the routes slowest to
45
+ * fetch on demand. The warm is deferred to the load event, so those bytes
46
+ * never compete with the current page's own render.
47
+ *
48
+ * An href matching no page route (an API path, a route this build doesn't
49
+ * serve) yields the union of every route's chunks: no destination is known,
50
+ * but those are needed by any navigation.
42
51
  */
43
52
  export declare function prefetchTargets(manifest: MatchableManifest | null | undefined, pathname: string): PrefetchTargets;
@@ -303,6 +303,12 @@ export interface SsrMetadata {
303
303
  export declare function renderMetadata(React: any, m: SsrMetadata | undefined): any;
304
304
  /** Import a project-relative module, trying each common extension. */
305
305
  export declare function importModule(cwd: string, relPath: string): Promise<any>;
306
+ /**
307
+ * `findBoundary` with its filesystem and root injected, so the walk is
308
+ * testable against a fixture without `chdir` — which races every other test
309
+ * in the process.
310
+ */
311
+ export declare function findBoundaryIn(fs: any, path: any, cwd: string, componentPath: string, fileName: string): string | null;
306
312
  /** Pure origin resolution (exported for tests).
307
313
  *
308
314
  * SECURITY: the request `Host` (and `X-Forwarded-Proto`) is attacker-
@@ -409,7 +415,30 @@ export declare function buildHydrationTail(args: {
409
415
  bucketAuth?: {
410
416
  signedIn: boolean;
411
417
  };
418
+ /** Client-side NAVIGATION payload: emit the `__PYLON_DATA__` blob and
419
+ * nothing else. The client already has the runtime and resolves the route
420
+ * entry from the build manifest, so the entry `<script>`, the preload tags
421
+ * and the dev-reload snippet are all dead weight on a navigation. Shares
422
+ * this function — and therefore its props strip — rather than rebuilding
423
+ * the payload somewhere the security-relevant parts could drift. */
424
+ dataOnly?: boolean;
412
425
  }): string;
426
+ /**
427
+ * Is this render answering a client-side navigation rather than a document
428
+ * load? The client runtime sets `X-Pylon-Nav: 1`; the Rust host reads the same
429
+ * header to key the ISR cache, so the two always agree on which shape a URL
430
+ * is being asked for.
431
+ */
432
+ export declare function isNavRequest(headers: Record<string, unknown> | undefined): boolean;
433
+ /**
434
+ * Render a detached element (the metadata fragment) to an HTML string.
435
+ *
436
+ * A navigation response carries the page's `<title>`/`<meta>`/`<link>` so the
437
+ * client can swap them, but not the page body — which the client re-renders
438
+ * from `__PYLON_DATA__` anyway. Rendering the fragment on its own is what
439
+ * lets the body be dropped without losing the head.
440
+ */
441
+ export declare function renderElementToString(renderToReadableStream: any, element: any): Promise<string>;
413
442
  /**
414
443
  * A short, non-reversible correlation id for an error — surfaced to the
415
444
  * client error boundary as `error.digest` (matching server logs) WITHOUT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pylonsync/functions",
3
- "version": "0.4.10",
3
+ "version": "0.4.12",
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",
@@ -0,0 +1,183 @@
1
+ // The server's boundary walk must agree with the client's on route groups.
2
+ //
3
+ // Regression: `app/(marketing)/not-found.tsx` served unmatched URLs — the host
4
+ // matches those on the manifest's URL path, where a group adds no segment —
5
+ // but NOT a notFound() thrown by a page, which walked real directories and so
6
+ // never crossed out of the page's own subtree. One class of 404 got the app's
7
+ // boundary and the other got the built-in, while ROUTE_PATH_DUPLICATE blocked
8
+ // keeping a root copy alongside the group's.
9
+ //
10
+ // These drive the SERVER walk (findBoundaryIn) over a real fixture tree and
11
+ // assert the same answers the client's nearestBoundaryComponent gives for the
12
+ // equivalent key set — the two diverging is what caused the bug.
13
+
14
+ import { afterEach, describe, expect, test } from "bun:test";
15
+ import * as fs from "node:fs";
16
+ import * as path from "node:path";
17
+ import * as os from "node:os";
18
+
19
+ import { findBoundaryIn } from "./ssr-runtime";
20
+ import { nearestBoundaryComponent } from "./ssr-client-boundary";
21
+
22
+ let tmp: string | null = null;
23
+
24
+ afterEach(() => {
25
+ if (tmp) {
26
+ try {
27
+ fs.rmSync(tmp, { recursive: true, force: true });
28
+ } catch {
29
+ /* best effort */
30
+ }
31
+ tmp = null;
32
+ }
33
+ });
34
+
35
+ /** Create a tree of empty module files and return its root. */
36
+ function tree(files: string[]): string {
37
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pylon-boundary-"));
38
+ for (const f of files) {
39
+ const full = path.join(dir, f);
40
+ fs.mkdirSync(path.dirname(full), { recursive: true });
41
+ fs.writeFileSync(full, "export default function X(){return null}\n", "utf8");
42
+ }
43
+ tmp = dir;
44
+ return dir;
45
+ }
46
+
47
+ /** Module paths (no extension) for the client-side equivalent. */
48
+ const keysOf = (files: string[]) => files.map((f) => f.replace(/\.tsx$/, ""));
49
+
50
+ describe("server boundary walk treats route groups as transparent", () => {
51
+ test("a group's not-found covers a page outside the group", () => {
52
+ const files = ["app/(marketing)/not-found.tsx", "app/gone/page.tsx"];
53
+ const root = tree(files);
54
+ expect(findBoundaryIn(fs, path, root, "app/gone/page", "not-found")).toBe(
55
+ "app/(marketing)/not-found",
56
+ );
57
+ // ...and the client agrees, which is the property that broke.
58
+ expect(
59
+ nearestBoundaryComponent("app/gone/page", "not-found", keysOf(files)),
60
+ ).toBe("app/(marketing)/not-found");
61
+ });
62
+
63
+ test("nested groups resolve too", () => {
64
+ const files = ["app/(a)/(b)/not-found.tsx", "app/gone/page.tsx"];
65
+ const root = tree(files);
66
+ expect(findBoundaryIn(fs, path, root, "app/gone/page", "not-found")).toBe(
67
+ "app/(a)/(b)/not-found",
68
+ );
69
+ expect(
70
+ nearestBoundaryComponent("app/gone/page", "not-found", keysOf(files)),
71
+ ).toBe("app/(a)/(b)/not-found");
72
+ });
73
+
74
+ test("a real directory's own boundary wins over a group beside it", () => {
75
+ const files = [
76
+ "app/not-found.tsx",
77
+ "app/(marketing)/not-found.tsx",
78
+ "app/gone/page.tsx",
79
+ ];
80
+ const root = tree(files);
81
+ // (An app can't actually ship both — both claim "/" and the duplicate
82
+ // check rejects it — but the walk must still be deterministic.)
83
+ expect(findBoundaryIn(fs, path, root, "app/gone/page", "not-found")).toBe(
84
+ "app/not-found",
85
+ );
86
+ });
87
+
88
+ test("a nearer boundary still beats a group one higher up", () => {
89
+ const files = [
90
+ "app/(marketing)/not-found.tsx",
91
+ "app/dashboard/not-found.tsx",
92
+ "app/dashboard/settings/page.tsx",
93
+ ];
94
+ const root = tree(files);
95
+ expect(
96
+ findBoundaryIn(fs, path, root, "app/dashboard/settings/page", "not-found"),
97
+ ).toBe("app/dashboard/not-found");
98
+ expect(
99
+ nearestBoundaryComponent(
100
+ "app/dashboard/settings/page",
101
+ "not-found",
102
+ keysOf(files),
103
+ ),
104
+ ).toBe("app/dashboard/not-found");
105
+ });
106
+
107
+ test("a group boundary in a deeper URL scope does not leak upward", () => {
108
+ // app/(marketing)/pricing/not-found is at "/pricing"; a page at
109
+ // "/dashboard" must not get it.
110
+ const files = [
111
+ "app/(marketing)/pricing/not-found.tsx",
112
+ "app/dashboard/page.tsx",
113
+ ];
114
+ const root = tree(files);
115
+ expect(
116
+ findBoundaryIn(fs, path, root, "app/dashboard/page", "not-found"),
117
+ ).toBeNull();
118
+ expect(
119
+ nearestBoundaryComponent("app/dashboard/page", "not-found", keysOf(files)),
120
+ ).toBeNull();
121
+ });
122
+
123
+ test("a boundary inside a group still covers that group's own pages", () => {
124
+ const files = [
125
+ "app/(marketing)/not-found.tsx",
126
+ "app/(marketing)/pricing/page.tsx",
127
+ ];
128
+ const root = tree(files);
129
+ expect(
130
+ findBoundaryIn(fs, path, root, "app/(marketing)/pricing/page", "not-found"),
131
+ ).toBe("app/(marketing)/not-found");
132
+ });
133
+
134
+ test("error.tsx and loading.tsx follow the same rule", () => {
135
+ const files = [
136
+ "app/(shell)/error.tsx",
137
+ "app/(shell)/loading.tsx",
138
+ "app/reports/page.tsx",
139
+ ];
140
+ const root = tree(files);
141
+ expect(findBoundaryIn(fs, path, root, "app/reports/page", "error")).toBe(
142
+ "app/(shell)/error",
143
+ );
144
+ expect(findBoundaryIn(fs, path, root, "app/reports/page", "loading")).toBe(
145
+ "app/(shell)/loading",
146
+ );
147
+ });
148
+
149
+ test("an app with no boundary anywhere still resolves to null", () => {
150
+ const root = tree(["app/page.tsx"]);
151
+ expect(findBoundaryIn(fs, path, root, "app/page", "not-found")).toBeNull();
152
+ });
153
+ });
154
+
155
+ // ---------------------------------------------------------------------------
156
+ // Boundary metadata.
157
+ //
158
+ // Regression: `export const metadata` on not-found.tsx / error.tsx was ignored
159
+ // on the catch path (a page calling notFound(), or throwing). The rendered
160
+ // head had no <title> at all — and because the framework's built-in 404 body
161
+ // supplies one, taking over the boundary LOST the title rather than replacing
162
+ // it, leaving the tab showing the raw URL.
163
+ // ---------------------------------------------------------------------------
164
+
165
+ import { renderMetadata } from "./ssr-runtime";
166
+
167
+ describe("renderMetadata drives boundary heads too", () => {
168
+ test("emits a title element a boundary can hoist", () => {
169
+ const React = require("react");
170
+ const frag = renderMetadata(React, { title: "Missing", description: "d" });
171
+ expect(frag).toBeTruthy();
172
+ const kids = React.Children.toArray(frag.props.children);
173
+ const types = kids.map((k: any) => k.type);
174
+ expect(types).toContain("title");
175
+ expect(types).toContain("meta");
176
+ });
177
+
178
+ test("no metadata yields nothing to wrap", () => {
179
+ // The boundary tree must be left alone rather than wrapped in an empty
180
+ // fragment, so hydration matches what the bundler baked for it.
181
+ expect(renderMetadata(require("react"), undefined)).toBeNull();
182
+ });
183
+ });
@@ -42,16 +42,38 @@ export function nearestBoundaryComponent(
42
42
  fileName: BoundaryFile,
43
43
  routeKeys: Iterable<string>,
44
44
  ): string | null {
45
- const keys = routeKeys instanceof Set ? routeKeys : new Set(routeKeys);
46
- let dir = String(component);
47
- dir = dir.includes("/") ? dir.slice(0, dir.lastIndexOf("/")) : "";
48
- while (dir) {
49
- const key = `${dir}/${fileName}`;
50
- if (keys.has(key)) return key;
51
- const slash = dir.lastIndexOf("/");
52
- dir = slash >= 0 ? dir.slice(0, slash) : "";
45
+ const target = boundaryScope(component);
46
+ let best: { key: string; depth: number } | null = null;
47
+ for (const key of routeKeys) {
48
+ if (!key.endsWith(`/${fileName}`)) continue;
49
+ const scope = boundaryScope(key);
50
+ // Same URL scope, or an ancestor of it.
51
+ if (scope !== target && !target.startsWith(`${scope}/`)) continue;
52
+ const depth = scope === "" ? 0 : scope.split("/").length;
53
+ // Nearest ancestor wins; ties (a directory's own file vs one in a group
54
+ // beside it) resolve by key order so the answer is deterministic.
55
+ if (!best || depth > best.depth || (depth === best.depth && key < best.key)) {
56
+ best = { key, depth };
57
+ }
53
58
  }
54
- return null;
59
+ return best ? best.key : null;
60
+ }
61
+
62
+ /**
63
+ * The URL-space directory a module sits in: its parent path with `(group)`
64
+ * segments removed, since a group contributes no URL segment.
65
+ *
66
+ * `web/app/(marketing)/pricing/page` → `web/app/pricing`, and
67
+ * `web/app/(marketing)/not-found` → `web/app`. That is what makes a group's
68
+ * boundary resolve for `/` — matching the server, which walks the real
69
+ * directories but treats group dirs as transparent the same way.
70
+ */
71
+ export function boundaryScope(componentPath: string): string {
72
+ const parts = String(componentPath).replace(/\\/g, "/").split("/");
73
+ parts.pop(); // the module's own basename
74
+ return parts
75
+ .filter((p) => !(p.startsWith("(") && p.endsWith(")")))
76
+ .join("/");
55
77
  }
56
78
 
57
79
  /** Runtime internals the boundary needs, injected so the module stays
@@ -29,7 +29,10 @@ import {
29
29
  generateLoadingRegistry,
30
30
  type PylonBundleManifest,
31
31
  } from "./ssr-client-bundler";
32
- import { nearestBoundaryComponent } from "./ssr-client-boundary";
32
+ import {
33
+ boundaryScope,
34
+ nearestBoundaryComponent,
35
+ } from "./ssr-client-boundary";
33
36
 
34
37
  // State that needs cleanup between tests.
35
38
  let originalCwd: string | null = null;
@@ -650,3 +653,81 @@ describe("loading.tsx is wired into the client build", () => {
650
653
  ).toBe(true);
651
654
  });
652
655
  });
656
+
657
+ // ---------------------------------------------------------------------------
658
+ // Route groups and boundary resolution.
659
+ //
660
+ // Regression: a `not-found.tsx` inside a route group served unmatched URLs
661
+ // (the host matches those on the manifest's URL path, where a group adds no
662
+ // segment, so it sits at "/") but NOT a notFound() thrown by a page (which
663
+ // walked real directories and never crossed out of the page's own subtree).
664
+ // So a group boundary silently covered one class of 404 and not the other,
665
+ // while ROUTE_PATH_DUPLICATE blocked keeping a root copy alongside it.
666
+ // ---------------------------------------------------------------------------
667
+
668
+ describe("boundaryScope", () => {
669
+ test("drops group segments, keeps real ones", () => {
670
+ expect(boundaryScope("web/app/(marketing)/pricing/page")).toBe("web/app/pricing");
671
+ expect(boundaryScope("web/app/(marketing)/not-found")).toBe("web/app");
672
+ expect(boundaryScope("app/not-found")).toBe("app");
673
+ });
674
+
675
+ test("handles nested groups", () => {
676
+ expect(boundaryScope("app/(a)/(b)/not-found")).toBe("app");
677
+ });
678
+ });
679
+
680
+ describe("nearestBoundaryComponent across route groups", () => {
681
+ test("a group's boundary covers a page outside the group", () => {
682
+ // Both are at URL "/", so the group copy IS the root boundary.
683
+ expect(
684
+ nearestBoundaryComponent(
685
+ "app/gone/page",
686
+ "not-found",
687
+ new Set(["app/(marketing)/not-found", "app/gone/page"]),
688
+ ),
689
+ ).toBe("app/(marketing)/not-found");
690
+ });
691
+
692
+ test("a nested group's boundary resolves the same way", () => {
693
+ expect(
694
+ nearestBoundaryComponent(
695
+ "app/gone/page",
696
+ "not-found",
697
+ new Set(["app/(a)/(b)/not-found"]),
698
+ ),
699
+ ).toBe("app/(a)/(b)/not-found");
700
+ });
701
+
702
+ test("a nearer real boundary still beats a group one higher up", () => {
703
+ expect(
704
+ nearestBoundaryComponent(
705
+ "app/dashboard/settings/page",
706
+ "not-found",
707
+ new Set(["app/(marketing)/not-found", "app/dashboard/not-found"]),
708
+ ),
709
+ ).toBe("app/dashboard/not-found");
710
+ });
711
+
712
+ test("a group boundary does not leak into a deeper URL scope", () => {
713
+ // app/(marketing)/pricing/not-found is at "/pricing" — it must not answer
714
+ // for a page at "/dashboard".
715
+ expect(
716
+ nearestBoundaryComponent(
717
+ "app/dashboard/page",
718
+ "not-found",
719
+ new Set(["app/(marketing)/pricing/not-found"]),
720
+ ),
721
+ ).toBeNull();
722
+ });
723
+
724
+ test("a boundary inside a group still covers that group's own pages", () => {
725
+ expect(
726
+ nearestBoundaryComponent(
727
+ "app/(marketing)/pricing/page",
728
+ "not-found",
729
+ new Set(["app/(marketing)/not-found"]),
730
+ ),
731
+ ).toBe("app/(marketing)/not-found");
732
+ });
733
+ });
@@ -332,6 +332,7 @@ import { createElement } from "react";
332
332
  import { hydrateRoot } from "react-dom/client";
333
333
  import { createPylonBoundary, nearestBoundaryComponent } from "./client-boundary";
334
334
  import { LOADING_MODULES } from "./loading-registry";
335
+ import { createNavPayloadCache } from "./nav-cache";
335
336
  import { matchRoute, prefetchTargets } from "./route-match";
336
337
 
337
338
  const routeCache = Object.create(null);
@@ -597,16 +598,38 @@ function whenLoaded(fn) {
597
598
  window.addEventListener("load", fn, { once: true });
598
599
  }
599
600
 
600
- async function prefetch(href) {
601
- // HTML prefetch — primes the SSR response cache.
601
+ // Marks a request as a client-side NAVIGATION rather than a document load.
602
+ // The server answers with the head metadata + __PYLON_DATA__ and skips the
603
+ // page markup, which this runtime re-renders from that data anyway. The host
604
+ // keys its render cache on the same header, so the two shapes never share an
605
+ // entry. Prefetch and navigate MUST send identical headers, or the prefetched
606
+ // response is the wrong shape for the click that consumes it.
607
+ const NAV_REQUEST_HEADERS = { Accept: "text/html", "X-Pylon-Nav": "1" };
608
+
609
+ // Payloads warmed by a hover, consumed by the click that follows. See
610
+ // ./nav-cache for why this is in memory rather than an HTTP-level prefetch.
611
+ const navPayloads = createNavPayloadCache({
612
+ now: () => Date.now(),
613
+ fetchPage: (target) =>
614
+ fetch(target, {
615
+ credentials: "same-origin",
616
+ headers: NAV_REQUEST_HEADERS,
617
+ }).then((res) => {
618
+ // A redirect means the URL navigate() would commit isn't the one that
619
+ // answered; let the real navigation resolve that itself.
620
+ if (!res.ok || res.redirected) return null;
621
+ return res.text();
622
+ }),
623
+ });
624
+
625
+ async function prefetch(href, opts) {
602
626
  const url = new URL(href, location.href);
603
627
  if (url.origin !== location.origin) return;
604
- if (!document.querySelector('link[rel="prefetch"][href="' + url.pathname + '"]')) {
605
- const html = document.createElement("link");
606
- html.rel = "prefetch";
607
- html.as = "document";
608
- html.href = url.pathname + url.search;
609
- document.head.appendChild(html);
628
+ // The page payload, but ONLY on a real intent signal (hover / touch). It
629
+ // costs a full SSR render, so warming a screenful of links on sight would
630
+ // spend a dozen renders per page load to save one.
631
+ if (opts && opts.document) {
632
+ navPayloads.prefetch(url.pathname + url.search);
610
633
  }
611
634
  const manifest = await loadManifest();
612
635
  if (!manifest) return;
@@ -643,6 +666,11 @@ async function loadRouteEntry(component) {
643
666
  export function hydrate(component, Page, Layouts) {
644
667
  // Always cache the route for nav.
645
668
  routeCache[component] = { Page, Layouts };
669
+ // Start the manifest fetch NOW rather than on first use. Nothing can resolve
670
+ // a route without it — the first <Link> reaching the viewport warms its
671
+ // chunks only after it lands — so leaving it lazy put a round trip in front
672
+ // of all prefetching. In flight from here, it resolves during hydration.
673
+ void loadManifest();
646
674
  const data = readPylonData();
647
675
  // First hydrate: the entry's component MATCHES the SSR'd page.
648
676
  // Establish the root + install the click + popstate handlers
@@ -851,15 +879,21 @@ async function navigate(href, opts) {
851
879
  // ---- Real fetch + render -----------------------------------------------
852
880
  let html;
853
881
  try {
854
- const res = await fetch(target, {
855
- credentials: "same-origin",
856
- headers: { Accept: "text/html" },
857
- });
858
- if (!res.ok) {
859
- fullLoad();
860
- return;
882
+ // A hover-prefetch usually has this in hand, or in flight — either way the
883
+ // click joins it instead of opening a second request for the same page.
884
+ const prefetched = navPayloads.take(target);
885
+ html = prefetched ? await prefetched : null;
886
+ if (html == null) {
887
+ const res = await fetch(target, {
888
+ credentials: "same-origin",
889
+ headers: NAV_REQUEST_HEADERS,
890
+ });
891
+ if (!res.ok) {
892
+ fullLoad();
893
+ return;
894
+ }
895
+ html = await res.text();
861
896
  }
862
- html = await res.text();
863
897
  } catch {
864
898
  fullLoad();
865
899
  return;
@@ -912,6 +946,10 @@ async function navigate(href, opts) {
912
946
  // next macrotask once the commit has settled with no error.
913
947
  pendingNav = target;
914
948
  currentComponent = data.component;
949
+ // Drop every other prefetched payload once a navigation commits: they were
950
+ // rendered against the previous page's state, and whatever the user does
951
+ // here can invalidate them. Hovering on the new page re-warms in one request.
952
+ navPayloads.clear();
915
953
  activeRoot.render(tree);
916
954
  setTimeout(() => {
917
955
  if (pendingNav === target) pendingNav = null;
@@ -1407,6 +1445,15 @@ async function _doBuildInner(
1407
1445
  "utf8",
1408
1446
  );
1409
1447
 
1448
+ // Same pattern for the prefetch payload cache — the runtime imports it as
1449
+ // `./nav-cache`; its source of truth is ssr-nav-cache.ts, unit-tested
1450
+ // directly (expiry, eviction, single-use).
1451
+ fs.writeFileSync(
1452
+ path.join(stageDir, "nav-cache.ts"),
1453
+ fs.readFileSync(path.join(here, "ssr-nav-cache.ts"), "utf8"),
1454
+ "utf8",
1455
+ );
1456
+
1410
1457
  // The app's loading.tsx modules, gathered into one module the runtime
1411
1458
  // imports statically (`./loading-registry`) so a pending navigation can
1412
1459
  // paint a skeleton without fetching anything. Always written — an app with
@@ -0,0 +1,146 @@
1
+ // Regression tests for the client-navigation payload cache.
2
+ //
3
+ // The bug this exists to fix: `<Link>` prefetched pages with a
4
+ // `<link rel="prefetch">` that could never be reused. Measured on a real app,
5
+ // the same URL was fetched twice — 16742 bytes both times, full transferSize
6
+ // on the second — because SSR pages are sent "private, no-store" and an
7
+ // as="document" prefetch only feeds real navigations, not fetch(). So each
8
+ // prefetch was a full server render, thrown away, and the click still paid.
9
+
10
+ import { describe, expect, it } from "bun:test";
11
+ import { createNavPayloadCache } from "./ssr-nav-cache";
12
+
13
+ /** A cache with a controllable clock and a fetch that records its calls. */
14
+ function harness(opts: { ttlMs?: number; max?: number } = {}) {
15
+ let clock = 1000;
16
+ const calls: string[] = [];
17
+ let resolveNext: ((v: string | null) => void) | null = null;
18
+ const cache = createNavPayloadCache({
19
+ now: () => clock,
20
+ ttlMs: opts.ttlMs,
21
+ max: opts.max,
22
+ fetchPage: (target) => {
23
+ calls.push(target);
24
+ return new Promise<string | null>((res) => {
25
+ resolveNext = res;
26
+ // Default: resolve immediately on the microtask queue.
27
+ queueMicrotask(() => res(`<html>${target}</html>`));
28
+ });
29
+ },
30
+ });
31
+ return {
32
+ cache,
33
+ calls,
34
+ advance: (ms: number) => {
35
+ clock += ms;
36
+ },
37
+ settleWith: (v: string | null) => resolveNext?.(v),
38
+ };
39
+ }
40
+
41
+ describe("nav payload cache", () => {
42
+ it("hands the prefetched payload to the click", async () => {
43
+ const h = harness();
44
+ h.cache.prefetch("/a");
45
+ const taken = h.cache.take("/a");
46
+ expect(taken).not.toBeNull();
47
+ expect(await taken!).toBe("<html>/a</html>");
48
+ // The whole point: the click opened no request of its own.
49
+ expect(h.calls).toEqual(["/a"]);
50
+ });
51
+
52
+ it("does not re-fetch a target already in flight", () => {
53
+ const h = harness();
54
+ h.cache.prefetch("/a");
55
+ h.cache.prefetch("/a");
56
+ h.cache.prefetch("/a");
57
+ expect(h.calls).toEqual(["/a"]);
58
+ });
59
+
60
+ it("lets a click join a prefetch that hasn't landed yet", async () => {
61
+ // Hover-to-click is usually shorter than the render, so this is the
62
+ // common path, not an edge case.
63
+ const h = harness();
64
+ h.cache.prefetch("/slow");
65
+ const taken = h.cache.take("/slow");
66
+ h.settleWith("<html>late</html>");
67
+ expect(await taken!).toBe("<html>late</html>");
68
+ expect(h.calls).toEqual(["/slow"]);
69
+ });
70
+
71
+ it("is single-use, so a second navigation re-renders", () => {
72
+ const h = harness();
73
+ h.cache.prefetch("/a");
74
+ expect(h.cache.take("/a")).not.toBeNull();
75
+ expect(h.cache.take("/a")).toBeNull();
76
+ });
77
+
78
+ it("refuses a payload past its TTL", () => {
79
+ const h = harness({ ttlMs: 15000 });
80
+ h.cache.prefetch("/a");
81
+ h.advance(15000);
82
+ expect(h.cache.take("/a")).toBeNull();
83
+ });
84
+
85
+ it("still serves a payload just inside its TTL", async () => {
86
+ const h = harness({ ttlMs: 15000 });
87
+ h.cache.prefetch("/a");
88
+ h.advance(14999);
89
+ expect(await h.cache.take("/a")!).toBe("<html>/a</html>");
90
+ });
91
+
92
+ it("re-warms an expired target instead of holding the stale one", async () => {
93
+ const h = harness({ ttlMs: 100 });
94
+ h.cache.prefetch("/a");
95
+ h.advance(200);
96
+ h.cache.prefetch("/a");
97
+ expect(h.calls).toEqual(["/a", "/a"]);
98
+ expect(await h.cache.take("/a")!).toBe("<html>/a</html>");
99
+ });
100
+
101
+ it("evicts the oldest entry at the cap", () => {
102
+ const h = harness({ max: 2 });
103
+ h.cache.prefetch("/a");
104
+ h.cache.prefetch("/b");
105
+ h.cache.prefetch("/c");
106
+ expect(h.cache.size()).toBe(2);
107
+ expect(h.cache.take("/a")).toBeNull();
108
+ expect(h.cache.take("/c")).not.toBeNull();
109
+ });
110
+
111
+ it("drops everything when a navigation commits", () => {
112
+ // Entries were rendered against the page the user just left.
113
+ const h = harness();
114
+ h.cache.prefetch("/a");
115
+ h.cache.prefetch("/b");
116
+ h.cache.clear();
117
+ expect(h.cache.size()).toBe(0);
118
+ expect(h.cache.take("/a")).toBeNull();
119
+ });
120
+
121
+ it("yields null when the fetch fails, so the click refetches", async () => {
122
+ const calls: string[] = [];
123
+ const cache = createNavPayloadCache({
124
+ now: () => 0,
125
+ fetchPage: (t) => {
126
+ calls.push(t);
127
+ return Promise.reject(new Error("offline"));
128
+ },
129
+ });
130
+ cache.prefetch("/a");
131
+ // A rejection must not escape as an unhandled rejection when nobody clicks.
132
+ expect(await cache.take("/a")!).toBeNull();
133
+ expect(calls).toEqual(["/a"]);
134
+ });
135
+
136
+ it("caches nothing for a response the fetch rejected as unusable", async () => {
137
+ // fetchPage returns null for a non-200 or a redirect — the URL that
138
+ // answered isn't the one navigate() would commit.
139
+ const cache = createNavPayloadCache({
140
+ now: () => 0,
141
+ fetchPage: () => Promise.resolve(null),
142
+ });
143
+ cache.prefetch("/a");
144
+ expect(await cache.take("/a")!).toBeNull();
145
+ });
146
+ });
@@ -0,0 +1,97 @@
1
+ // Prefetched page payloads for client-side navigation.
2
+ //
3
+ // `<Link>` warms a destination on hover; the click then consumes what the
4
+ // hover fetched instead of opening its own request. Before this existed the
5
+ // prefetch was a `<link rel="prefetch">` that could never be reused — SSR
6
+ // pages are sent "private, no-store", so nothing was storable, and an
7
+ // `as="document"` prefetch only feeds real navigations anyway, not `fetch()`.
8
+ // Every prefetch was therefore a full server render, executed and discarded,
9
+ // and the click still paid full price.
10
+ //
11
+ // So the cache lives HERE, in memory, for the length of the tab: honoring
12
+ // no-store means never handing the response to a disk cache, and it means a
13
+ // reload always re-renders.
14
+ //
15
+ // Kept in its own module (not inlined in CLIENT_RUNTIME_SOURCE) so its
16
+ // expiry, eviction and single-use rules are unit-testable — the same reason
17
+ // client-boundary and route-match were pulled out. The bundler stages a copy
18
+ // next to the generated runtime, which imports it as "./nav-cache".
19
+
20
+ export interface NavPayloadCacheDeps {
21
+ /** Fetch a page, resolving to its HTML or null when it shouldn't be reused. */
22
+ fetchPage: (target: string) => Promise<string | null>;
23
+ /** Injected so tests drive expiry without sleeping. */
24
+ now: () => number;
25
+ /** Long enough to cover hover-to-click, short enough that nobody reads a
26
+ * page rendered from data this old. */
27
+ ttlMs?: number;
28
+ /** Cap on retained payloads; the oldest is evicted first. Pages are whole
29
+ * HTML documents, so this bounds memory on a link-dense page. */
30
+ max?: number;
31
+ }
32
+
33
+ export interface NavPayloadCache {
34
+ /** Warm `target`, unless a fresh entry is already present or in flight. */
35
+ prefetch(target: string): void;
36
+ /** Hand over `target`'s payload, or null. Single-use: a prefetch
37
+ * accelerates the NEXT click, and holding it past that would serve
38
+ * navigations from an increasingly stale render. */
39
+ take(target: string): Promise<string | null> | null;
40
+ /** Drop everything — called when a navigation commits, since entries were
41
+ * rendered against the page the user just left. */
42
+ clear(): void;
43
+ /** Retained entry count (tests + diagnostics). */
44
+ size(): number;
45
+ }
46
+
47
+ export function createNavPayloadCache(
48
+ deps: NavPayloadCacheDeps,
49
+ ): NavPayloadCache {
50
+ const { fetchPage, now } = deps;
51
+ const ttlMs = deps.ttlMs ?? 15000;
52
+ const max = deps.max ?? 8;
53
+ // Holds the in-flight PROMISE, not just the resolved text, so a click that
54
+ // lands mid-prefetch joins that request rather than starting a second one —
55
+ // the common case, since hover-to-click is usually shorter than the render.
56
+ const entries = new Map<string, { at: number; promise: Promise<string | null> }>();
57
+
58
+ return {
59
+ prefetch(target: string): void {
60
+ const hit = entries.get(target);
61
+ if (hit && now() - hit.at < ttlMs) return;
62
+ // Re-warming an expired target: drop it first so the refreshed entry
63
+ // re-enters at the END of the insertion order. Map.set on an existing
64
+ // key keeps its original position, which would make a repeatedly
65
+ // re-warmed target the first one evicted. No test covers this: a
66
+ // re-warm only happens after expiry, so everything ahead of it in the
67
+ // order has expired too and evicting it costs nothing today. Keeping
68
+ // the LRU order honest anyway, so this stays true if the TTL does not.
69
+ entries.delete(target);
70
+ while (entries.size >= max) {
71
+ const oldest = entries.keys().next();
72
+ if (oldest.done) break;
73
+ entries.delete(oldest.value);
74
+ }
75
+ // A rejected fetch must not surface as an unhandled rejection when
76
+ // nobody ends up clicking; resolve to null and let the click refetch.
77
+ const promise = fetchPage(target).catch(() => null);
78
+ entries.set(target, { at: now(), promise });
79
+ },
80
+
81
+ take(target: string): Promise<string | null> | null {
82
+ const hit = entries.get(target);
83
+ if (!hit) return null;
84
+ entries.delete(target);
85
+ if (now() - hit.at >= ttlMs) return null;
86
+ return hit.promise;
87
+ },
88
+
89
+ clear(): void {
90
+ entries.clear();
91
+ },
92
+
93
+ size(): number {
94
+ return entries.size;
95
+ },
96
+ };
97
+ }
@@ -0,0 +1,95 @@
1
+ // Regression tests for the client-navigation response shape.
2
+ //
3
+ // A client-side navigation re-renders the page from __PYLON_DATA__, so the
4
+ // markup the server built for it is parsed and thrown away. Measured on a real
5
+ // route, that was 16442 bytes of HTML to deliver a 374-byte payload. The nav
6
+ // response carries the head metadata and the data, and nothing else.
7
+
8
+ import { describe, expect, it } from "bun:test";
9
+ import { buildHydrationTail, isNavRequest } from "./ssr-runtime";
10
+
11
+ describe("isNavRequest", () => {
12
+ it("recognizes the header the client sends", () => {
13
+ expect(isNavRequest({ "x-pylon-nav": "1" })).toBe(true);
14
+ });
15
+
16
+ it("tolerates surrounding whitespace", () => {
17
+ expect(isNavRequest({ "x-pylon-nav": " 1 " })).toBe(true);
18
+ });
19
+
20
+ it("treats a document load as a document load", () => {
21
+ expect(isNavRequest({})).toBe(false);
22
+ expect(isNavRequest(undefined)).toBe(false);
23
+ expect(isNavRequest({ accept: "text/html" })).toBe(false);
24
+ });
25
+
26
+ it("refuses any value other than 1", () => {
27
+ // Fail closed: an unrecognized value serves the full page, which is
28
+ // always correct, rather than a payload the caller can't use.
29
+ expect(isNavRequest({ "x-pylon-nav": "0" })).toBe(false);
30
+ expect(isNavRequest({ "x-pylon-nav": "true" })).toBe(false);
31
+ expect(isNavRequest({ "x-pylon-nav": "" })).toBe(false);
32
+ });
33
+ });
34
+
35
+ const TAIL_ARGS = {
36
+ component: "app/dashboard/page",
37
+ layouts: ["app/layout"],
38
+ props: { url: "/dashboard", params: {}, auth: { user_id: "u_1" } },
39
+ ssrData: { "list:[\"Todo\"]": [{ id: "t1" }] },
40
+ manifestRoute: { file: "entry-a1.js", imports: ["chunks/s.js"], css: [] },
41
+ publicPrefix: "/_pylon/build/",
42
+ manifestErr: null,
43
+ };
44
+
45
+ describe("hydration tail, dataOnly", () => {
46
+ it("carries the same payload as a full page tail", () => {
47
+ const data = (html: string) =>
48
+ html.match(/<script id="__PYLON_DATA__"[^>]*>(.*?)<\/script>/s)?.[1];
49
+ expect(data(buildHydrationTail({ ...TAIL_ARGS, dataOnly: true }))).toBe(
50
+ data(buildHydrationTail(TAIL_ARGS))!,
51
+ );
52
+ });
53
+
54
+ it("drops the entry script and preloads a navigation doesn't need", () => {
55
+ // The client already has the runtime and resolves the route entry from the
56
+ // build manifest, so these are dead weight on every navigation.
57
+ const nav = buildHydrationTail({ ...TAIL_ARGS, dataOnly: true });
58
+ expect(nav).not.toContain("<script type=\"module\"");
59
+ expect(nav).not.toContain("entry-a1.js");
60
+ const full = buildHydrationTail(TAIL_ARGS);
61
+ expect(full).toContain("entry-a1.js");
62
+ });
63
+
64
+ it("still strips the live handles and the request's own headers/cookies", () => {
65
+ // SECURITY: the strip is why this shares buildHydrationTail rather than
66
+ // rebuilding the payload — the session cookie must never reach client JS.
67
+ const nav = buildHydrationTail({
68
+ ...TAIL_ARGS,
69
+ props: {
70
+ ...TAIL_ARGS.props,
71
+ headers: { cookie: "pylon_session=secret" },
72
+ cookies: { pylon_session: "secret" },
73
+ serverData: { list: () => {} },
74
+ response: { setStatus: () => {} },
75
+ },
76
+ dataOnly: true,
77
+ });
78
+ expect(nav).not.toContain("secret");
79
+ expect(nav).not.toContain("serverData");
80
+ });
81
+
82
+ it("carries only the binary signed-in bit on a bucketed render", () => {
83
+ // A bucketed render is stored SHARED, so its payload must never carry the
84
+ // rendering user's identity — same rule, data-only or not.
85
+ const nav = buildHydrationTail({
86
+ ...TAIL_ARGS,
87
+ props: { ...TAIL_ARGS.props, auth: { user_id: "u_1", email: "a@b.c" } },
88
+ bucketAuth: { signedIn: true },
89
+ dataOnly: true,
90
+ });
91
+ expect(nav).not.toContain("u_1");
92
+ expect(nav).not.toContain("a@b.c");
93
+ expect(nav).toContain('"signedIn":true');
94
+ });
95
+ });
@@ -162,10 +162,15 @@ describe("prefetchTargets", () => {
162
162
  });
163
163
 
164
164
  it("is null-safe on an absent or empty manifest", () => {
165
- expect(prefetchTargets(null, "/")).toEqual({ file: "", imports: [] });
165
+ expect(prefetchTargets(null, "/")).toEqual({
166
+ file: "",
167
+ imports: [],
168
+
169
+ });
166
170
  expect(prefetchTargets({ routes: {} }, "/")).toEqual({
167
171
  file: "",
168
172
  imports: [],
173
+
169
174
  });
170
175
  });
171
176
 
@@ -116,34 +116,48 @@ export function matchRoute(
116
116
  export interface PrefetchTargets {
117
117
  /** The destination route's own entry chunk, or "" when no page route matches. */
118
118
  file: string;
119
- /** Shared chunks to warm alongside it. */
119
+ /** Chunks to warm alongside it. */
120
120
  imports: string[];
121
121
  }
122
122
 
123
123
  /**
124
124
  * The chunks a click on `pathname` will need before anything can render: the
125
- * destination route's entry, plus the shared chunks (React, the client
126
- * runtime, common layouts) that every route pulls in.
125
+ * destination route's entry, plus the chunks (React, the client runtime,
126
+ * common layouts) it pulls in.
127
127
  *
128
128
  * Warming the page payload alone leaves the entry chunk to be fetched after
129
129
  * the click, and the route cannot render until it lands — so the prefetch
130
- * covers only the half that was already fast. An href matching no page route
131
- * (an API path, a route this build doesn't serve) still yields the shared set;
132
- * `file` is "" and the caller skips it.
130
+ * covers only the half that was already fast.
131
+ *
132
+ * Size is deliberately NOT a factor. Build output is content-hashed and served
133
+ * immutable, so warming a heavy route costs its bytes once per browser and
134
+ * makes every later visit to it instant; skipping it would trade a permanent
135
+ * win for a one-time saving, and would skip exactly the routes slowest to
136
+ * fetch on demand. The warm is deferred to the load event, so those bytes
137
+ * never compete with the current page's own render.
138
+ *
139
+ * An href matching no page route (an API path, a route this build doesn't
140
+ * serve) yields the union of every route's chunks: no destination is known,
141
+ * but those are needed by any navigation.
133
142
  */
134
143
  export function prefetchTargets(
135
144
  manifest: MatchableManifest | null | undefined,
136
145
  pathname: string,
137
146
  ): PrefetchTargets {
138
- const imports = new Set<string>();
139
147
  if (!manifest || !manifest.routes) return { file: "", imports: [] };
140
- for (const r of Object.values(manifest.routes)) {
141
- for (const i of r?.imports || []) imports.add(i);
142
- }
143
148
  const matched = matchRoute(manifest, pathname);
144
149
  const route = matched ? manifest.routes[matched.component] : null;
145
150
  if (route) {
146
- for (const i of route.imports || []) imports.add(i);
151
+ // The route's OWN transitive chunks — everything it needs, and nothing
152
+ // belonging to routes the user isn't heading for.
153
+ return {
154
+ file: route.file || "",
155
+ imports: Array.from(new Set(route.imports || [])),
156
+ };
157
+ }
158
+ const imports = new Set<string>();
159
+ for (const r of Object.values(manifest.routes)) {
160
+ for (const i of r?.imports || []) imports.add(i);
147
161
  }
148
- return { file: route?.file || "", imports: Array.from(imports) };
162
+ return { file: "", imports: Array.from(imports) };
149
163
  }
@@ -763,24 +763,85 @@ async function buildLayoutTree(
763
763
  * Returns the project-relative path (no extension) or null.
764
764
  */
765
765
  function findBoundary(componentPath: string, fileName: string): string | null {
766
- const fs = require("node:fs");
767
- const path = require("node:path");
768
- const cwd = process.cwd();
766
+ return findBoundaryIn(
767
+ require("node:fs"),
768
+ require("node:path"),
769
+ process.cwd(),
770
+ componentPath,
771
+ fileName,
772
+ );
773
+ }
774
+
775
+ /**
776
+ * `findBoundary` with its filesystem and root injected, so the walk is
777
+ * testable against a fixture without `chdir` — which races every other test
778
+ * in the process.
779
+ */
780
+ export function findBoundaryIn(
781
+ fs: any,
782
+ path: any,
783
+ cwd: string,
784
+ componentPath: string,
785
+ fileName: string,
786
+ ): string | null {
769
787
  // Component paths use "/" — walk up directory by directory.
770
788
  let dir = componentPath.replace(/\\/g, "/");
771
789
  dir = dir.includes("/") ? dir.slice(0, dir.lastIndexOf("/")) : "";
772
790
  while (dir && dir !== "." && dir !== "/") {
773
- for (const ext of MODULE_EXTS) {
774
- if (fs.existsSync(path.join(cwd, dir, `${fileName}${ext}`))) {
775
- return `${dir}/${fileName}`;
776
- }
777
- }
791
+ const hit = boundaryInDirOrGroups(fs, path, cwd, dir, fileName);
792
+ if (hit) return hit;
778
793
  const slash = dir.lastIndexOf("/");
779
794
  dir = slash >= 0 ? dir.slice(0, slash) : "";
780
795
  }
781
796
  return null;
782
797
  }
783
798
 
799
+ /**
800
+ * The boundary for one URL-space level: `<dir>/<fileName>`, or the same file
801
+ * inside a route group under it.
802
+ *
803
+ * A route group contributes no URL segment, so `app/(marketing)/not-found` is
804
+ * the boundary for `/` exactly as `app/not-found` is — and an app can't ship
805
+ * both, because the duplicate-path check rejects two routes claiming `/`.
806
+ * Without this, putting the file in a group left `/` with no boundary at all
807
+ * while the build insisted the group's copy owned it.
808
+ *
809
+ * Groups nest, so this recurses. The directory's own file wins over a group's,
810
+ * and groups are searched in name order so the answer never depends on
811
+ * readdir ordering.
812
+ */
813
+ function boundaryInDirOrGroups(
814
+ fs: any,
815
+ path: any,
816
+ cwd: string,
817
+ dir: string,
818
+ fileName: string,
819
+ ): string | null {
820
+ for (const ext of MODULE_EXTS) {
821
+ if (fs.existsSync(path.join(cwd, dir, `${fileName}${ext}`))) {
822
+ return `${dir}/${fileName}`;
823
+ }
824
+ }
825
+ let entries: any[];
826
+ try {
827
+ entries = fs.readdirSync(path.join(cwd, dir), { withFileTypes: true });
828
+ } catch {
829
+ return null;
830
+ }
831
+ const groups = entries
832
+ .filter(
833
+ (e: any) =>
834
+ e.isDirectory() && e.name.startsWith("(") && e.name.endsWith(")"),
835
+ )
836
+ .map((e: any) => e.name as string)
837
+ .sort();
838
+ for (const g of groups) {
839
+ const hit = boundaryInDirOrGroups(fs, path, cwd, `${dir}/${g}`, fileName);
840
+ if (hit) return hit;
841
+ }
842
+ return null;
843
+ }
844
+
784
845
  // ---------------------------------------------------------------------------
785
846
  // Social-card image file convention (Next-style `opengraph-image.png` /
786
847
  // `twitter-image.png` colocated with a `page.tsx`). Drop the file in a
@@ -1904,6 +1965,13 @@ export function buildHydrationTail(args: {
1904
1965
  // user's identity to another (the #277 leak class, at the body level). The
1905
1966
  // raw `auth` is replaced AFTER the live-handle strip below.
1906
1967
  bucketAuth?: { signedIn: boolean };
1968
+ /** Client-side NAVIGATION payload: emit the `__PYLON_DATA__` blob and
1969
+ * nothing else. The client already has the runtime and resolves the route
1970
+ * entry from the build manifest, so the entry `<script>`, the preload tags
1971
+ * and the dev-reload snippet are all dead weight on a navigation. Shares
1972
+ * this function — and therefore its props strip — rather than rebuilding
1973
+ * the payload somewhere the security-relevant parts could drift. */
1974
+ dataOnly?: boolean;
1907
1975
  }): string {
1908
1976
  // Strip live, non-serializable handles (serverData / response / reset) + the
1909
1977
  // request headers/cookies (SECURITY: never expose the session cookie to
@@ -1950,6 +2018,7 @@ export function buildHydrationTail(args: {
1950
2018
  if (args.kind) hydrationPayload.kind = args.kind;
1951
2019
  const json = escapeScriptJson(JSON.stringify(hydrationPayload));
1952
2020
  let tail = `<script id="__PYLON_DATA__" type="application/json">${json}</script>`;
2021
+ if (args.dataOnly) return tail;
1953
2022
  if (args.manifestRoute) {
1954
2023
  // A cross-origin CDN module (`public_prefix` is an absolute http(s) URL) is
1955
2024
  // fetched in CORS mode; `crossorigin` makes the matching modulepreload
@@ -1967,6 +2036,54 @@ export function buildHydrationTail(args: {
1967
2036
  return tail;
1968
2037
  }
1969
2038
 
2039
+ /**
2040
+ * Is this render answering a client-side navigation rather than a document
2041
+ * load? The client runtime sets `X-Pylon-Nav: 1`; the Rust host reads the same
2042
+ * header to key the ISR cache, so the two always agree on which shape a URL
2043
+ * is being asked for.
2044
+ */
2045
+ export function isNavRequest(
2046
+ headers: Record<string, unknown> | undefined,
2047
+ ): boolean {
2048
+ if (!headers) return false;
2049
+ const v = headers["x-pylon-nav"] ?? headers["X-Pylon-Nav"];
2050
+ return typeof v === "string" && v.trim() === "1";
2051
+ }
2052
+
2053
+ /**
2054
+ * Render a detached element (the metadata fragment) to an HTML string.
2055
+ *
2056
+ * A navigation response carries the page's `<title>`/`<meta>`/`<link>` so the
2057
+ * client can swap them, but not the page body — which the client re-renders
2058
+ * from `__PYLON_DATA__` anyway. Rendering the fragment on its own is what
2059
+ * lets the body be dropped without losing the head.
2060
+ */
2061
+ export async function renderElementToString(
2062
+ renderToReadableStream: any,
2063
+ element: any,
2064
+ ): Promise<string> {
2065
+ if (!element) return "";
2066
+ try {
2067
+ const stream = await renderToReadableStream(element);
2068
+ if ((stream as any).allReady) await (stream as any).allReady;
2069
+ const reader = stream.getReader();
2070
+ // One decoder across the whole read: a multi-byte character split across
2071
+ // chunk boundaries decodes correctly only if the decoder carries state.
2072
+ const decoder = new TextDecoder("utf-8");
2073
+ let out = "";
2074
+ for (;;) {
2075
+ const { done, value } = await reader.read();
2076
+ if (done) break;
2077
+ out += decoder.decode(value, { stream: true });
2078
+ }
2079
+ out += decoder.decode();
2080
+ return out;
2081
+ } catch {
2082
+ // Metadata is never worth failing a navigation over.
2083
+ return "";
2084
+ }
2085
+ }
2086
+
1970
2087
  /**
1971
2088
  * The layout chain for a component, walked top-down from `app/` to the
1972
2089
  * component's own directory — IDENTICAL to the bundler's `discoverRoutes`
@@ -2146,7 +2263,32 @@ async function tryRenderBoundary(
2146
2263
  };
2147
2264
  compProps = { ...props, error: errorForClient, reset: () => {} };
2148
2265
  }
2266
+ // A boundary's own `metadata` / `generateMetadata`, same as a page's.
2267
+ // Without this a not-found.tsx rendered no <title> at all — and because
2268
+ // the framework's built-in 404 body supplies one, taking over the boundary
2269
+ // silently LOST the title rather than replacing it, leaving the tab
2270
+ // showing the raw URL. The fragment goes first so React hoists its
2271
+ // <title>/<meta> into the <head> a layout renders.
2272
+ let boundaryMeta: SsrMetadata | undefined = mod.metadata;
2273
+ if (typeof mod.generateMetadata === "function") {
2274
+ try {
2275
+ boundaryMeta = await mod.generateMetadata(compProps);
2276
+ } catch {
2277
+ // A boundary is already the failure path; don't fail it again over
2278
+ // its title.
2279
+ boundaryMeta = mod.metadata;
2280
+ }
2281
+ }
2282
+ const boundaryMetaFragment = renderMetadata(React, boundaryMeta);
2149
2283
  let tree = React.createElement(Comp, compProps);
2284
+ if (boundaryMetaFragment) {
2285
+ tree = React.createElement(
2286
+ React.Fragment,
2287
+ null,
2288
+ boundaryMetaFragment,
2289
+ tree,
2290
+ );
2291
+ }
2150
2292
  tree = await buildLayoutTree(cwd, tree, boundaryLayouts, compProps, React);
2151
2293
  await renderBoundaryToClient(
2152
2294
  React,
@@ -2969,6 +3111,14 @@ export async function handleRenderRoute(
2969
3111
  metadata = applyAutoIcons(msg.component, metadata);
2970
3112
  const metaFragment = renderMetadata(React, metadata);
2971
3113
 
3114
+ // Client-side navigation: the browser already has the runtime, the layouts
3115
+ // and the styles, and re-renders the page from `__PYLON_DATA__`. Measured
3116
+ // on a real route, the full document was 16442 bytes to deliver a 374-byte
3117
+ // payload — the markup is built, shipped, parsed, and thrown away. This
3118
+ // render still runs (it is what resolves serverData), but the response
3119
+ // carries only the head metadata and the data.
3120
+ const navRender = isNavRequest(msg.headers as Record<string, unknown>);
3121
+
2972
3122
  // loading.tsx (#278): the nearest `loading` module — walked up from the
2973
3123
  // page dir, like not-found/error — becomes ONE route-level Suspense
2974
3124
  // fallback wrapping the page. When present, the shell (layouts) + this
@@ -3094,7 +3244,10 @@ export async function handleRenderRoute(
3094
3244
  // the fully-resolved `ssrData` map) and after all of React's $RC reveals,
3095
3245
  // so the client's `use()` reads a fulfilled value and never re-suspends —
3096
3246
  // there is no progressive hydration racing the stream.
3097
- if (!wantsStream && (stream as any).allReady) {
3247
+ // A navigation response is data, so it must wait for the whole render even
3248
+ // on a page that would otherwise stream: `ssrData` is only complete once
3249
+ // every serverData read has resolved, and that map IS the response.
3250
+ if ((!wantsStream || navRender) && (stream as any).allReady) {
3098
3251
  await (stream as any).allReady;
3099
3252
  }
3100
3253
 
@@ -3314,7 +3467,26 @@ export async function handleRenderRoute(
3314
3467
  data: Buffer.from(text, "utf8").toString("base64"),
3315
3468
  });
3316
3469
  };
3317
- await streamWithHeadInjection(stream.getReader(), headBlob, sendChunk);
3470
+ if (navRender) {
3471
+ // Drain React's output and drop it. The render is what resolves the
3472
+ // serverData reads that fill `ssrData`; the markup it produces is the
3473
+ // part the client rebuilds for itself.
3474
+ const reader = stream.getReader();
3475
+ for (;;) {
3476
+ const { done } = await reader.read();
3477
+ if (done) break;
3478
+ }
3479
+ // A minimal document, so the client's existing parse path works
3480
+ // unchanged: it reads document.title, copies [data-pylon-meta], and
3481
+ // pulls __PYLON_DATA__ — all of which this carries.
3482
+ sendChunk(
3483
+ '<!DOCTYPE html><html><head><meta charset="utf-8">' +
3484
+ (await renderElementToString(renderToReadableStream, metaFragment)) +
3485
+ "</head><body>",
3486
+ );
3487
+ } else {
3488
+ await streamWithHeadInjection(stream.getReader(), headBlob, sendChunk);
3489
+ }
3318
3490
 
3319
3491
  // #278: detect a late response.* mutation from a suspended subtree that the
3320
3492
  // already-committed head couldn't carry, and warn loudly (a silently
@@ -3401,17 +3573,22 @@ export async function handleRenderRoute(
3401
3573
  bucketAuth: bucketable
3402
3574
  ? { signedIn: msg.session_present === true }
3403
3575
  : undefined,
3576
+ dataOnly: navRender,
3404
3577
  });
3405
3578
  sendChunk(tail);
3406
3579
  }
3407
3580
 
3581
+
3408
3582
  // Dev HUD (dev only): append the cache-verdict + render-timing blob + the
3409
3583
  // floating overlay, and emit ONE structured log line. After the page tail so
3410
3584
  // the HUD marker/probe are in place before the deferred client entry boots the
3411
3585
  // sync engine. `devVerdict` was computed once above (reused here + in the
3412
3586
  // x-pylon-dev header). The log rides the runtime's inherited stderr, so an
3413
3587
  // agent running `pylon dev` sees the verdict without any extra call.
3414
- if (devVerdict) {
3588
+ //
3589
+ // Never on a navigation response: there is no document for the overlay to
3590
+ // attach to, and its blob would dwarf the payload it rode in on.
3591
+ if (devVerdict && !navRender) {
3415
3592
  const renderMs = Math.round((performance.now() - renderStart) * 10) / 10;
3416
3593
  // Build-level failures that degraded this page (currently: the
3417
3594
  // Tailwind compile). Dev-only and unmissable — the HUD paints a
@@ -3452,6 +3629,8 @@ export async function handleRenderRoute(
3452
3629
  // [pylon] line — no duplicate console.error here.
3453
3630
  }
3454
3631
 
3632
+ if (navRender) sendChunk("</body></html>");
3633
+
3455
3634
  send({ type: "render_done", call_id: msg.call_id });
3456
3635
  } catch (err: any) {
3457
3636
  // A page/layout called response.redirect()/response.notFound(), or