@pylonsync/functions 0.22.60 → 0.22.61
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/offline-sw.d.ts +2 -0
- package/dist/ssr-client-bundler.d.ts +1 -1
- package/dist/ssr-page-tree.d.ts +1 -1
- package/dist/ssr-runtime.d.ts +29 -9
- package/package.json +1 -1
- package/src/offline-sw.test.ts +36 -1
- package/src/offline-sw.ts +26 -1
- package/src/ssr-client-bundler.ts +90 -10
- package/src/ssr-client-nav-runtime.fixture.ts +168 -8
- package/src/ssr-client-nav-runtime.test.ts +68 -4
- package/src/ssr-metadata.test.ts +251 -0
- package/src/ssr-page-tree.ts +5 -8
- package/src/ssr-runtime.ts +215 -41
|
@@ -24,11 +24,33 @@ const MANIFEST = {
|
|
|
24
24
|
"app/fast/page": { file: "fast.js", imports: [], css: [], path: "/fast", navigation: "client" },
|
|
25
25
|
"app/slow/page": { file: "slow.js", imports: [], css: [], path: "/slow" },
|
|
26
26
|
"app/offline/page": { file: "offline.js", imports: [], css: [], path: "/offline" },
|
|
27
|
+
// Never registered by a test hydrate() call, and their files do not
|
|
28
|
+
// exist: loading them fails like a file the deploy removed.
|
|
29
|
+
"app/reports/page": { file: "reports-v1.js", imports: [], css: [], path: "/reports" },
|
|
30
|
+
"app/quick/page": { file: "quick-v1.js", imports: [], css: [], path: "/quick", navigation: "client" },
|
|
27
31
|
},
|
|
28
32
|
offline: { routes: ["/offline"] },
|
|
29
33
|
};
|
|
30
34
|
|
|
31
|
-
|
|
35
|
+
/** \`stale-manifest\`: the page runs build v1, and the build manifest it gets
|
|
36
|
+
* is build v0's, as an older service worker answers from its cache. */
|
|
37
|
+
const MODE = process.argv[2] ?? "default";
|
|
38
|
+
|
|
39
|
+
/** Build v0's manifest: other files for the same routes. */
|
|
40
|
+
const STALE_MANIFEST = {
|
|
41
|
+
...MANIFEST,
|
|
42
|
+
routes: Object.fromEntries(
|
|
43
|
+
Object.entries(MANIFEST.routes).map(([k, v]) => [k, { ...v, file: v.file.replace(/(-v1)?\.js$/, "-v0.js") }]),
|
|
44
|
+
),
|
|
45
|
+
offline: { ...MANIFEST.offline, version: "v0" },
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** The page's build: the home page's __PYLON_DATA__ carries it. */
|
|
49
|
+
const PAGE_BUILD = "v1";
|
|
50
|
+
/** The build the server runs after the deploy. */
|
|
51
|
+
let serverBuild = "v1";
|
|
52
|
+
|
|
53
|
+
const LINKS = ["/", "/fast", "/slow", "/offline", "/reports", "/quick"];
|
|
32
54
|
|
|
33
55
|
function Layout({ children }: { children?: React.ReactNode }) {
|
|
34
56
|
return React.createElement(
|
|
@@ -65,16 +87,25 @@ const Offline = page("offline");
|
|
|
65
87
|
|
|
66
88
|
const fetches: string[] = [];
|
|
67
89
|
const scrolls: number[] = [];
|
|
90
|
+
/** Full page loads (\`location.href = ...\`) the runtime started. */
|
|
91
|
+
const fullLoads: string[] = [];
|
|
92
|
+
/** Calls to @pylonsync/react's moveToLatestBuild, and its answer. */
|
|
93
|
+
const moves: any[] = [];
|
|
94
|
+
let moveResult = "moving";
|
|
95
|
+
/** The server's build as the page's updater last saw it: a recheck asks
|
|
96
|
+
* the server; null until then (no check has run). */
|
|
97
|
+
let knownBuild: string | null = null;
|
|
68
98
|
let win: any;
|
|
69
99
|
let rt: any;
|
|
70
100
|
let dir = "";
|
|
71
101
|
|
|
72
|
-
function pylonData(component: string, pathname: string): string {
|
|
102
|
+
function pylonData(component: string, pathname: string, build = serverBuild): string {
|
|
73
103
|
const data = {
|
|
74
104
|
component,
|
|
75
105
|
layouts: [],
|
|
76
106
|
props: { pathname, url: pathname, params: {}, searchParams: {} },
|
|
77
107
|
ssrData: {},
|
|
108
|
+
offline: { version: build },
|
|
78
109
|
};
|
|
79
110
|
return `<script id="__PYLON_DATA__" type="application/json">${JSON.stringify(data)}</script>`;
|
|
80
111
|
}
|
|
@@ -108,18 +139,29 @@ async function setUp() {
|
|
|
108
139
|
dir = fs.mkdtempSync(path.join(cacheDir, "pylon-client-nav-"));
|
|
109
140
|
await writeClientRuntimeModules(fs, path, dir);
|
|
110
141
|
// @pylonsync/react is not a dependency of this package: stand in for the
|
|
111
|
-
//
|
|
142
|
+
// functions the runtime calls.
|
|
112
143
|
const runtimeFile = path.join(dir, "client-runtime.ts");
|
|
113
144
|
fs.writeFileSync(
|
|
114
145
|
runtimeFile,
|
|
115
146
|
fs
|
|
116
147
|
.readFileSync(runtimeFile, "utf8")
|
|
117
|
-
.
|
|
148
|
+
.replaceAll('from "@pylonsync/react"', 'from "./pylon-react-stub"'),
|
|
118
149
|
);
|
|
119
150
|
fs.writeFileSync(
|
|
120
151
|
path.join(dir, "pylon-react-stub.ts"),
|
|
121
|
-
"export function configureClient() {}\
|
|
152
|
+
"export function configureClient() {}\n" +
|
|
153
|
+
"export async function registerOfflineWorker() { return null; }\n" +
|
|
154
|
+
"export function moveToLatestBuild(options) { return globalThis.__moveToLatestBuild(options); }\n",
|
|
122
155
|
);
|
|
156
|
+
// Answers like AppUpdater.moveToLatestBuild: from the last check, or
|
|
157
|
+
// from the server on a recheck.
|
|
158
|
+
(globalThis as any).__moveToLatestBuild = async (options: any) => {
|
|
159
|
+
moves.push(options);
|
|
160
|
+
if (options.recheck) knownBuild = serverBuild;
|
|
161
|
+
if (knownBuild === null) return "offline";
|
|
162
|
+
if (knownBuild === PAGE_BUILD) return "current";
|
|
163
|
+
return moveResult;
|
|
164
|
+
};
|
|
123
165
|
fs.writeFileSync(path.join(dir, "loading-registry.ts"), "export const LOADING_MODULES = {};\n");
|
|
124
166
|
|
|
125
167
|
win = new Window({ url: "http://localhost/" });
|
|
@@ -138,11 +180,22 @@ async function setUp() {
|
|
|
138
180
|
g.requestAnimationFrame = (cb: any) => setTimeout(() => cb(Date.now()), 0);
|
|
139
181
|
g.cancelAnimationFrame = (id: any) => clearTimeout(id);
|
|
140
182
|
win.scrollTo = (_x: number, y: number) => scrolls.push(y);
|
|
183
|
+
// Record full page loads instead of letting happy-dom navigate.
|
|
184
|
+
const hrefDescriptor = Object.getOwnPropertyDescriptor(
|
|
185
|
+
Object.getPrototypeOf(win.location),
|
|
186
|
+
"href",
|
|
187
|
+
)!;
|
|
188
|
+
Object.defineProperty(win.location, "href", {
|
|
189
|
+
configurable: true,
|
|
190
|
+
get: () => hrefDescriptor.get!.call(win.location),
|
|
191
|
+
set: (value: string) => fullLoads.push(value),
|
|
192
|
+
});
|
|
141
193
|
const fakeFetch = async (input: any) => {
|
|
142
194
|
const url = typeof input === "string" ? input : input.url;
|
|
143
195
|
fetches.push(url);
|
|
144
196
|
if (url === "/_pylon/build/manifest.json") {
|
|
145
|
-
|
|
197
|
+
const body = MODE === "stale-manifest" ? STALE_MANIFEST : MANIFEST;
|
|
198
|
+
return new Response(JSON.stringify(body), { status: 200 });
|
|
146
199
|
}
|
|
147
200
|
const pathname = new URL(url, "http://localhost").pathname;
|
|
148
201
|
if (pathname === "/slow") {
|
|
@@ -151,6 +204,12 @@ async function setUp() {
|
|
|
151
204
|
{ status: 200 },
|
|
152
205
|
);
|
|
153
206
|
}
|
|
207
|
+
if (pathname === "/reports") {
|
|
208
|
+
return new Response(
|
|
209
|
+
`<!DOCTYPE html><html><head><title>Reports</title></head><body>${pylonData("app/reports/page", pathname)}</body></html>`,
|
|
210
|
+
{ status: 200 },
|
|
211
|
+
);
|
|
212
|
+
}
|
|
154
213
|
return new Response("not found", { status: 404 });
|
|
155
214
|
};
|
|
156
215
|
g.fetch = fakeFetch;
|
|
@@ -160,7 +219,7 @@ async function setUp() {
|
|
|
160
219
|
const props = { pathname: "/", url: "/", params: {}, searchParams: {} };
|
|
161
220
|
const html = renderToString(React.createElement(Layout, null, React.createElement(Home, props)));
|
|
162
221
|
const parsed = new win.DOMParser().parseFromString(
|
|
163
|
-
"<!DOCTYPE html>" + html.replace("</body>", pylonData("app/home/page", "/") + "</body>"),
|
|
222
|
+
"<!DOCTYPE html>" + html.replace("</body>", pylonData("app/home/page", "/", PAGE_BUILD) + "</body>"),
|
|
164
223
|
"text/html",
|
|
165
224
|
);
|
|
166
225
|
// Remove, then append: happy-dom 20's replaceChild inserts the new root
|
|
@@ -245,7 +304,102 @@ async function prefetchTiming() {
|
|
|
245
304
|
const intent = preloads().length - beforeSave;
|
|
246
305
|
return { sightBeforeIdle, sightAfterIdle, saveDataSight, intent };
|
|
247
306
|
}
|
|
248
|
-
|
|
307
|
+
/** A deploy while the page is open: its build's route files are gone. */
|
|
308
|
+
async function afterDeploy() {
|
|
309
|
+
const out: Record<string, any> = {};
|
|
310
|
+
const take = () => {
|
|
311
|
+
const seen = {
|
|
312
|
+
moves: moves.splice(0),
|
|
313
|
+
fullLoads: fullLoads.splice(0),
|
|
314
|
+
heading: heading() ?? null,
|
|
315
|
+
pageFetches: pageFetches(),
|
|
316
|
+
};
|
|
317
|
+
fetches.length = 0;
|
|
318
|
+
return seen;
|
|
319
|
+
};
|
|
320
|
+
take();
|
|
321
|
+
|
|
322
|
+
// Before the deploy, a route entry that fails to load (a bug, not a
|
|
323
|
+
// deploy) still falls back to one full page load.
|
|
324
|
+
click("toreports");
|
|
325
|
+
await settle();
|
|
326
|
+
out.sameBuildFailure = take();
|
|
327
|
+
|
|
328
|
+
serverBuild = "v2";
|
|
329
|
+
knownBuild = null;
|
|
330
|
+
// A click on a page route: the server answers with build v2's page. The
|
|
331
|
+
// page moves to v2 once, through moveToLatestBuild, opening the link;
|
|
332
|
+
// before the fix this was a full load of the old build, on every click.
|
|
333
|
+
click("toreports");
|
|
334
|
+
await settle();
|
|
335
|
+
out.pageRoute = take();
|
|
336
|
+
|
|
337
|
+
// A click on a route that renders on the client, whose entry the deploy
|
|
338
|
+
// removed: the same move, with no page fetch.
|
|
339
|
+
knownBuild = null;
|
|
340
|
+
click("toquick");
|
|
341
|
+
await settle();
|
|
342
|
+
out.clientRoute = take();
|
|
343
|
+
|
|
344
|
+
// The page's last check already saw the new build: the click moves at
|
|
345
|
+
// once, with no page fetch and no entry load.
|
|
346
|
+
knownBuild = "v2";
|
|
347
|
+
click("toreports");
|
|
348
|
+
await settle();
|
|
349
|
+
out.knownAhead = take();
|
|
350
|
+
|
|
351
|
+
// Offline: the entry cannot load and the server cannot be asked. The
|
|
352
|
+
// page keeps its screen; no full load, which would show a kept page.
|
|
353
|
+
moveResult = "offline";
|
|
354
|
+
knownBuild = null;
|
|
355
|
+
serverBuild = null as any;
|
|
356
|
+
click("toquick");
|
|
357
|
+
await settle();
|
|
358
|
+
out.offline = take();
|
|
359
|
+
|
|
360
|
+
// A page that already moved and is still on the old build: one full
|
|
361
|
+
// load, and no loop of moves.
|
|
362
|
+
moveResult = "blocked";
|
|
363
|
+
serverBuild = "v2";
|
|
364
|
+
knownBuild = null;
|
|
365
|
+
click("toreports");
|
|
366
|
+
await settle();
|
|
367
|
+
out.blocked = take();
|
|
368
|
+
return out;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** The page of build v1 got build v0's manifest. Before the fix it loaded
|
|
372
|
+
* v0's route files (gone, so every click was a full page load). */
|
|
373
|
+
async function staleManifest() {
|
|
374
|
+
const warnings: string[] = [];
|
|
375
|
+
const warn = console.warn;
|
|
376
|
+
console.warn = (...args: any[]) => warnings.push(args.map(String).join(" "));
|
|
377
|
+
try {
|
|
378
|
+
await setUp();
|
|
379
|
+
// The server and the page agree on v1; the page's own worker waits
|
|
380
|
+
// behind the old one, so the updater moves.
|
|
381
|
+
knownBuild = null;
|
|
382
|
+
moveResult = "moving";
|
|
383
|
+
serverBuild = "v1";
|
|
384
|
+
(globalThis as any).__moveToLatestBuild = async (options: any) => {
|
|
385
|
+
moves.push(options);
|
|
386
|
+
return options.recheck ? "moving" : "offline";
|
|
387
|
+
};
|
|
388
|
+
click("toreports");
|
|
389
|
+
await settle();
|
|
390
|
+
return {
|
|
391
|
+
moves: moves.splice(0),
|
|
392
|
+
fullLoads: fullLoads.splice(0),
|
|
393
|
+
preloads: preloads(),
|
|
394
|
+
pageFetches: pageFetches(),
|
|
395
|
+
warnedAboutManifest: warnings.some((w) => w.includes("build manifest is for build v0")),
|
|
396
|
+
};
|
|
397
|
+
} finally {
|
|
398
|
+
console.warn = warn;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
async function defaultFlow() {
|
|
249
403
|
await setUp();
|
|
250
404
|
report.hydrated = observe();
|
|
251
405
|
click("tofast");
|
|
@@ -266,6 +420,12 @@ try {
|
|
|
266
420
|
await traverse("forward");
|
|
267
421
|
report.forward2 = observe();
|
|
268
422
|
report.prefetch = await prefetchTiming();
|
|
423
|
+
report.deploy = await afterDeploy();
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
try {
|
|
427
|
+
if (MODE === "stale-manifest") report.staleManifest = await staleManifest();
|
|
428
|
+
else await defaultFlow();
|
|
269
429
|
} finally {
|
|
270
430
|
if (dir) fs.rmSync(dir, { recursive: true, force: true });
|
|
271
431
|
}
|
|
@@ -6,15 +6,22 @@
|
|
|
6
6
|
// route) render with no page fetch;
|
|
7
7
|
// - /slow fetches its page, as before;
|
|
8
8
|
// - back and forward add no history entries and fetch only /slow;
|
|
9
|
-
// - every navigation resets the scroll, like a fetched one
|
|
9
|
+
// - every navigation resets the scroll, like a fetched one;
|
|
10
|
+
// - after a deploy, a click moves the page to the new build once instead
|
|
11
|
+
// of a full page load of the old build per click, and an offline page
|
|
12
|
+
// keeps its screen;
|
|
13
|
+
// - a build manifest of another build (an older service worker's cached
|
|
14
|
+
// copy) is not used.
|
|
10
15
|
|
|
11
16
|
import { beforeAll, describe, expect, test } from "bun:test";
|
|
12
17
|
import path from "node:path";
|
|
13
18
|
|
|
14
19
|
let report: Record<string, any> = {};
|
|
15
20
|
|
|
16
|
-
|
|
17
|
-
const
|
|
21
|
+
async function runFixture(mode?: string): Promise<Record<string, any>> {
|
|
22
|
+
const args = [process.execPath, path.join(import.meta.dir, "ssr-client-nav-runtime.fixture.ts")];
|
|
23
|
+
if (mode) args.push(mode);
|
|
24
|
+
const proc = Bun.spawn(args, {
|
|
18
25
|
cwd: path.join(import.meta.dir, ".."),
|
|
19
26
|
stdout: "pipe",
|
|
20
27
|
stderr: "pipe",
|
|
@@ -23,7 +30,12 @@ beforeAll(async () => {
|
|
|
23
30
|
await proc.exited;
|
|
24
31
|
const line = out.split("\n").find((l) => l.startsWith("REPORT "));
|
|
25
32
|
if (!line) throw new Error(`fixture printed no report:\n${out}\n${err}`);
|
|
26
|
-
|
|
33
|
+
return JSON.parse(line.slice("REPORT ".length));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
beforeAll(async () => {
|
|
37
|
+
const [main, stale] = await Promise.all([runFixture(), runFixture("stale-manifest")]);
|
|
38
|
+
report = { ...main, staleManifest: stale.staleManifest };
|
|
27
39
|
}, 60_000);
|
|
28
40
|
|
|
29
41
|
describe("instant client navigation", () => {
|
|
@@ -77,3 +89,55 @@ describe("instant client navigation", () => {
|
|
|
77
89
|
expect(intent).toBeGreaterThan(0);
|
|
78
90
|
});
|
|
79
91
|
});
|
|
92
|
+
|
|
93
|
+
describe("a deploy while the page is open", () => {
|
|
94
|
+
const early = (url: string) => ({ url, skipIfPendingWrites: true });
|
|
95
|
+
const recheck = (url: string) => ({ url, recheck: true });
|
|
96
|
+
|
|
97
|
+
test("before the deploy, an entry that fails to load still gets one full page load", () => {
|
|
98
|
+
const { sameBuildFailure } = report.deploy;
|
|
99
|
+
expect(sameBuildFailure.moves).toEqual([early("/reports"), recheck("/reports")]);
|
|
100
|
+
expect(sameBuildFailure.fullLoads).toEqual(["/reports"]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("a page route served by the new build moves the page once, with no full load", () => {
|
|
104
|
+
const { pageRoute } = report.deploy;
|
|
105
|
+
expect(pageRoute.pageFetches).toEqual(["/reports"]);
|
|
106
|
+
expect(pageRoute.moves).toEqual([early("/reports"), recheck("/reports")]);
|
|
107
|
+
expect(pageRoute.fullLoads).toEqual([]);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("a client route whose entry the deploy removed moves the page, with no page fetch", () => {
|
|
111
|
+
const { clientRoute } = report.deploy;
|
|
112
|
+
expect(clientRoute.moves).toEqual([early("/quick"), recheck("/quick")]);
|
|
113
|
+
expect(clientRoute.pageFetches).toEqual([]);
|
|
114
|
+
expect(clientRoute.fullLoads).toEqual([]);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("when the last check saw the new build, the click moves at once", () => {
|
|
118
|
+
const { knownAhead } = report.deploy;
|
|
119
|
+
expect(knownAhead.moves).toEqual([early("/reports")]);
|
|
120
|
+
expect(knownAhead.pageFetches).toEqual([]);
|
|
121
|
+
expect(knownAhead.fullLoads).toEqual([]);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("offline, a route that cannot load keeps the current screen", () => {
|
|
125
|
+
const { offline } = report.deploy;
|
|
126
|
+
expect(offline.fullLoads).toEqual([]);
|
|
127
|
+
expect(offline.heading).toBe("slow /slow");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("a page that already moved and is still on the old build gets one full load, not a loop", () => {
|
|
131
|
+
const { blocked } = report.deploy;
|
|
132
|
+
expect(blocked.moves).toEqual([early("/reports"), recheck("/reports")]);
|
|
133
|
+
expect(blocked.fullLoads).toEqual(["/reports"]);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("another build's manifest is not used: no route files of that build load", () => {
|
|
137
|
+
const stale = report.staleManifest;
|
|
138
|
+
expect(stale.warnedAboutManifest).toBe(true);
|
|
139
|
+
expect(stale.preloads.filter((h: string) => h.includes("-v0"))).toEqual([]);
|
|
140
|
+
expect(stale.moves).toEqual([early("/reports"), recheck("/reports")]);
|
|
141
|
+
expect(stale.fullLoads).toEqual([]);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
// Page metadata in the served HTML.
|
|
2
|
+
//
|
|
3
|
+
// Veritas pages exported `metadata` and the HTML had none of it: the server
|
|
4
|
+
// put the tags inside the React tree beside the page, and the field layout
|
|
5
|
+
// renders a loading screen on the server instead of its children (the
|
|
6
|
+
// member check runs in the browser), so the tags were never rendered. The
|
|
7
|
+
// server now writes the metadata into <head> itself, merges layout metadata
|
|
8
|
+
// under the page's, and replaces the tags a root layout writes in its own
|
|
9
|
+
// <head> instead of adding a second set.
|
|
10
|
+
//
|
|
11
|
+
// ssr-runtime.ts pulls in runtime.ts (the bun runner entrypoint, which runs
|
|
12
|
+
// main() on import), so each render runs in a child process, like
|
|
13
|
+
// ssr-offline-shell.test.ts.
|
|
14
|
+
|
|
15
|
+
import { describe, expect, test } from "bun:test";
|
|
16
|
+
import { mkdirSync, mkdtempSync, symlinkSync, writeFileSync } from "node:fs";
|
|
17
|
+
import { tmpdir } from "node:os";
|
|
18
|
+
import { join } from "node:path";
|
|
19
|
+
import { dropReplacedHeadTags, headTagKeys, mergeMetadata } from "./ssr-runtime";
|
|
20
|
+
|
|
21
|
+
const SSR_RUNTIME = join(import.meta.dir, "ssr-runtime.ts");
|
|
22
|
+
const PKG_NODE_MODULES = join(import.meta.dir, "..", "node_modules");
|
|
23
|
+
|
|
24
|
+
function makeApp(files: Record<string, string>): string {
|
|
25
|
+
const dir = mkdtempSync(join(tmpdir(), "pylon-metadata-"));
|
|
26
|
+
mkdirSync(join(dir, "app"), { recursive: true });
|
|
27
|
+
symlinkSync(PKG_NODE_MODULES, join(dir, "node_modules"), "dir");
|
|
28
|
+
for (const [p, src] of Object.entries(files)) {
|
|
29
|
+
mkdirSync(join(dir, p, ".."), { recursive: true });
|
|
30
|
+
writeFileSync(join(dir, p), src);
|
|
31
|
+
}
|
|
32
|
+
return dir;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function render(
|
|
36
|
+
dir: string,
|
|
37
|
+
layouts: string[],
|
|
38
|
+
headers: Record<string, string> = {},
|
|
39
|
+
component = "app/page",
|
|
40
|
+
): Promise<string> {
|
|
41
|
+
const msg = {
|
|
42
|
+
type: "render_route", call_id: "c1", component, layouts,
|
|
43
|
+
route_path: "/", url: "/", host: "app.example", params: {}, search_params: {},
|
|
44
|
+
headers: { host: "app.example", ...headers }, cookies: {}, auth: {}, session_present: false,
|
|
45
|
+
};
|
|
46
|
+
const probe = `
|
|
47
|
+
import { handleRenderRoute } from ${JSON.stringify(SSR_RUNTIME)};
|
|
48
|
+
const frames = [];
|
|
49
|
+
await handleRenderRoute(${JSON.stringify(msg)}, (m) => frames.push(m));
|
|
50
|
+
console.error("FRAMES " + JSON.stringify(frames));
|
|
51
|
+
setTimeout(() => process.exit(0), 50);
|
|
52
|
+
`;
|
|
53
|
+
const probePath = join(dir, "probe.ts");
|
|
54
|
+
writeFileSync(probePath, probe);
|
|
55
|
+
const proc = Bun.spawn(["bun", "run", probePath], { cwd: dir, env: { ...process.env, PYLON_DEV_MODE: "0" }, stdin: "pipe", stdout: "pipe", stderr: "pipe" });
|
|
56
|
+
const [out, err] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
|
|
57
|
+
const code = await proc.exited;
|
|
58
|
+
const line = err.split("\n").find((l) => l.includes("FRAMES "));
|
|
59
|
+
if (code !== 0 || !line) throw new Error(`probe failed (exit ${code})\nstdout: ${out}\nstderr: ${err}`);
|
|
60
|
+
const frames = JSON.parse(line.slice(line.indexOf("FRAMES ") + 7)) as any[];
|
|
61
|
+
return frames.filter((f) => f.type === "render_chunk").map((f) => Buffer.from(f.data, "base64").toString("utf8")).join("");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
// A root layout like Veritas's: its own <head> with default link-preview tags.
|
|
66
|
+
const ROOT_LAYOUT = `export default function RootLayout({ children }: any) {
|
|
67
|
+
return (
|
|
68
|
+
<html lang="en">
|
|
69
|
+
<head>
|
|
70
|
+
<meta charSet="utf-8" />
|
|
71
|
+
<title>Field Sales</title>
|
|
72
|
+
<meta name="description" content="The field app." />
|
|
73
|
+
<meta property="og:title" content="Field Sales" />
|
|
74
|
+
<meta property="og:image" content="https://app.example/og.png" />
|
|
75
|
+
<meta property="og:image:width" content="1200" />
|
|
76
|
+
<meta name="twitter:title" content="Field Sales" />
|
|
77
|
+
<script dangerouslySetInnerHTML={{ __html: 'var t = "<title>kept</title>";' }} />
|
|
78
|
+
</head>
|
|
79
|
+
<body>{children}</body>
|
|
80
|
+
</html>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
`;
|
|
84
|
+
|
|
85
|
+
// A layout that decides access in the browser: on the server it renders a
|
|
86
|
+
// loading screen, never its children.
|
|
87
|
+
const GATE_LAYOUT = `export default function Gate({ children }: any) {
|
|
88
|
+
return typeof window === "undefined" ? <p>Loading</p> : children;
|
|
89
|
+
}
|
|
90
|
+
`;
|
|
91
|
+
|
|
92
|
+
const count = (html: string, re: RegExp) => (html.match(re) ?? []).length;
|
|
93
|
+
// The document up to </head>, without script text (the root layout's script
|
|
94
|
+
// holds a <title> string that is not a tag).
|
|
95
|
+
const headOf = (html: string) =>
|
|
96
|
+
html.slice(0, html.indexOf("</head>")).replace(/<script\b[\s\S]*?<\/script>/g, "");
|
|
97
|
+
|
|
98
|
+
describe("page metadata in the HTML", () => {
|
|
99
|
+
test("reaches <head> when a layout does not render the page, and replaces the root layout's tags", async () => {
|
|
100
|
+
const dir = makeApp({
|
|
101
|
+
"app/layout.tsx": ROOT_LAYOUT,
|
|
102
|
+
"app/gate/layout.tsx": GATE_LAYOUT,
|
|
103
|
+
"app/page.tsx": `export const metadata = {
|
|
104
|
+
title: "Home · Field Sales",
|
|
105
|
+
openGraph: { title: "Home", description: "Today's doors." },
|
|
106
|
+
twitter: { title: "Home" },
|
|
107
|
+
};
|
|
108
|
+
export default function P() { return <p>page body</p>; }
|
|
109
|
+
`,
|
|
110
|
+
});
|
|
111
|
+
const html = await render(dir, ["app/layout", "app/gate/layout"]);
|
|
112
|
+
expect(html).toContain("Loading");
|
|
113
|
+
const head = headOf(html);
|
|
114
|
+
expect(html).not.toContain("page body");
|
|
115
|
+
expect(count(head, /<title>/g)).toBe(1);
|
|
116
|
+
expect(head).toContain("<title>Home · Field Sales</title>");
|
|
117
|
+
expect(count(head, /property="og:title"/g)).toBe(1);
|
|
118
|
+
expect(head).toContain('property="og:title" content="Home"');
|
|
119
|
+
expect(head).toContain('property="og:description" content="Today's doors."');
|
|
120
|
+
expect(count(head, /name="twitter:title"/g)).toBe(1);
|
|
121
|
+
expect(head).toContain('name="twitter:title" content="Home"');
|
|
122
|
+
// Tags the page does not set stay as the layout wrote them.
|
|
123
|
+
expect(head).toContain('name="description" content="The field app."');
|
|
124
|
+
expect(head).toContain('property="og:image" content="https://app.example/og.png"');
|
|
125
|
+
expect(head).toContain('property="og:image:width" content="1200"');
|
|
126
|
+
// Text inside a script is not a tag.
|
|
127
|
+
expect(html).toContain('var t = "<title>kept</title>";');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("a page's og:image replaces the layout's image and its dimensions", async () => {
|
|
131
|
+
const dir = makeApp({
|
|
132
|
+
"app/layout.tsx": ROOT_LAYOUT,
|
|
133
|
+
"app/page.tsx": `export const metadata = { openGraph: { image: "https://app.example/home.png" } };
|
|
134
|
+
export default function P() { return <p>x</p>; }
|
|
135
|
+
`,
|
|
136
|
+
});
|
|
137
|
+
const head = headOf(await render(dir, ["app/layout"]));
|
|
138
|
+
expect(count(head, /property="og:image"/g)).toBe(1);
|
|
139
|
+
expect(head).toContain('property="og:image" content="https://app.example/home.png"');
|
|
140
|
+
expect(head).not.toContain("og:image:width");
|
|
141
|
+
expect(head).toContain("<title>Field Sales</title>");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("a layout's metadata applies to its pages, and the page's values win", async () => {
|
|
145
|
+
const dir = makeApp({
|
|
146
|
+
"app/layout.tsx": `export const metadata = { title: "Site", description: "From the layout.", openGraph: { siteName: "Site" } };
|
|
147
|
+
export default function L({ children }: any) { return <html><head><meta charSet="utf-8" /></head><body>{children}</body></html>; }
|
|
148
|
+
`,
|
|
149
|
+
"app/inner/layout.tsx": `export async function generateMetadata() { return { robots: "noindex" }; }
|
|
150
|
+
export default function L({ children }: any) { return children; }
|
|
151
|
+
`,
|
|
152
|
+
"app/page.tsx": `export const metadata = { title: "Page", openGraph: { title: "Page OG" } };
|
|
153
|
+
export default function P() { return <p>x</p>; }
|
|
154
|
+
`,
|
|
155
|
+
});
|
|
156
|
+
const head = headOf(await render(dir, ["app/layout", "app/inner/layout"]));
|
|
157
|
+
expect(count(head, /<title>/g)).toBe(1);
|
|
158
|
+
expect(head).toContain("<title>Page</title>");
|
|
159
|
+
expect(head).toContain('name="description" content="From the layout."');
|
|
160
|
+
expect(head).toContain('name="robots" content="noindex"');
|
|
161
|
+
expect(head).toContain('property="og:site_name" content="Site"');
|
|
162
|
+
expect(head).toContain('property="og:title" content="Page OG"');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("an offline shell request carries the metadata", async () => {
|
|
166
|
+
const dir = makeApp({
|
|
167
|
+
"app/layout.tsx": ROOT_LAYOUT,
|
|
168
|
+
"app/page.tsx": `export const metadata = { title: "Home · Field Sales" };
|
|
169
|
+
export default function P() { return <p>x</p>; }
|
|
170
|
+
`,
|
|
171
|
+
});
|
|
172
|
+
const head = headOf(await render(dir, ["app/layout"], { "x-pylon-offline-shell": "1" }));
|
|
173
|
+
expect(count(head, /<title>/g)).toBe(1);
|
|
174
|
+
expect(head).toContain("<title>Home · Field Sales</title>");
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("a navigation response carries the page's title, or the layout's when the page has none", async () => {
|
|
178
|
+
const titled = makeApp({
|
|
179
|
+
"app/layout.tsx": ROOT_LAYOUT,
|
|
180
|
+
"app/page.tsx": `export const metadata = { title: "Jobs" };
|
|
181
|
+
export default function P() { return <p>x</p>; }
|
|
182
|
+
`,
|
|
183
|
+
});
|
|
184
|
+
const nav = await render(titled, ["app/layout"], { "x-pylon-nav": "1" });
|
|
185
|
+
expect(count(nav, /<title>/g)).toBe(1);
|
|
186
|
+
expect(nav).toContain("<title>Jobs</title>");
|
|
187
|
+
|
|
188
|
+
const untitled = makeApp({
|
|
189
|
+
"app/layout.tsx": ROOT_LAYOUT,
|
|
190
|
+
"app/page.tsx": `export default function P() { return <p>x</p>; }`,
|
|
191
|
+
});
|
|
192
|
+
const nav2 = await render(untitled, ["app/layout"], { "x-pylon-nav": "1" });
|
|
193
|
+
expect(count(nav2, /<title>/g)).toBe(1);
|
|
194
|
+
expect(nav2).toContain("<title>Field Sales</title>");
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("a not-found boundary's metadata replaces the layout's title", async () => {
|
|
198
|
+
const dir = makeApp({
|
|
199
|
+
"app/layout.tsx": ROOT_LAYOUT,
|
|
200
|
+
"app/not-found.tsx": `export const metadata = { title: "Not found" };
|
|
201
|
+
export default function NF() { return <p>missing</p>; }
|
|
202
|
+
`,
|
|
203
|
+
});
|
|
204
|
+
// The host renders `app/not-found` by name for an unmatched URL.
|
|
205
|
+
const head = headOf(await render(dir, ["app/layout"], {}, "app/not-found"));
|
|
206
|
+
expect(count(head, /<title>/g)).toBe(1);
|
|
207
|
+
expect(head).toContain("<title>Not found</title>");
|
|
208
|
+
|
|
209
|
+
// A page that calls notFound() renders the same boundary.
|
|
210
|
+
writeFileSync(
|
|
211
|
+
join(dir, "app", "page.tsx"),
|
|
212
|
+
`export default function P(props: any) { props.response.notFound(); }`,
|
|
213
|
+
);
|
|
214
|
+
const caught = headOf(await render(dir, ["app/layout"]));
|
|
215
|
+
expect(count(caught, /<title>/g)).toBe(1);
|
|
216
|
+
expect(caught).toContain("<title>Not found</title>");
|
|
217
|
+
expect(caught).toContain('property="og:title" content="Field Sales"');
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
describe("dropReplacedHeadTags", () => {
|
|
222
|
+
test("drops only the tags the metadata sets, by title, name, property and canonical", () => {
|
|
223
|
+
const head =
|
|
224
|
+
'<head><title>Old</title><meta name="description" content="a > b"/>' +
|
|
225
|
+
'<meta property="og:image" content="x"/><meta property="og:image:alt" content="y"/>' +
|
|
226
|
+
'<meta name="viewport" content="w"/><link rel="canonical" href="/old"/>' +
|
|
227
|
+
'<link rel="stylesheet" href="/s.css"/><style>title{}</style>';
|
|
228
|
+
const keys = headTagKeys(
|
|
229
|
+
'<title>New</title><meta data-pylon-meta="" name="description" content="n"/>' +
|
|
230
|
+
'<meta data-pylon-meta="" property="og:image" content="z"/><link data-pylon-meta="" rel="canonical" href="/new"/>',
|
|
231
|
+
);
|
|
232
|
+
expect(dropReplacedHeadTags(head, keys)).toBe(
|
|
233
|
+
'<head><meta name="viewport" content="w"/><link rel="stylesheet" href="/s.css"/><style>title{}</style>',
|
|
234
|
+
);
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
describe("mergeMetadata", () => {
|
|
239
|
+
test("merges openGraph field by field; a new image drops the old image's fields", () => {
|
|
240
|
+
expect(
|
|
241
|
+
mergeMetadata(
|
|
242
|
+
{ title: "A", openGraph: { siteName: "S", image: "/a.png", imageWidth: 1200 }, twitter: { image: "/a.png", imageAlt: "a" } },
|
|
243
|
+
{ title: "B", openGraph: { image: "/b.png" }, twitter: { image: "/b.png" } },
|
|
244
|
+
),
|
|
245
|
+
).toEqual({
|
|
246
|
+
title: "B",
|
|
247
|
+
openGraph: { siteName: "S", image: "/b.png" },
|
|
248
|
+
twitter: { image: "/b.png" },
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
});
|
package/src/ssr-page-tree.ts
CHANGED
|
@@ -3,19 +3,16 @@
|
|
|
3
3
|
// imports this file as `./page-tree`).
|
|
4
4
|
//
|
|
5
5
|
// React derives `useId` values from the tree's shape, including how many
|
|
6
|
-
// children each element has.
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
// holds null. React counts a null slot, so every id inside the page is the
|
|
11
|
-
// same on both sides. Without the slot, every Radix menu, dialog and label id
|
|
12
|
-
// on a page with metadata mismatched on hydration.
|
|
6
|
+
// children each element has. Both sides give the page a fragment with two
|
|
7
|
+
// slots, the first one null. The server once put the page's metadata there;
|
|
8
|
+
// it now writes the metadata into <head> as HTML, outside the tree. The slot
|
|
9
|
+
// stays so the tree keeps the shape that built clients hydrate against.
|
|
13
10
|
//
|
|
14
11
|
// No imports: the client bundler copies this file next to the runtime.
|
|
15
12
|
|
|
16
13
|
type CreateElement = (type: any, props: any, ...children: any[]) => any;
|
|
17
14
|
|
|
18
|
-
/** The page with its metadata slot.
|
|
15
|
+
/** The page with its metadata slot. Both sides pass null for `meta`. */
|
|
19
16
|
export function withMetaSlot(createElement: CreateElement, Fragment: any, meta: unknown, page: unknown): any {
|
|
20
17
|
return createElement(Fragment, null, meta ?? null, page);
|
|
21
18
|
}
|