@pylonsync/functions 0.22.59 → 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.
@@ -42,7 +42,7 @@ import {
42
42
  type ManifestBuildConfig,
43
43
  } from "./build-compat";
44
44
  import { serverBundle } from "./server-bundle";
45
- import { declaresClientNavigation } from "./ssr-client-nav";
45
+ import { declaresClientNavigation, stripComments } from "./ssr-client-nav";
46
46
  import { precompressBuild } from "./ssr-precompress";
47
47
  import { parseNamespaceBarrel, rewriteBarrelImports } from "./ssr-barrel-imports";
48
48
  import { buildOfflineWorker, readManifestOffline, type BundleOffline } from "./ssr-offline";
@@ -362,6 +362,9 @@ export const CLIENT_RUNTIME_SOURCE = `// Generated by Pylon SSR (Phase 2 client
362
362
  import { createElement, Fragment, Suspense } from "react";
363
363
  import { hydrateRoot } from "react-dom/client";
364
364
  import { configureClient, registerOfflineWorker } from "@pylonsync/react";
365
+ // A namespace import for what an older @pylonsync/react may not export, so
366
+ // an app on it still builds (the missing export is undefined).
367
+ import * as pylonReact from "@pylonsync/react";
365
368
  import { createPylonBoundary, nearestBoundaryComponent } from "./client-boundary";
366
369
  import { LOADING_MODULES } from "./loading-registry";
367
370
  import { createNavPayloadCache } from "./nav-cache";
@@ -380,6 +383,9 @@ let currentComponent = null;
380
383
  let pendingNav = null;
381
384
  let manifestPromise = null;
382
385
  const prefetchedChunks = new Set();
386
+ // The build this page runs (the service worker version from
387
+ // __PYLON_DATA__), or null in an app without \`buildManifest({ offline })\`.
388
+ let pageBuild = null;
383
389
 
384
390
  function buildTree(Page, Layouts, props, loadingFor) {
385
391
  let tree = createElement(Page, props);
@@ -610,17 +616,64 @@ function readPylonData() {
610
616
  }
611
617
  }
612
618
 
619
+ // The build manifest of this page's build. A manifest of another build is
620
+ // not used: its route files cannot run next to this page's (two copies of
621
+ // React, a layout from each build). That happens when an older service
622
+ // worker answers from its cache, or when the server already runs a newer
623
+ // build. Navigation then moves the page to the server's build
624
+ // (moveToLatestBuild). A failed fetch is not kept, so the next call asks
625
+ // again.
613
626
  async function loadManifest() {
614
627
  if (!manifestPromise) {
615
- manifestPromise = fetch("/_pylon/build/manifest.json", {
628
+ const pending = fetch("/_pylon/build/manifest.json", {
616
629
  credentials: "same-origin",
617
630
  })
618
631
  .then((r) => (r.ok ? r.json() : null))
619
- .catch(() => null);
632
+ .catch(() => null)
633
+ .then((manifest) => {
634
+ if (!manifest && manifestPromise === pending) manifestPromise = null;
635
+ const build = manifest && manifest.offline && manifest.offline.version;
636
+ if (build && pageBuild && build !== pageBuild) {
637
+ console.warn(
638
+ "[pylon ssr] the build manifest is for build " + build +
639
+ ", and this page runs build " + pageBuild + ". Not using it.",
640
+ );
641
+ return null;
642
+ }
643
+ return manifest;
644
+ });
645
+ manifestPromise = pending;
620
646
  }
621
647
  return manifestPromise;
622
648
  }
623
649
 
650
+ // Move this page to the server's build when it runs another one (see
651
+ // moveToLatestBuild in @pylonsync/react): the outbox is sent first, the
652
+ // waiting service worker takes over, and \`url\` loads once. Resolves with
653
+ // what happened: "moving", "current", "offline", "pending-writes",
654
+ // "blocked", or "inert" (no offline build, or an older @pylonsync/react).
655
+ function moveToLatestBuild(options) {
656
+ const move = pylonReact.moveToLatestBuild;
657
+ if (typeof move !== "function") return Promise.resolve("inert");
658
+ return move(options).catch((e) => {
659
+ console.warn("[pylon ssr] could not move to the new build:", e);
660
+ return "inert";
661
+ });
662
+ }
663
+
664
+ // A route entry failed to load, or the server answered with a page of
665
+ // another build. True when navigate() is done: the page is loading the
666
+ // server's build, or the device is offline and the page keeps its screen.
667
+ // False when the caller falls back to a full page load (the server runs
668
+ // this build, or the page already moved and is still on it).
669
+ async function movedForFailedRoute(href) {
670
+ const result = await moveToLatestBuild({ url: href, recheck: true });
671
+ if (result === "offline") {
672
+ console.warn("[pylon ssr] offline: " + href + " cannot open now.");
673
+ }
674
+ return result === "moving" || result === "offline";
675
+ }
676
+
624
677
  function preloadChunks(routeInfo) {
625
678
  if (!routeInfo) return;
626
679
  const prefix = routeInfo.public_prefix || "/_pylon/build/";
@@ -782,6 +835,7 @@ export function hydrate(component, Page, Layouts, metadata) {
782
835
  );
783
836
  return;
784
837
  }
838
+ pageBuild = (data.offline && data.offline.version) || null;
785
839
  setNavParams(data);
786
840
  currentPageProps = withClientProps(data);
787
841
  currentComponent = data.component;
@@ -936,6 +990,15 @@ async function navigate(href, opts) {
936
990
  // timer that fires late can never paint a skeleton over real content.
937
991
  const navState = { settled: false };
938
992
 
993
+ // ---- New build -----------------------------------------------------------
994
+ // The last check found a newer build on the server: open the link in it
995
+ // instead of rendering it with this page's build, whose files the deploy
996
+ // removed. Not while writes wait to be sent; a failure below moves then.
997
+ if (activeRoot) {
998
+ const early = await moveToLatestBuild({ url: href, skipIfPendingWrites: true });
999
+ if (early === "moving" || myEpoch !== navEpoch) return;
1000
+ }
1001
+
939
1002
  // ---- Instant navigation --------------------------------------------------
940
1003
  // A route whose page exports \`navigation = "client"\`, or whose URL is an
941
1004
  // offline route, renders from the client with no page fetch: its data comes
@@ -950,8 +1013,11 @@ async function navigate(href, opts) {
950
1013
  try {
951
1014
  route = await loadRouteEntry(matched.component);
952
1015
  } catch (e) {
953
- // Fall through to the fetch below, which reloads the page when the
954
- // entry cannot load.
1016
+ // After a deploy: move to the new build. Offline: keep the screen.
1017
+ // Otherwise fall through to the fetch below, which reloads the page
1018
+ // when the entry cannot load.
1019
+ if (myEpoch !== navEpoch) return;
1020
+ if (await movedForFailedRoute(href)) return;
955
1021
  console.warn("[pylon ssr] client navigation fell back to a page fetch:", e);
956
1022
  }
957
1023
  if (myEpoch !== navEpoch) return;
@@ -1090,15 +1156,29 @@ async function navigate(href, opts) {
1090
1156
  fullLoad();
1091
1157
  return;
1092
1158
  }
1093
- let route;
1094
- try {
1095
- route = await loadRouteEntry(data.component);
1096
- } catch (e) {
1097
- console.warn("[pylon ssr] nav fallback (entry load failed):", e);
1159
+ // The server runs another build: this page's route files cannot render
1160
+ // its pages.
1161
+ const otherBuild =
1162
+ pageBuild && data.offline && data.offline.version && data.offline.version !== pageBuild;
1163
+ let route = null;
1164
+ if (!otherBuild) {
1165
+ try {
1166
+ route = await loadRouteEntry(data.component);
1167
+ } catch (e) {
1168
+ console.warn("[pylon ssr] nav fallback (entry load failed):", e);
1169
+ }
1170
+ }
1171
+ if (myEpoch !== navEpoch) return;
1172
+ if (!route) {
1173
+ if (await movedForFailedRoute(href)) {
1174
+ navState.settled = true;
1175
+ if (placeholderTimer) clearTimeout(placeholderTimer);
1176
+ return;
1177
+ }
1178
+ if (myEpoch !== navEpoch) return;
1098
1179
  fullLoad();
1099
1180
  return;
1100
1181
  }
1101
- if (myEpoch !== navEpoch) return;
1102
1182
  navState.settled = true;
1103
1183
  if (placeholderTimer) clearTimeout(placeholderTimer);
1104
1184
  document.title = doc.title || document.title;
@@ -1375,9 +1455,10 @@ function readComponentSource(fs: any, path: any, cwd: string, component: string)
1375
1455
  * it to the runtime, which takes the title from it on a navigation with no
1376
1456
  * page fetch. A static scan, so a page without one imports nothing extra. */
1377
1457
  export function exportsStaticMetadata(source: string): boolean {
1458
+ const code = stripComments(source);
1378
1459
  return (
1379
- /\bexport\s+(?:const|let|var)\s+metadata\b/.test(source) ||
1380
- /\bexport\s*\{[^}]*\bmetadata\b[^}]*\}/.test(source)
1460
+ /\bexport\s+(?:const|let|var)\s+metadata\b/.test(code) ||
1461
+ /\bexport\s*\{[^}]*\bmetadata\b[^}]*\}/.test(code)
1381
1462
  );
1382
1463
  }
1383
1464
 
@@ -2021,36 +2102,9 @@ export async function _doBuildInner(
2021
2102
  // against; the build itself is fine.
2022
2103
  }
2023
2104
 
2024
- // Scan a built JS file for STATIC `import` literals pointing at
2025
- // `./chunks/<file>.js` and return them resolved to outdir-relative paths.
2026
- // Bun's minified output uses simple double quotes for module specifiers,
2027
- // so a `matchAll` covers both `import X from "Y"` and bare `import "Y"`.
2028
- //
2029
- // DYNAMIC `import("Y")` is deliberately NOT matched. This list becomes the
2030
- // route's modulepreload set, and a chunk the author deferred behind
2031
- // `import()` must not be downloaded eagerly anyway — that would split the
2032
- // code out of the entry (so it isn't parsed on load) while still shipping
2033
- // every byte, which is the expensive half of the cost with none of the
2034
- // saving. The trailing `(` is what distinguishes the two forms.
2035
- function scanChunkImports(jsAbsPath: string): string[] {
2036
- let src: string;
2037
- try {
2038
- src = fs.readFileSync(jsAbsPath, "utf8");
2039
- } catch {
2040
- return [];
2041
- }
2042
- const found = new Set<string>();
2043
- const matches = src.matchAll(/(?:from\s*|import\s*)["']([^"']+)["']/g);
2044
- for (const m of matches) {
2045
- const spec = m[1];
2046
- if (spec.startsWith("./chunks/") || spec.startsWith("chunks/")) {
2047
- const base = path.basename(spec);
2048
- const hit = chunksByBasename.get(base);
2049
- if (hit) found.add(hit.relPath);
2050
- }
2051
- }
2052
- return Array.from(found);
2053
- }
2105
+ const chunkRelPaths = new Set(
2106
+ Array.from(chunksByBasename.values()).map((c) => c.relPath),
2107
+ );
2054
2108
 
2055
2109
  const manifest: PylonBundleManifest = {
2056
2110
  build_id: makeBuildId(),
@@ -2064,22 +2118,9 @@ export async function _doBuildInner(
2064
2118
  const stem = `client-entry-${slug}`;
2065
2119
  const entry = entriesByStem.get(stem);
2066
2120
  if (!entry) continue;
2067
- // Walk transitively in case Bun emits chunks that reference
2068
- // other chunks (rare in the single-level splitting we use,
2069
- // but free to compute).
2070
- const seen = new Set<string>();
2071
- const queue: string[] = scanChunkImports(entry.absPath);
2072
- for (const q of queue) seen.add(q);
2073
- while (queue.length > 0) {
2074
- const relChunk = queue.shift()!;
2075
- const absChunk = path.join(outdir, relChunk);
2076
- for (const nested of scanChunkImports(absChunk)) {
2077
- if (!seen.has(nested)) {
2078
- seen.add(nested);
2079
- queue.push(nested);
2080
- }
2081
- }
2082
- }
2121
+ // The SSR head modulepreloads every file in the static graph, so the
2122
+ // browser fetches them in parallel instead of level by level.
2123
+ const seen = staticImportGraph(fs, path, outdir, entry.absPath, chunkRelPaths);
2083
2124
  manifest.routes[r.component] = {
2084
2125
  file: entry.relPath,
2085
2126
  imports: Array.from(seen),
@@ -2372,6 +2413,65 @@ const ASSET_RE = /\.(wasm|png|jpe?g|gif|webp|avif|ico|svg|woff2?|ttf|otf|mp3|mp4
2372
2413
  * the absolute URL. A stylesheet's `url(...)` is left to Bun: CSS cannot
2373
2414
  * import a JavaScript module.
2374
2415
  */
2416
+ /**
2417
+ * The outdir-relative chunks a built entry reaches through STATIC imports,
2418
+ * nearest first. Specifiers are relative to the importing file: an entry
2419
+ * imports `./chunks/<file>.js`, a chunk imports its siblings as
2420
+ * `./<file>.js`. Following only the first form dropped every chunk-to-chunk
2421
+ * import, so those chunks were missing from the route's modulepreload set
2422
+ * and loaded in a second wave (3 of 28 files on the Veritas home page).
2423
+ *
2424
+ * DYNAMIC `import("Y")` is deliberately not followed. This list becomes the
2425
+ * route's modulepreload set, and a chunk the author deferred behind
2426
+ * `import()` must not be downloaded eagerly: that would ship every byte the
2427
+ * split was meant to defer. The trailing `(` tells the forms apart; Bun's
2428
+ * minified output quotes specifiers plainly, so one pattern covers
2429
+ * `import X from "Y"`, `import{a}from"Y"` and bare `import "Y"`.
2430
+ */
2431
+ export function staticImportGraph(
2432
+ fs: any,
2433
+ path: any,
2434
+ outdir: string,
2435
+ entryAbsPath: string,
2436
+ chunkRelPaths: Set<string>,
2437
+ ): Set<string> {
2438
+ const toUrlPath = (p: string) => p.replace(/\\/g, "/");
2439
+ const scan = (jsAbsPath: string): string[] => {
2440
+ let src: string;
2441
+ try {
2442
+ src = fs.readFileSync(jsAbsPath, "utf8");
2443
+ } catch {
2444
+ return [];
2445
+ }
2446
+ const found = new Set<string>();
2447
+ const dir = path.dirname(jsAbsPath);
2448
+ for (const m of src.matchAll(/(?:from\s*|import\s*)["']([^"']+)["']/g)) {
2449
+ const spec = m[1]!;
2450
+ if (!spec.startsWith("./") && !spec.startsWith("../") && !spec.startsWith("chunks/")) {
2451
+ continue;
2452
+ }
2453
+ const rel = toUrlPath(
2454
+ path.relative(outdir, path.resolve(spec.startsWith("chunks/") ? outdir : dir, spec)),
2455
+ );
2456
+ if (chunkRelPaths.has(rel)) found.add(rel);
2457
+ }
2458
+ return Array.from(found);
2459
+ };
2460
+ const seen = new Set<string>();
2461
+ const queue = scan(entryAbsPath);
2462
+ for (const q of queue) seen.add(q);
2463
+ while (queue.length > 0) {
2464
+ const rel = queue.shift()!;
2465
+ for (const nested of scan(path.join(outdir, rel))) {
2466
+ if (!seen.has(nested)) {
2467
+ seen.add(nested);
2468
+ queue.push(nested);
2469
+ }
2470
+ }
2471
+ }
2472
+ return seen;
2473
+ }
2474
+
2375
2475
  /**
2376
2476
  * Rewrite named imports from namespace barrels (`radix-ui`) in app source
2377
2477
  * into imports of the re-exported packages, so a route bundles only the
@@ -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
- const LINKS = ["/", "/fast", "/slow", "/offline"];
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
- // two functions the runtime calls at hydration.
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
- .replace('from "@pylonsync/react"', 'from "./pylon-react-stub"'),
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() {}\nexport async function registerOfflineWorker() { return null; }\n",
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
- return new Response(JSON.stringify(MANIFEST), { status: 200 });
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
- try {
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
- beforeAll(async () => {
17
- const proc = Bun.spawn([process.execPath, path.join(import.meta.dir, "ssr-client-nav-runtime.fixture.ts")], {
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
- report = JSON.parse(line.slice("REPORT ".length));
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
+ });