@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.
- package/dist/offline-sw.d.ts +2 -0
- package/dist/ssr-client-bundler.d.ts +28 -1
- package/dist/ssr-client-nav.d.ts +7 -0
- package/dist/ssr-page-tree.d.ts +1 -1
- package/dist/ssr-route-assets.d.ts +20 -1
- package/dist/ssr-runtime.d.ts +29 -9
- package/dist/types.d.ts +18 -0
- package/package.json +2 -2
- package/src/offline-sw.test.ts +36 -1
- package/src/offline-sw.ts +26 -1
- package/src/runtime-db.test.ts +12 -0
- package/src/runtime.ts +22 -0
- package/src/ssr-client-bundler.ts +159 -59
- package/src/ssr-client-nav-runtime.fixture.ts +168 -8
- package/src/ssr-client-nav-runtime.test.ts +68 -4
- package/src/ssr-client-nav.test.ts +7 -0
- package/src/ssr-client-nav.ts +42 -1
- package/src/ssr-metadata.test.ts +251 -0
- package/src/ssr-offline-shell.test.ts +26 -0
- package/src/ssr-page-tree.ts +5 -8
- package/src/ssr-route-assets.ts +24 -3
- package/src/ssr-runtime.ts +217 -42
- package/src/ssr-static-graph.test.ts +35 -0
- package/src/types.ts +20 -0
|
@@ -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
|
-
|
|
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
|
-
//
|
|
954
|
-
//
|
|
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
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
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(
|
|
1380
|
-
/\bexport\s*\{[^}]*\bmetadata\b[^}]*\}/.test(
|
|
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
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
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
|
-
//
|
|
2068
|
-
//
|
|
2069
|
-
|
|
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
|
-
|
|
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
|
+
});
|