@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.
- package/dist/ssr-client-boundary.d.ts +10 -0
- package/dist/ssr-nav-cache.d.ts +26 -0
- package/dist/ssr-route-match.d.ts +15 -6
- package/dist/ssr-runtime.d.ts +29 -0
- package/package.json +1 -1
- package/src/ssr-boundary-groups.test.ts +183 -0
- package/src/ssr-client-boundary.ts +31 -9
- package/src/ssr-client-bundler.test.ts +82 -1
- package/src/ssr-client-bundler.ts +63 -16
- package/src/ssr-nav-cache.test.ts +146 -0
- package/src/ssr-nav-cache.ts +97 -0
- package/src/ssr-nav-response.test.ts +95 -0
- package/src/ssr-route-match.test.ts +6 -1
- package/src/ssr-route-match.ts +26 -12
- package/src/ssr-runtime.ts +190 -11
|
@@ -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
|
-
/**
|
|
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
|
|
35
|
-
*
|
|
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.
|
|
40
|
-
*
|
|
41
|
-
*
|
|
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;
|
package/dist/ssr-runtime.d.ts
CHANGED
|
@@ -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
|
@@ -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
|
|
46
|
-
let
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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 {
|
|
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
|
-
|
|
601
|
-
|
|
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
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
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
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
if (
|
|
859
|
-
|
|
860
|
-
|
|
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({
|
|
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
|
|
package/src/ssr-route-match.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
|
126
|
-
*
|
|
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.
|
|
131
|
-
*
|
|
132
|
-
*
|
|
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
|
-
|
|
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:
|
|
162
|
+
return { file: "", imports: Array.from(imports) };
|
|
149
163
|
}
|
package/src/ssr-runtime.ts
CHANGED
|
@@ -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
|
-
|
|
767
|
-
|
|
768
|
-
|
|
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
|
-
|
|
774
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|