@pylonsync/functions 0.22.50 → 0.22.59
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/index.d.ts +1 -1
- package/dist/runtime.d.ts +5 -1
- package/dist/ssr-barrel-imports.d.ts +16 -0
- package/dist/ssr-client-bundler.d.ts +44 -0
- package/dist/ssr-client-nav.d.ts +30 -0
- package/dist/ssr-offline.d.ts +34 -5
- package/dist/ssr-precompress.d.ts +16 -0
- package/dist/ssr-runtime.d.ts +12 -8
- package/dist/types.d.ts +53 -0
- package/package.json +2 -2
- package/src/index.ts +3 -0
- package/src/offline-sw.test.ts +36 -0
- package/src/offline-sw.ts +15 -2
- package/src/runtime-files.test.ts +55 -0
- package/src/runtime.ts +17 -4
- package/src/ssr-barrel-imports.test.ts +70 -0
- package/src/ssr-barrel-imports.ts +104 -0
- package/src/ssr-bucket-serverdata.test.ts +167 -0
- package/src/ssr-client-bundler.test.ts +160 -0
- package/src/ssr-client-bundler.ts +315 -73
- package/src/ssr-client-nav-runtime.fixture.ts +273 -0
- package/src/ssr-client-nav-runtime.test.ts +79 -0
- package/src/ssr-client-nav.test.ts +78 -0
- package/src/ssr-client-nav.ts +70 -0
- package/src/ssr-offline-shell.test.ts +7 -2
- package/src/ssr-offline.ts +114 -8
- package/src/ssr-precompress.test.ts +49 -0
- package/src/ssr-precompress.ts +67 -0
- package/src/ssr-runtime.ts +45 -26
- package/src/ssr-serverdata-fn.test.ts +2 -2
- package/src/types.ts +50 -1
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
// The browser half of ssr-client-nav-runtime.test.ts, run in its own Bun
|
|
2
|
+
// process: it stages CLIENT_RUNTIME_SOURCE and its sibling modules exactly as
|
|
3
|
+
// the bundler does (writeClientRuntimeModules), imports the runtime into a
|
|
4
|
+
// happy-dom window, hydrates a page, taps links, goes back and forward, and
|
|
5
|
+
// prints what it saw as JSON. Its own process, because loading react-dom and
|
|
6
|
+
// the staged runtime into the test runner broke later Bun.build calls in the
|
|
7
|
+
// same run (EISDIR reading scheduler/index.js).
|
|
8
|
+
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { Window } from "happy-dom";
|
|
12
|
+
import React from "react";
|
|
13
|
+
import { renderToString } from "react-dom/server";
|
|
14
|
+
import { writeClientRuntimeModules } from "./ssr-client-bundler";
|
|
15
|
+
|
|
16
|
+
const PKG_NODE_MODULES = path.join(import.meta.dir, "..", "node_modules");
|
|
17
|
+
|
|
18
|
+
const MANIFEST = {
|
|
19
|
+
build_id: "t",
|
|
20
|
+
outdir: ".pylon/client-build",
|
|
21
|
+
public_prefix: "/_pylon/build/",
|
|
22
|
+
routes: {
|
|
23
|
+
"app/home/page": { file: "home.js", imports: [], css: [], path: "/" },
|
|
24
|
+
"app/fast/page": { file: "fast.js", imports: [], css: [], path: "/fast", navigation: "client" },
|
|
25
|
+
"app/slow/page": { file: "slow.js", imports: [], css: [], path: "/slow" },
|
|
26
|
+
"app/offline/page": { file: "offline.js", imports: [], css: [], path: "/offline" },
|
|
27
|
+
},
|
|
28
|
+
offline: { routes: ["/offline"] },
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const LINKS = ["/", "/fast", "/slow", "/offline"];
|
|
32
|
+
|
|
33
|
+
function Layout({ children }: { children?: React.ReactNode }) {
|
|
34
|
+
return React.createElement(
|
|
35
|
+
"html",
|
|
36
|
+
null,
|
|
37
|
+
React.createElement("head", null, React.createElement("title", null, "App")),
|
|
38
|
+
React.createElement(
|
|
39
|
+
"body",
|
|
40
|
+
null,
|
|
41
|
+
React.createElement(
|
|
42
|
+
"nav",
|
|
43
|
+
null,
|
|
44
|
+
...LINKS.map((href) =>
|
|
45
|
+
React.createElement(
|
|
46
|
+
"a",
|
|
47
|
+
{ key: href, href, "data-pylon-link": "", id: "to" + (href.slice(1) || "home") },
|
|
48
|
+
href,
|
|
49
|
+
),
|
|
50
|
+
),
|
|
51
|
+
),
|
|
52
|
+
React.createElement("main", { id: "page" }, children),
|
|
53
|
+
),
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const page = (name: string) =>
|
|
58
|
+
function Page(props: any) {
|
|
59
|
+
return React.createElement("h1", null, `${name} ${props.pathname ?? ""}`);
|
|
60
|
+
};
|
|
61
|
+
const Home = page("home");
|
|
62
|
+
const Fast = page("fast");
|
|
63
|
+
const Slow = page("slow");
|
|
64
|
+
const Offline = page("offline");
|
|
65
|
+
|
|
66
|
+
const fetches: string[] = [];
|
|
67
|
+
const scrolls: number[] = [];
|
|
68
|
+
let win: any;
|
|
69
|
+
let rt: any;
|
|
70
|
+
let dir = "";
|
|
71
|
+
|
|
72
|
+
function pylonData(component: string, pathname: string): string {
|
|
73
|
+
const data = {
|
|
74
|
+
component,
|
|
75
|
+
layouts: [],
|
|
76
|
+
props: { pathname, url: pathname, params: {}, searchParams: {} },
|
|
77
|
+
ssrData: {},
|
|
78
|
+
};
|
|
79
|
+
return `<script id="__PYLON_DATA__" type="application/json">${JSON.stringify(data)}</script>`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function settle() {
|
|
83
|
+
for (let i = 0; i < 20; i++) await new Promise((r) => setTimeout(r, 0));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function click(id: string) {
|
|
87
|
+
const el = win.document.getElementById(id);
|
|
88
|
+
el.dispatchEvent(new win.MouseEvent("click", { bubbles: true, cancelable: true, button: 0 }));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// A browser fires popstate after back() and forward(); happy-dom 15 moves
|
|
92
|
+
// the location without it, so the test fires it.
|
|
93
|
+
async function traverse(direction: "back" | "forward") {
|
|
94
|
+
win.history[direction]();
|
|
95
|
+
win.dispatchEvent(new win.PopStateEvent("popstate", { state: win.history.state }));
|
|
96
|
+
await settle();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const heading = () => win.document.querySelector("#page h1")?.textContent;
|
|
100
|
+
const pageFetches = () => fetches.filter((u) => !u.includes("/_pylon/build/manifest.json"));
|
|
101
|
+
|
|
102
|
+
async function setUp() {
|
|
103
|
+
// Inside this package's node_modules, so the runtime resolves react and
|
|
104
|
+
// react-dom the way the test does. A symlinked node_modules in a temp dir
|
|
105
|
+
// left Bun's resolver cache broken for later Bun.build calls in the run.
|
|
106
|
+
const cacheDir = path.join(PKG_NODE_MODULES, ".cache");
|
|
107
|
+
fs.mkdirSync(cacheDir, { recursive: true });
|
|
108
|
+
dir = fs.mkdtempSync(path.join(cacheDir, "pylon-client-nav-"));
|
|
109
|
+
await writeClientRuntimeModules(fs, path, dir);
|
|
110
|
+
// @pylonsync/react is not a dependency of this package: stand in for the
|
|
111
|
+
// two functions the runtime calls at hydration.
|
|
112
|
+
const runtimeFile = path.join(dir, "client-runtime.ts");
|
|
113
|
+
fs.writeFileSync(
|
|
114
|
+
runtimeFile,
|
|
115
|
+
fs
|
|
116
|
+
.readFileSync(runtimeFile, "utf8")
|
|
117
|
+
.replace('from "@pylonsync/react"', 'from "./pylon-react-stub"'),
|
|
118
|
+
);
|
|
119
|
+
fs.writeFileSync(
|
|
120
|
+
path.join(dir, "pylon-react-stub.ts"),
|
|
121
|
+
"export function configureClient() {}\nexport async function registerOfflineWorker() { return null; }\n",
|
|
122
|
+
);
|
|
123
|
+
fs.writeFileSync(path.join(dir, "loading-registry.ts"), "export const LOADING_MODULES = {};\n");
|
|
124
|
+
|
|
125
|
+
win = new Window({ url: "http://localhost/" });
|
|
126
|
+
const g = globalThis as any;
|
|
127
|
+
g.window = win;
|
|
128
|
+
g.document = win.document;
|
|
129
|
+
g.navigator = win.navigator;
|
|
130
|
+
g.location = win.location;
|
|
131
|
+
g.history = win.history;
|
|
132
|
+
g.HTMLElement = win.HTMLElement;
|
|
133
|
+
g.Node = win.Node;
|
|
134
|
+
g.Event = win.Event;
|
|
135
|
+
g.MouseEvent = win.MouseEvent;
|
|
136
|
+
g.DOMParser = win.DOMParser;
|
|
137
|
+
g.MutationObserver = win.MutationObserver;
|
|
138
|
+
g.requestAnimationFrame = (cb: any) => setTimeout(() => cb(Date.now()), 0);
|
|
139
|
+
g.cancelAnimationFrame = (id: any) => clearTimeout(id);
|
|
140
|
+
win.scrollTo = (_x: number, y: number) => scrolls.push(y);
|
|
141
|
+
const fakeFetch = async (input: any) => {
|
|
142
|
+
const url = typeof input === "string" ? input : input.url;
|
|
143
|
+
fetches.push(url);
|
|
144
|
+
if (url === "/_pylon/build/manifest.json") {
|
|
145
|
+
return new Response(JSON.stringify(MANIFEST), { status: 200 });
|
|
146
|
+
}
|
|
147
|
+
const pathname = new URL(url, "http://localhost").pathname;
|
|
148
|
+
if (pathname === "/slow") {
|
|
149
|
+
return new Response(
|
|
150
|
+
`<!DOCTYPE html><html><head><title>Slow</title></head><body>${pylonData("app/slow/page", pathname)}</body></html>`,
|
|
151
|
+
{ status: 200 },
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
return new Response("not found", { status: 404 });
|
|
155
|
+
};
|
|
156
|
+
g.fetch = fakeFetch;
|
|
157
|
+
win.fetch = fakeFetch;
|
|
158
|
+
|
|
159
|
+
// The server-rendered home page, with its hydration data.
|
|
160
|
+
const props = { pathname: "/", url: "/", params: {}, searchParams: {} };
|
|
161
|
+
const html = renderToString(React.createElement(Layout, null, React.createElement(Home, props)));
|
|
162
|
+
const parsed = new win.DOMParser().parseFromString(
|
|
163
|
+
"<!DOCTYPE html>" + html.replace("</body>", pylonData("app/home/page", "/") + "</body>"),
|
|
164
|
+
"text/html",
|
|
165
|
+
);
|
|
166
|
+
// Remove, then append: happy-dom 20's replaceChild inserts the new root
|
|
167
|
+
// before it removes the old one, which a document refuses.
|
|
168
|
+
const root = win.document.importNode(parsed.documentElement, true);
|
|
169
|
+
win.document.documentElement.remove();
|
|
170
|
+
win.document.appendChild(root);
|
|
171
|
+
|
|
172
|
+
rt = await import(path.join(dir, "client-runtime.ts"));
|
|
173
|
+
rt.hydrate("app/home/page", Home, [Layout]);
|
|
174
|
+
await settle();
|
|
175
|
+
// The other routes' entries, as their chunks register them.
|
|
176
|
+
rt.hydrate("app/fast/page", Fast, [Layout], { title: "Fast" });
|
|
177
|
+
rt.hydrate("app/slow/page", Slow, [Layout]);
|
|
178
|
+
rt.hydrate("app/offline/page", Offline, [Layout]);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
interface Observation {
|
|
185
|
+
heading: string | null;
|
|
186
|
+
pathname: string;
|
|
187
|
+
pageFetches: string[];
|
|
188
|
+
historyLength: number;
|
|
189
|
+
scrolls: number[];
|
|
190
|
+
title: string;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function observe(): Observation {
|
|
194
|
+
const seen = {
|
|
195
|
+
heading: heading() ?? null,
|
|
196
|
+
pathname: win.location.pathname,
|
|
197
|
+
pageFetches: pageFetches(),
|
|
198
|
+
historyLength: win.history.length,
|
|
199
|
+
scrolls: [...scrolls],
|
|
200
|
+
title: win.document.title,
|
|
201
|
+
};
|
|
202
|
+
fetches.length = 0;
|
|
203
|
+
scrolls.length = 0;
|
|
204
|
+
return seen;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const report: Record<string, any> = {};
|
|
208
|
+
|
|
209
|
+
const preloads = () =>
|
|
210
|
+
[...win.document.querySelectorAll('link[rel="modulepreload"]')].map((l: any) => l.getAttribute("href"));
|
|
211
|
+
|
|
212
|
+
/** Prefetch timing: chunks for a link on screen wait for idle and skip
|
|
213
|
+
* Save-Data; intent fetches at once. */
|
|
214
|
+
async function prefetchTiming() {
|
|
215
|
+
const pylon = win.__pylon;
|
|
216
|
+
const before = preloads().length;
|
|
217
|
+
// Idle callbacks run only when the test says so.
|
|
218
|
+
const idle: Array<() => void> = [];
|
|
219
|
+
win.requestIdleCallback = (fn: () => void) => {
|
|
220
|
+
idle.push(fn);
|
|
221
|
+
return idle.length;
|
|
222
|
+
};
|
|
223
|
+
await pylon.prefetch("/fast");
|
|
224
|
+
await settle();
|
|
225
|
+
const sightBeforeIdle = preloads().length - before;
|
|
226
|
+
for (const fn of idle.splice(0)) fn();
|
|
227
|
+
await settle();
|
|
228
|
+
const sightAfterIdle = preloads().length - before;
|
|
229
|
+
|
|
230
|
+
// Save-Data: nothing on sight, even when idle.
|
|
231
|
+
Object.defineProperty(win.navigator, "connection", {
|
|
232
|
+
value: { saveData: true, effectiveType: "4g" },
|
|
233
|
+
configurable: true,
|
|
234
|
+
});
|
|
235
|
+
const beforeSave = preloads().length;
|
|
236
|
+
await pylon.prefetch("/offline");
|
|
237
|
+
await settle();
|
|
238
|
+
for (const fn of idle.splice(0)) fn();
|
|
239
|
+
await settle();
|
|
240
|
+
const saveDataSight = preloads().length - beforeSave;
|
|
241
|
+
|
|
242
|
+
// Intent fetches at once, Save-Data or not.
|
|
243
|
+
await pylon.prefetch("/offline", { document: true });
|
|
244
|
+
await settle();
|
|
245
|
+
const intent = preloads().length - beforeSave;
|
|
246
|
+
return { sightBeforeIdle, sightAfterIdle, saveDataSight, intent };
|
|
247
|
+
}
|
|
248
|
+
try {
|
|
249
|
+
await setUp();
|
|
250
|
+
report.hydrated = observe();
|
|
251
|
+
click("tofast");
|
|
252
|
+
await settle();
|
|
253
|
+
report.fast = observe();
|
|
254
|
+
click("tooffline");
|
|
255
|
+
await settle();
|
|
256
|
+
report.offline = observe();
|
|
257
|
+
click("toslow");
|
|
258
|
+
await settle();
|
|
259
|
+
report.slow = observe();
|
|
260
|
+
await traverse("back");
|
|
261
|
+
report.back1 = observe();
|
|
262
|
+
await traverse("back");
|
|
263
|
+
report.back2 = observe();
|
|
264
|
+
await traverse("forward");
|
|
265
|
+
report.forward1 = observe();
|
|
266
|
+
await traverse("forward");
|
|
267
|
+
report.forward2 = observe();
|
|
268
|
+
report.prefetch = await prefetchTiming();
|
|
269
|
+
} finally {
|
|
270
|
+
if (dir) fs.rmSync(dir, { recursive: true, force: true });
|
|
271
|
+
}
|
|
272
|
+
console.log("REPORT " + JSON.stringify(report));
|
|
273
|
+
process.exit(0);
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Instant client navigation, run in the real client runtime: the fixture
|
|
2
|
+
// (ssr-client-nav-runtime.fixture.ts) hydrates a page in happy-dom and taps
|
|
3
|
+
// through it, in its own process. This checks what it saw:
|
|
4
|
+
//
|
|
5
|
+
// - /fast (the page exports navigation = "client") and /offline (an offline
|
|
6
|
+
// route) render with no page fetch;
|
|
7
|
+
// - /slow fetches its page, as before;
|
|
8
|
+
// - back and forward add no history entries and fetch only /slow;
|
|
9
|
+
// - every navigation resets the scroll, like a fetched one.
|
|
10
|
+
|
|
11
|
+
import { beforeAll, describe, expect, test } from "bun:test";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
|
|
14
|
+
let report: Record<string, any> = {};
|
|
15
|
+
|
|
16
|
+
beforeAll(async () => {
|
|
17
|
+
const proc = Bun.spawn([process.execPath, path.join(import.meta.dir, "ssr-client-nav-runtime.fixture.ts")], {
|
|
18
|
+
cwd: path.join(import.meta.dir, ".."),
|
|
19
|
+
stdout: "pipe",
|
|
20
|
+
stderr: "pipe",
|
|
21
|
+
});
|
|
22
|
+
const [out, err] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
|
|
23
|
+
await proc.exited;
|
|
24
|
+
const line = out.split("\n").find((l) => l.startsWith("REPORT "));
|
|
25
|
+
if (!line) throw new Error(`fixture printed no report:\n${out}\n${err}`);
|
|
26
|
+
report = JSON.parse(line.slice("REPORT ".length));
|
|
27
|
+
}, 60_000);
|
|
28
|
+
|
|
29
|
+
describe("instant client navigation", () => {
|
|
30
|
+
test("the page hydrated", () => {
|
|
31
|
+
expect(report.hydrated.heading).toBe("home /");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('a click on a route with navigation = "client" makes no page fetch', () => {
|
|
35
|
+
const { fast, hydrated } = report;
|
|
36
|
+
expect(fast.heading).toBe("fast /fast");
|
|
37
|
+
expect(fast.pathname).toBe("/fast");
|
|
38
|
+
expect(fast.pageFetches).toEqual([]);
|
|
39
|
+
expect(fast.historyLength).toBe(hydrated.historyLength + 1);
|
|
40
|
+
expect(fast.scrolls).toEqual([0]);
|
|
41
|
+
// The title comes from the page's static metadata.
|
|
42
|
+
expect(fast.title).toBe("Fast");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("a click on an offline route makes no page fetch", () => {
|
|
46
|
+
expect(report.offline.heading).toBe("offline /offline");
|
|
47
|
+
expect(report.offline.pageFetches).toEqual([]);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("a route that did not opt in still fetches its page", () => {
|
|
51
|
+
const { slow } = report;
|
|
52
|
+
expect(slow.heading).toBe("slow /slow");
|
|
53
|
+
expect(slow.pageFetches).toEqual(["/slow"]);
|
|
54
|
+
expect(slow.pathname).toBe("/slow");
|
|
55
|
+
expect(slow.scrolls).toEqual([0]);
|
|
56
|
+
expect(slow.title).toBe("Slow");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("back and forward re-render without new history entries, fetching only /slow", () => {
|
|
60
|
+
const { slow, back1, back2, forward1, forward2 } = report;
|
|
61
|
+
expect([back1.pathname, back1.heading]).toEqual(["/offline", "offline /offline"]);
|
|
62
|
+
expect([back2.pathname, back2.heading]).toEqual(["/fast", "fast /fast"]);
|
|
63
|
+
expect([forward1.pathname, forward1.heading]).toEqual(["/offline", "offline /offline"]);
|
|
64
|
+
expect([forward2.pathname, forward2.heading]).toEqual(["/slow", "slow /slow"]);
|
|
65
|
+
expect([back1, back2, forward1].flatMap((o) => o.pageFetches)).toEqual([]);
|
|
66
|
+
expect(forward2.pageFetches).toEqual(["/slow"]);
|
|
67
|
+
for (const o of [back1, back2, forward1, forward2]) {
|
|
68
|
+
expect(o.historyLength).toBe(slow.historyLength);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("prefetch on sight waits for idle and skips Save-Data; intent fetches at once", () => {
|
|
73
|
+
const { sightBeforeIdle, sightAfterIdle, saveDataSight, intent } = report.prefetch;
|
|
74
|
+
expect(sightBeforeIdle).toBe(0);
|
|
75
|
+
expect(sightAfterIdle).toBeGreaterThan(0);
|
|
76
|
+
expect(saveDataSight).toBe(0);
|
|
77
|
+
expect(intent).toBeGreaterThan(0);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// Which routes navigate with no page fetch: a page that exports
|
|
2
|
+
// `navigation = "client"`, or an offline route.
|
|
3
|
+
|
|
4
|
+
import { describe, expect, test } from "bun:test";
|
|
5
|
+
import { exportsStaticMetadata } from "./ssr-client-bundler";
|
|
6
|
+
import { clientRouteFor, declaresClientNavigation, isOfflinePath } from "./ssr-client-nav";
|
|
7
|
+
import { matchRoute } from "./ssr-route-match";
|
|
8
|
+
|
|
9
|
+
const MANIFEST = {
|
|
10
|
+
routes: {
|
|
11
|
+
"app/leads/page": { path: "/leads", navigation: "client" },
|
|
12
|
+
"app/leads/[id]/page": { path: "/leads/[id]" },
|
|
13
|
+
"app/map/page": { path: "/map" },
|
|
14
|
+
"app/reports/page": { path: "/reports" },
|
|
15
|
+
},
|
|
16
|
+
offline: { routes: ["/map", "/leads/[id]"] },
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
describe("declaresClientNavigation", () => {
|
|
20
|
+
test("finds the export in its usual spellings", () => {
|
|
21
|
+
for (const src of [
|
|
22
|
+
`export const navigation = "client";`,
|
|
23
|
+
`export const navigation = 'client'`,
|
|
24
|
+
"export const navigation = `client`;",
|
|
25
|
+
`export const navigation: "client" | "server" = "client";`,
|
|
26
|
+
`export const navigation="client" as const;`,
|
|
27
|
+
]) {
|
|
28
|
+
expect(declaresClientNavigation(src)).toBe(true);
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("ignores other values and other names", () => {
|
|
33
|
+
for (const src of [
|
|
34
|
+
`export const navigation = "server";`,
|
|
35
|
+
`const navigation = "client";`,
|
|
36
|
+
`export const navigationMode = "client";`,
|
|
37
|
+
`// navigation = "client"`,
|
|
38
|
+
"",
|
|
39
|
+
]) {
|
|
40
|
+
expect(declaresClientNavigation(src)).toBe(false);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("clientRouteFor", () => {
|
|
46
|
+
test("a page that opted in", () => {
|
|
47
|
+
expect(clientRouteFor(MANIFEST, "/leads", matchRoute)?.component).toBe("app/leads/page");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("an offline route, static or dynamic", () => {
|
|
51
|
+
expect(clientRouteFor(MANIFEST, "/map", matchRoute)?.component).toBe("app/map/page");
|
|
52
|
+
const lead = clientRouteFor(MANIFEST, "/leads/42", matchRoute);
|
|
53
|
+
expect(lead?.component).toBe("app/leads/[id]/page");
|
|
54
|
+
expect(lead?.params).toEqual({ id: "42" });
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("anything else fetches its page", () => {
|
|
58
|
+
expect(clientRouteFor(MANIFEST, "/reports", matchRoute)).toBeNull();
|
|
59
|
+
expect(clientRouteFor(MANIFEST, "/nowhere", matchRoute)).toBeNull();
|
|
60
|
+
expect(clientRouteFor(null, "/leads", matchRoute)).toBeNull();
|
|
61
|
+
const { offline: _o, ...noOffline } = MANIFEST;
|
|
62
|
+
expect(clientRouteFor(noOffline, "/map", matchRoute)).toBeNull();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("isOfflinePath matches route patterns", () => {
|
|
66
|
+
expect(isOfflinePath(MANIFEST, "/leads/7", matchRoute)).toBe(true);
|
|
67
|
+
expect(isOfflinePath(MANIFEST, "/leads", matchRoute)).toBe(false);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe("exportsStaticMetadata", () => {
|
|
72
|
+
test("finds a static metadata export", () => {
|
|
73
|
+
expect(exportsStaticMetadata(`export const metadata = { title: "Leads" };`)).toBe(true);
|
|
74
|
+
expect(exportsStaticMetadata(`const metadata = {}; export { metadata };`)).toBe(true);
|
|
75
|
+
expect(exportsStaticMetadata(`export async function generateMetadata() {}`)).toBe(false);
|
|
76
|
+
expect(exportsStaticMetadata(`const metadata = {};`)).toBe(false);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// Instant client navigation: which routes the client runtime renders with no
|
|
2
|
+
// page fetch.
|
|
3
|
+
//
|
|
4
|
+
// A client navigation normally fetches the destination's SSR payload for its
|
|
5
|
+
// props and serverData, then renders. A route that reads neither does not
|
|
6
|
+
// need that round trip: its data comes from the local replica (db.useQuery),
|
|
7
|
+
// and the props it may read are the identity-free ones the last page had.
|
|
8
|
+
// Such a route opts in one of two ways:
|
|
9
|
+
//
|
|
10
|
+
// - its page declares `export const navigation = "client"`, or
|
|
11
|
+
// - its URL is one of `buildManifest({ offline: { routes } })`, which
|
|
12
|
+
// already follow the same render rule (see ssr-offline.ts).
|
|
13
|
+
//
|
|
14
|
+
// The client runtime imports this module as `./client-nav` (the bundler
|
|
15
|
+
// stages this file next to it, like ./route-match), and the bundler uses
|
|
16
|
+
// `declaresClientNavigation` to mark pages in the bundle manifest. No imports:
|
|
17
|
+
// the staged copy must stand alone.
|
|
18
|
+
|
|
19
|
+
/** A route match, as `matchRoute` in ssr-route-match.ts returns it. */
|
|
20
|
+
export interface ClientRouteMatch {
|
|
21
|
+
component: string;
|
|
22
|
+
params: Record<string, string | string[]>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The bundle manifest fields this module reads. */
|
|
26
|
+
export interface ClientNavManifest {
|
|
27
|
+
routes: Record<string, { path?: string; navigation?: string }>;
|
|
28
|
+
offline?: { routes?: string[] };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
type MatchRoute = (manifest: any, pathname: string) => ClientRouteMatch | null;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Whether page source declares `export const navigation = "client"` (single,
|
|
35
|
+
* double or backtick quotes, with an optional type annotation or `as const`).
|
|
36
|
+
* A static scan: the bundler reads the page file without running it.
|
|
37
|
+
*/
|
|
38
|
+
export function declaresClientNavigation(source: string): boolean {
|
|
39
|
+
return /export\s+const\s+navigation\s*(?::[^=]+)?=\s*(["'`])client\1/.test(source);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Whether `pathname` is one of the app's offline routes. */
|
|
43
|
+
export function isOfflinePath(
|
|
44
|
+
manifest: ClientNavManifest | null,
|
|
45
|
+
pathname: string,
|
|
46
|
+
matchRoute: MatchRoute,
|
|
47
|
+
): boolean {
|
|
48
|
+
const patterns = manifest?.offline?.routes;
|
|
49
|
+
if (!patterns || patterns.length === 0) return false;
|
|
50
|
+
const routes: Record<string, { path: string }> = {};
|
|
51
|
+
for (const pattern of patterns) routes[pattern] = { path: pattern };
|
|
52
|
+
return matchRoute({ routes }, pathname) !== null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The page to render from the client for `pathname`, or null when the
|
|
57
|
+
* navigation must fetch the page (no match, or the page did not opt in).
|
|
58
|
+
*/
|
|
59
|
+
export function clientRouteFor(
|
|
60
|
+
manifest: ClientNavManifest | null,
|
|
61
|
+
pathname: string,
|
|
62
|
+
matchRoute: MatchRoute,
|
|
63
|
+
): ClientRouteMatch | null {
|
|
64
|
+
if (!manifest) return null;
|
|
65
|
+
const matched = matchRoute(manifest, pathname);
|
|
66
|
+
if (!matched) return null;
|
|
67
|
+
if (manifest.routes[matched.component]?.navigation === "client") return matched;
|
|
68
|
+
if (isOfflinePath(manifest, pathname, matchRoute)) return matched;
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
@@ -59,6 +59,7 @@ const BUNDLE_MANIFEST = {
|
|
|
59
59
|
startUrl: "/",
|
|
60
60
|
routes: ["/"],
|
|
61
61
|
version: "v1",
|
|
62
|
+
builtAt: "2026-10-22T12:00:00.000Z",
|
|
62
63
|
},
|
|
63
64
|
};
|
|
64
65
|
|
|
@@ -204,10 +205,14 @@ test("every page of an offline app gets the install tags and the register flag",
|
|
|
204
205
|
expect(html).toContain('<meta name="theme-color" content="#0b2545">');
|
|
205
206
|
expect(html).toContain('<meta name="apple-mobile-web-app-title" content="Sales">');
|
|
206
207
|
expect(html).toContain('<link rel="apple-touch-icon" href="/icons/512.png">');
|
|
207
|
-
expect(data.offline).toEqual({
|
|
208
|
+
expect(data.offline).toEqual({
|
|
209
|
+
dev: false,
|
|
210
|
+
version: "v1",
|
|
211
|
+
builtAt: "2026-10-22T12:00:00.000Z",
|
|
212
|
+
});
|
|
208
213
|
|
|
209
214
|
const dev = await render(dir, { env: { PYLON_DEV_MODE: "1" } });
|
|
210
|
-
expect(dev.data.offline).
|
|
215
|
+
expect(dev.data.offline.dev).toBe(true);
|
|
211
216
|
});
|
|
212
217
|
|
|
213
218
|
test("an app without offline gets neither", async () => {
|