@pylonsync/functions 0.4.9 → 0.4.11

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.
@@ -1,15 +1,19 @@
1
+ /** A per-directory module resolved by walking up from a route: the two error
2
+ * boundaries, plus `loading` (the pending-navigation skeleton). */
3
+ export type BoundaryFile = "not-found" | "error" | "loading";
1
4
  /**
2
- * Resolve the nearest boundary module (`<dir>/not-found` or `<dir>/error`)
3
- * for a route by walking its component path up to the app root, returning the
4
- * first manifest route key that exists. Nearest ancestor wins — the same model
5
- * the server's `findBoundary` uses, but driven off the client build manifest's
6
- * route keys so the client runtime needs no extra server round-trip.
5
+ * Resolve the nearest `<dir>/<fileName>` module for a route by walking its
6
+ * component path up to the app root, returning the first key that exists.
7
+ * Nearest ancestor wins — the same model the server's `findBoundary` uses, but
8
+ * driven off client-side keys so the runtime needs no extra server round-trip.
7
9
  *
8
10
  * `component` is a cwd-relative path with "/" separators and no extension
9
- * (e.g. "web/app/dashboard/orgs/[slug]/page"); `routeKeys` is
10
- * `Object.keys(manifest.routes)`.
11
+ * (e.g. "web/app/dashboard/orgs/[slug]/page"). `keys` is the set to resolve
12
+ * against: `Object.keys(manifest.routes)` for not-found/error, which ship as
13
+ * route entries, or the loading registry's keys for `loading`, which ships in
14
+ * the shared chunk instead.
11
15
  */
12
- export declare function nearestBoundaryComponent(component: string, fileName: "not-found" | "error", routeKeys: Iterable<string>): string | null;
16
+ export declare function nearestBoundaryComponent(component: string, fileName: BoundaryFile, routeKeys: Iterable<string>): string | null;
13
17
  /** Runtime internals the boundary needs, injected so the module stays
14
18
  * browser-safe AND unit-testable. */
15
19
  export interface BoundaryDeps {
@@ -24,6 +24,30 @@ interface BundleClientMessage {
24
24
  * import into a loud build failure that names the offending importer.
25
25
  */
26
26
  export declare function assertNotServerOnly(specifier: string, importer: string): void;
27
+ /**
28
+ * Every route-level `loading` module under the app dir, as project-relative
29
+ * component paths (`app/dashboard/loading`), sorted for a stable build.
30
+ *
31
+ * Unlike not-found / error, these get NO client entry of their own — see
32
+ * `generateLoadingRegistry` for why.
33
+ */
34
+ export declare function discoverLoadingModules(fs: any, path: any, cwd: string, appDirRel: string): string[];
35
+ /**
36
+ * The module the client runtime imports as `./loading-registry`: every
37
+ * loading.tsx in the app, keyed by component path so the runtime can walk up
38
+ * from a destination route to its nearest one — the same nearest-ancestor rule
39
+ * the server's findBoundary applies.
40
+ *
41
+ * Statically imported, so these land in the SHARED chunk rather than shipping
42
+ * as route entries the way not-found / error do. A loading state exists to
43
+ * acknowledge a click that is already waiting on the network; fetching a chunk
44
+ * to render it would put it behind the very delay it covers.
45
+ *
46
+ * Route groups need no special handling: a page under `app/(dash)/settings`
47
+ * resolves against `app/(dash)/loading` by path, exactly as it does on the
48
+ * server.
49
+ */
50
+ export declare function generateLoadingRegistry(components: string[]): string;
27
51
  /**
28
52
  * Manifest schema. One entry per route, indexed by the same
29
53
  * project-relative component path the SSR side passes through.
@@ -0,0 +1,26 @@
1
+ export interface NavPayloadCacheDeps {
2
+ /** Fetch a page, resolving to its HTML or null when it shouldn't be reused. */
3
+ fetchPage: (target: string) => Promise<string | null>;
4
+ /** Injected so tests drive expiry without sleeping. */
5
+ now: () => number;
6
+ /** Long enough to cover hover-to-click, short enough that nobody reads a
7
+ * page rendered from data this old. */
8
+ ttlMs?: number;
9
+ /** Cap on retained payloads; the oldest is evicted first. Pages are whole
10
+ * HTML documents, so this bounds memory on a link-dense page. */
11
+ max?: number;
12
+ }
13
+ export interface NavPayloadCache {
14
+ /** Warm `target`, unless a fresh entry is already present or in flight. */
15
+ prefetch(target: string): void;
16
+ /** Hand over `target`'s payload, or null. Single-use: a prefetch
17
+ * accelerates the NEXT click, and holding it past that would serve
18
+ * navigations from an increasingly stale render. */
19
+ take(target: string): Promise<string | null> | null;
20
+ /** Drop everything — called when a navigation commits, since entries were
21
+ * rendered against the page the user just left. */
22
+ clear(): void;
23
+ /** Retained entry count (tests + diagnostics). */
24
+ size(): number;
25
+ }
26
+ export declare function createNavPayloadCache(deps: NavPayloadCacheDeps): NavPayloadCache;
@@ -4,10 +4,14 @@ export interface RouteMatch {
4
4
  /** Decoded dynamic params captured from the path (e.g. `{ slug: "shoe-x" }`). */
5
5
  params: Record<string, string>;
6
6
  }
7
- /** The slice of the build manifest this matcher needs. */
7
+ /** The slice of the build manifest this module needs. */
8
8
  export interface MatchableManifest {
9
9
  routes: Record<string, {
10
10
  path?: string;
11
+ /** The route's own entry chunk (outdir-relative). */
12
+ file?: string;
13
+ /** Shared chunks the browser needs before that entry runs. */
14
+ imports?: string[];
11
15
  }>;
12
16
  }
13
17
  /**
@@ -18,3 +22,31 @@ export interface MatchableManifest {
18
22
  * so `/orders/new` beats `/orders/[id]` beats `/[...all]`.
19
23
  */
20
24
  export declare function matchRoute(manifest: MatchableManifest | null | undefined, pathname: string): RouteMatch | null;
25
+ /** What `<Link prefetch>` should warm for a destination href. */
26
+ export interface PrefetchTargets {
27
+ /** The destination route's own entry chunk, or "" when no page route matches. */
28
+ file: string;
29
+ /** Chunks to warm alongside it. */
30
+ imports: string[];
31
+ }
32
+ /**
33
+ * The chunks a click on `pathname` will need before anything can render: the
34
+ * destination route's entry, plus the chunks (React, the client runtime,
35
+ * common layouts) it pulls in.
36
+ *
37
+ * Warming the page payload alone leaves the entry chunk to be fetched after
38
+ * the click, and the route cannot render until it lands — so the prefetch
39
+ * covers only the half that was already fast.
40
+ *
41
+ * Size is deliberately NOT a factor. Build output is content-hashed and served
42
+ * immutable, so warming a heavy route costs its bytes once per browser and
43
+ * makes every later visit to it instant; skipping it would trade a permanent
44
+ * win for a one-time saving, and would skip exactly the routes slowest to
45
+ * fetch on demand. The warm is deferred to the load event, so those bytes
46
+ * never compete with the current page's own render.
47
+ *
48
+ * An href matching no page route (an API path, a route this build doesn't
49
+ * serve) yields the union of every route's chunks: no destination is known,
50
+ * but those are needed by any navigation.
51
+ */
52
+ export declare function prefetchTargets(manifest: MatchableManifest | null | undefined, pathname: string): PrefetchTargets;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pylonsync/functions",
3
- "version": "0.4.9",
3
+ "version": "0.4.11",
4
4
  "description": "TypeScript function runtime for pylon — defines server-side queries, mutations, and actions.",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -21,20 +21,25 @@
21
21
 
22
22
  import { Component, createElement, useEffect, useState } from "react";
23
23
 
24
+ /** A per-directory module resolved by walking up from a route: the two error
25
+ * boundaries, plus `loading` (the pending-navigation skeleton). */
26
+ export type BoundaryFile = "not-found" | "error" | "loading";
27
+
24
28
  /**
25
- * Resolve the nearest boundary module (`<dir>/not-found` or `<dir>/error`)
26
- * for a route by walking its component path up to the app root, returning the
27
- * first manifest route key that exists. Nearest ancestor wins — the same model
28
- * the server's `findBoundary` uses, but driven off the client build manifest's
29
- * route keys so the client runtime needs no extra server round-trip.
29
+ * Resolve the nearest `<dir>/<fileName>` module for a route by walking its
30
+ * component path up to the app root, returning the first key that exists.
31
+ * Nearest ancestor wins — the same model the server's `findBoundary` uses, but
32
+ * driven off client-side keys so the runtime needs no extra server round-trip.
30
33
  *
31
34
  * `component` is a cwd-relative path with "/" separators and no extension
32
- * (e.g. "web/app/dashboard/orgs/[slug]/page"); `routeKeys` is
33
- * `Object.keys(manifest.routes)`.
35
+ * (e.g. "web/app/dashboard/orgs/[slug]/page"). `keys` is the set to resolve
36
+ * against: `Object.keys(manifest.routes)` for not-found/error, which ship as
37
+ * route entries, or the loading registry's keys for `loading`, which ships in
38
+ * the shared chunk instead.
34
39
  */
35
40
  export function nearestBoundaryComponent(
36
41
  component: string,
37
- fileName: "not-found" | "error",
42
+ fileName: BoundaryFile,
38
43
  routeKeys: Iterable<string>,
39
44
  ): string | null {
40
45
  const keys = routeKeys instanceof Set ? routeKeys : new Set(routeKeys);
@@ -25,6 +25,8 @@ import {
25
25
  buildClientBundle,
26
26
  buildTailwind,
27
27
  assertNotServerOnly,
28
+ discoverLoadingModules,
29
+ generateLoadingRegistry,
28
30
  type PylonBundleManifest,
29
31
  } from "./ssr-client-bundler";
30
32
  import { nearestBoundaryComponent } from "./ssr-client-boundary";
@@ -488,3 +490,163 @@ describe("server-only guard (secrets can't leak into the client bundle)", () =>
488
490
  fs.rmSync(dir, { recursive: true, force: true });
489
491
  });
490
492
  });
493
+
494
+ // ---------------------------------------------------------------------------
495
+ // loading.tsx during client-side navigation.
496
+ //
497
+ // Regression: a route-level loading.tsx was honored ONLY by the server's
498
+ // streaming render. The client bundler emitted entries for not-found and error
499
+ // but not loading, so no loading module existed on the client at all and a
500
+ // pending navigation left the previous page fully painted until the
501
+ // destination was ready. Polling the DOM every 15ms across a sidebar click
502
+ // never saw the skeleton; the same route hard-loaded streamed it fine.
503
+ // ---------------------------------------------------------------------------
504
+
505
+ const LOADING_BODY = `
506
+ import React from "react";
507
+ export default function Loading() {
508
+ return <div aria-busy="true" data-skeleton="pylon-pending">Loading…</div>;
509
+ }
510
+ `;
511
+
512
+ describe("discoverLoadingModules", () => {
513
+ test("finds loading modules at every depth, sorted", () => {
514
+ tempDir = makeFixture(
515
+ {
516
+ "page.tsx": PAGE_BODY("Home"),
517
+ "loading.tsx": LOADING_BODY,
518
+ "dashboard/page.tsx": PAGE_BODY("Dash"),
519
+ "dashboard/events/[id]/page.tsx": PAGE_BODY("Event"),
520
+ "dashboard/events/[id]/loading.tsx": LOADING_BODY,
521
+ },
522
+ { "layout.tsx": LAYOUT_BODY },
523
+ );
524
+ expect(discoverLoadingModules(fs, path, tempDir, "app")).toEqual([
525
+ "app/dashboard/events/[id]/loading",
526
+ "app/loading",
527
+ ]);
528
+ });
529
+
530
+ test("finds one inside a route group", () => {
531
+ // Chrome commonly lives in a (group) layout; its loading sibling has to be
532
+ // discoverable by the same path walk the server uses.
533
+ tempDir = makeFixture(
534
+ {
535
+ "(dash)/settings/page.tsx": PAGE_BODY("Settings"),
536
+ "(dash)/loading.tsx": LOADING_BODY,
537
+ },
538
+ { "layout.tsx": LAYOUT_BODY },
539
+ );
540
+ expect(discoverLoadingModules(fs, path, tempDir, "app")).toEqual([
541
+ "app/(dash)/loading",
542
+ ]);
543
+ });
544
+
545
+ test("an app with no loading.tsx yields an empty list, not an error", () => {
546
+ tempDir = makeFixture(
547
+ { "page.tsx": PAGE_BODY("Home") },
548
+ { "layout.tsx": LAYOUT_BODY },
549
+ );
550
+ expect(discoverLoadingModules(fs, path, tempDir, "app")).toEqual([]);
551
+ });
552
+ });
553
+
554
+ describe("generateLoadingRegistry", () => {
555
+ test("keys each module by component path so the nearest-ancestor walk works", () => {
556
+ const src = generateLoadingRegistry([
557
+ "app/loading",
558
+ "app/dashboard/loading",
559
+ ]);
560
+ expect(src).toContain(`import M0 from "../app/loading";`);
561
+ expect(src).toContain(`import M1 from "../app/dashboard/loading";`);
562
+ expect(src).toContain(`"app/loading": M0,`);
563
+ expect(src).toContain(`"app/dashboard/loading": M1,`);
564
+ });
565
+
566
+ test("emits a valid empty registry when the app has none", () => {
567
+ // The runtime imports LOADING_MODULES unconditionally — an app without a
568
+ // loading.tsx must still produce a module that parses and exports it.
569
+ const src = generateLoadingRegistry([]);
570
+ expect(src).toContain("export const LOADING_MODULES = {");
571
+ expect(src).not.toContain("import M0");
572
+ });
573
+ });
574
+
575
+ describe("nearestBoundaryComponent resolves loading.tsx", () => {
576
+ const loadingKeys = new Set([
577
+ "web/app/loading",
578
+ "web/app/dashboard/events/[id]/loading",
579
+ ]);
580
+
581
+ test("a sibling route picks up the nearest ancestor's skeleton", () => {
582
+ // Every tab under events/[id] shares that one loading.tsx.
583
+ expect(
584
+ nearestBoundaryComponent(
585
+ "web/app/dashboard/events/[id]/speakers/page",
586
+ "loading",
587
+ loadingKeys,
588
+ ),
589
+ ).toBe("web/app/dashboard/events/[id]/loading");
590
+ });
591
+
592
+ test("falls back to the root skeleton outside that subtree", () => {
593
+ expect(
594
+ nearestBoundaryComponent("web/app/settings/page", "loading", loadingKeys),
595
+ ).toBe("web/app/loading");
596
+ });
597
+
598
+ test("returns null when the app ships none", () => {
599
+ expect(
600
+ nearestBoundaryComponent("web/app/page", "loading", new Set()),
601
+ ).toBeNull();
602
+ });
603
+ });
604
+
605
+ describe("loading.tsx is wired into the client build", () => {
606
+ test("ships in the SHARED chunk — a skeleton can't wait on its own fetch", async () => {
607
+ tempDir = makeFixture(
608
+ {
609
+ "page.tsx": PAGE_BODY("Home"),
610
+ "dashboard/page.tsx": PAGE_BODY("Dash"),
611
+ "dashboard/loading.tsx": LOADING_BODY,
612
+ },
613
+ { "layout.tsx": LAYOUT_BODY },
614
+ );
615
+ originalCwd = process.cwd();
616
+ process.chdir(tempDir);
617
+
618
+ const { manifestPath, outdir } = await buildClientBundle();
619
+ const manifest = JSON.parse(
620
+ fs.readFileSync(manifestPath, "utf8"),
621
+ ) as PylonBundleManifest;
622
+
623
+ // Not a route entry: it has no URL of its own, and a loading state that
624
+ // costs a chunk fetch sits behind the very delay it exists to cover.
625
+ expect(manifest.routes["app/dashboard/loading"]).toBeUndefined();
626
+
627
+ const sharedChunks = fs
628
+ .readdirSync(path.join(outdir, "chunks"))
629
+ .map((n) => fs.readFileSync(path.join(outdir, "chunks", n), "utf8"))
630
+ .join("\n");
631
+ // The skeleton's own markup, and the registry key the runtime walks to.
632
+ expect(sharedChunks).toContain("pylon-pending");
633
+ expect(sharedChunks).toContain("app/dashboard/loading");
634
+ });
635
+
636
+ test("an app with no loading.tsx still builds and navigates", async () => {
637
+ // The runtime's import of ./loading-registry is unconditional, so the
638
+ // staged module must exist even with nothing to put in it.
639
+ tempDir = makeFixture(
640
+ { "page.tsx": PAGE_BODY("Home"), "about/page.tsx": PAGE_BODY("About") },
641
+ { "layout.tsx": LAYOUT_BODY },
642
+ );
643
+ originalCwd = process.cwd();
644
+ process.chdir(tempDir);
645
+
646
+ const { manifestPath } = await buildClientBundle();
647
+ expect(fs.existsSync(manifestPath)).toBe(true);
648
+ expect(
649
+ fs.existsSync(path.join(tempDir, ".pylon", "loading-registry.ts")),
650
+ ).toBe(true);
651
+ });
652
+ });
@@ -230,6 +230,77 @@ function discoverRoutes(
230
230
  }));
231
231
  }
232
232
 
233
+ /**
234
+ * Every route-level `loading` module under the app dir, as project-relative
235
+ * component paths (`app/dashboard/loading`), sorted for a stable build.
236
+ *
237
+ * Unlike not-found / error, these get NO client entry of their own — see
238
+ * `generateLoadingRegistry` for why.
239
+ */
240
+ export function discoverLoadingModules(
241
+ fs: any,
242
+ path: any,
243
+ cwd: string,
244
+ appDirRel: string,
245
+ ): string[] {
246
+ const found: string[] = [];
247
+ function walk(dir: string) {
248
+ let entries: any[];
249
+ try {
250
+ entries = fs.readdirSync(dir, { withFileTypes: true });
251
+ } catch {
252
+ return;
253
+ }
254
+ const here = ["loading.tsx", "loading.ts", "loading.jsx", "loading.js"]
255
+ .map((n: string) => path.join(dir, n))
256
+ .find((p: string) => fs.existsSync(p));
257
+ if (here) {
258
+ found.push(
259
+ path.relative(cwd, here).replace(/\.(tsx?|jsx?)$/, "").replace(/\\/g, "/"),
260
+ );
261
+ }
262
+ for (const e of entries) {
263
+ if (!e.isDirectory()) continue;
264
+ if (e.name.startsWith(".") || e.name === "node_modules") continue;
265
+ walk(path.join(dir, e.name));
266
+ }
267
+ }
268
+ walk(path.join(cwd, appDirRel));
269
+ found.sort();
270
+ return found;
271
+ }
272
+
273
+ /**
274
+ * The module the client runtime imports as `./loading-registry`: every
275
+ * loading.tsx in the app, keyed by component path so the runtime can walk up
276
+ * from a destination route to its nearest one — the same nearest-ancestor rule
277
+ * the server's findBoundary applies.
278
+ *
279
+ * Statically imported, so these land in the SHARED chunk rather than shipping
280
+ * as route entries the way not-found / error do. A loading state exists to
281
+ * acknowledge a click that is already waiting on the network; fetching a chunk
282
+ * to render it would put it behind the very delay it covers.
283
+ *
284
+ * Route groups need no special handling: a page under `app/(dash)/settings`
285
+ * resolves against `app/(dash)/loading` by path, exactly as it does on the
286
+ * server.
287
+ */
288
+ export function generateLoadingRegistry(components: string[]): string {
289
+ const imports = components
290
+ .map((c, i) => `import M${i} from "${cwd_to_import(c)}";`)
291
+ .join("\n");
292
+ const entries = components
293
+ .map((c, i) => ` ${JSON.stringify(c)}: M${i},`)
294
+ .join("\n");
295
+ return `// Generated by Pylon SSR (route-level loading.tsx registry).
296
+ // DO NOT EDIT — overwritten on every pylon dev / build.
297
+ ${imports ? "\n" + imports + "\n" : ""}
298
+ export const LOADING_MODULES = {
299
+ ${entries}
300
+ };
301
+ `;
302
+ }
303
+
233
304
  /**
234
305
  * The shared hydration dispatcher + router. ONE module, imported
235
306
  * by every per-route entry. Bun's splitter sees N entries reach
@@ -259,11 +330,16 @@ const CLIENT_RUNTIME_SOURCE = `// Generated by Pylon SSR (Phase 2 client runtime
259
330
 
260
331
  import { createElement } from "react";
261
332
  import { hydrateRoot } from "react-dom/client";
262
- import { createPylonBoundary } from "./client-boundary";
263
- import { matchRoute } from "./route-match";
333
+ import { createPylonBoundary, nearestBoundaryComponent } from "./client-boundary";
334
+ import { LOADING_MODULES } from "./loading-registry";
335
+ import { createNavPayloadCache } from "./nav-cache";
336
+ import { matchRoute, prefetchTargets } from "./route-match";
264
337
 
265
338
  const routeCache = Object.create(null);
266
339
  let activeRoot = null;
340
+ // Component path of the route currently mounted (the manifest key). Its cached
341
+ // layout chain is what a pending-navigation skeleton renders inside.
342
+ let currentComponent = null;
267
343
  // Destination of an in-flight client navigation. Read by hydrateRoot's
268
344
  // onUncaughtError so a re-render that throws mid-nav degrades to a full page
269
345
  // load instead of a white screen. Null when no nav is in flight.
@@ -509,32 +585,57 @@ function preloadChunks(routeInfo) {
509
585
  }
510
586
  }
511
587
 
512
- async function prefetch(href) {
513
- // HTML prefetch — primes the SSR response cache.
588
+ // Run fn once the page itself has finished loading. modulepreload fetches at
589
+ // high priority, and a sidebar of links warms on first paint (Link's
590
+ // IntersectionObserver), so warming a dozen route chunks inline would compete
591
+ // with the current page's own scripts and data — buying a faster second
592
+ // navigation with a slower first render.
593
+ function whenLoaded(fn) {
594
+ if (document.readyState === "complete") {
595
+ fn();
596
+ return;
597
+ }
598
+ window.addEventListener("load", fn, { once: true });
599
+ }
600
+
601
+ // Payloads warmed by a hover, consumed by the click that follows. See
602
+ // ./nav-cache for why this is in memory rather than an HTTP-level prefetch.
603
+ const navPayloads = createNavPayloadCache({
604
+ now: () => Date.now(),
605
+ fetchPage: (target) =>
606
+ fetch(target, {
607
+ credentials: "same-origin",
608
+ headers: { Accept: "text/html" },
609
+ }).then((res) => {
610
+ // A redirect means the URL navigate() would commit isn't the one that
611
+ // answered; let the real navigation resolve that itself.
612
+ if (!res.ok || res.redirected) return null;
613
+ return res.text();
614
+ }),
615
+ });
616
+
617
+ async function prefetch(href, opts) {
514
618
  const url = new URL(href, location.href);
515
619
  if (url.origin !== location.origin) return;
516
- if (!document.querySelector('link[rel="prefetch"][href="' + url.pathname + '"]')) {
517
- const html = document.createElement("link");
518
- html.rel = "prefetch";
519
- html.as = "document";
520
- html.href = url.pathname + url.search;
521
- document.head.appendChild(html);
620
+ // The page payload, but ONLY on a real intent signal (hover / touch). It
621
+ // costs a full SSR render, so warming a screenful of links on sight would
622
+ // spend a dozen renders per page load to save one.
623
+ if (opts && opts.document) {
624
+ navPayloads.prefetch(url.pathname + url.search);
522
625
  }
523
- // Chunk prefetch — peek at the manifest, but we don't know the
524
- // component path from the href without server help. v1: rely on
525
- // the shared chunk already being cached + the SSR head emitting
526
- // the right preload tags after the user lands. So all prefetch
527
- // does today is HTML — chunk dedup happens via the manifest.
528
626
  const manifest = await loadManifest();
529
- if (manifest) {
530
- // Pre-warm all routes' shared chunks (one chunk in practice).
531
- const shared = new Set();
532
- for (const r of Object.values(manifest.routes || {})) {
533
- for (const i of r.imports || []) shared.add(i);
534
- }
535
- const info = { public_prefix: manifest.public_prefix, file: "", imports: Array.from(shared) };
536
- preloadChunks(info);
537
- }
627
+ if (!manifest) return;
628
+ // Chunk prefetch. The page payload above is the half that was already
629
+ // cheap: nothing can render until the destination's own entry chunk lands,
630
+ // so warming only the HTML leaves a full round-trip on the click.
631
+ const targets = prefetchTargets(manifest, url.pathname);
632
+ whenLoaded(() =>
633
+ preloadChunks({
634
+ public_prefix: manifest.public_prefix,
635
+ file: targets.file,
636
+ imports: targets.imports,
637
+ }),
638
+ );
538
639
  }
539
640
 
540
641
  async function loadRouteEntry(component) {
@@ -557,6 +658,11 @@ async function loadRouteEntry(component) {
557
658
  export function hydrate(component, Page, Layouts) {
558
659
  // Always cache the route for nav.
559
660
  routeCache[component] = { Page, Layouts };
661
+ // Start the manifest fetch NOW rather than on first use. Nothing can resolve
662
+ // a route without it — the first <Link> reaching the viewport warms its
663
+ // chunks only after it lands — so leaving it lazy put a round trip in front
664
+ // of all prefetching. In flight from here, it resolves during hydration.
665
+ void loadManifest();
560
666
  const data = readPylonData();
561
667
  // First hydrate: the entry's component MATCHES the SSR'd page.
562
668
  // Establish the root + install the click + popstate handlers
@@ -570,6 +676,7 @@ export function hydrate(component, Page, Layouts) {
570
676
  }
571
677
  setNavParams(data);
572
678
  currentPageProps = withClientProps(data);
679
+ currentComponent = data.component;
573
680
  const tree = withBoundary(
574
681
  buildTree(Page, Layouts, currentPageProps),
575
682
  data.component,
@@ -628,6 +735,48 @@ function syncHeadMeta(doc) {
628
735
  }
629
736
  }
630
737
 
738
+ // How long a navigation may run before the destination's loading.tsx takes
739
+ // over the page area. Below this, the transition finishes on its own and
740
+ // swapping to a skeleton would read as a flicker rather than as feedback.
741
+ const PENDING_PLACEHOLDER_MS = 100;
742
+
743
+ // Paint the destination's nearest loading.tsx in place of the current page,
744
+ // so a click that can't complete immediately is still acknowledged. Returns
745
+ // whether anything was rendered — apps without a loading.tsx keep the old
746
+ // behavior (previous page stays put until the new one is ready).
747
+ //
748
+ // Rendered inside the CURRENTLY MOUNTED layout chain, not the destination's:
749
+ // the destination's layouts live in the chunk this skeleton exists to cover,
750
+ // so waiting for them would defeat the point. Sibling routes (the common case
751
+ // for a nav that's slow enough to reach here) share that chain anyway, and
752
+ // React keeps those layouts mounted across the swap — only the page area
753
+ // changes.
754
+ //
755
+ // Props are the current page's, verbatim. Layouts re-render with the data they
756
+ // already had, so one that read serverData or params can't suspend on a
757
+ // never-resolving handle or crash on a cache miss halfway through a
758
+ // transition. A skeleton has no data of its own to show.
759
+ function renderPendingPlaceholder(pathname, manifest, myEpoch) {
760
+ if (!activeRoot || !currentComponent) return false;
761
+ const matched = matchRoute(manifest, pathname);
762
+ if (!matched) return false;
763
+ const key = nearestBoundaryComponent(
764
+ matched.component,
765
+ "loading",
766
+ Object.keys(LOADING_MODULES),
767
+ );
768
+ const Loading = key ? LOADING_MODULES[key] : null;
769
+ if (!Loading) return false;
770
+ const current = routeCache[currentComponent];
771
+ const tree = withBoundary(
772
+ buildTree(Loading, (current && current.Layouts) || [], currentPageProps),
773
+ currentComponent,
774
+ myEpoch,
775
+ );
776
+ activeRoot.render(tree);
777
+ return true;
778
+ }
779
+
631
780
  async function navigate(href, opts) {
632
781
  const push = !opts || opts.push !== false;
633
782
  const url = new URL(href, location.href);
@@ -643,6 +792,14 @@ async function navigate(href, opts) {
643
792
  const myEpoch = ++navEpoch;
644
793
  currentSeed = opts && opts.seed != null ? opts.seed : null;
645
794
 
795
+ // Flipped the moment this nav commits or gives up, so a pending-placeholder
796
+ // timer that fires late can never paint a skeleton over real content.
797
+ const navState = { settled: false };
798
+ const fullLoad = () => {
799
+ navState.settled = true;
800
+ window.location.href = href;
801
+ };
802
+
646
803
  // ---- Optimistic first paint --------------------------------------------
647
804
  // With a seed AND a client-resolvable route, render the destination NOW —
648
805
  // before the SSR fetch — with a pending serverData, so the page shows its
@@ -673,6 +830,7 @@ async function navigate(href, opts) {
673
830
  myEpoch,
674
831
  );
675
832
  pendingNav = target;
833
+ currentComponent = matched.component;
676
834
  activeRoot.render(tree);
677
835
  if (opts && opts.replace) {
678
836
  history.replaceState({ component: matched.component }, "", target);
@@ -688,34 +846,62 @@ async function navigate(href, opts) {
688
846
  }
689
847
  }
690
848
 
849
+ // ---- Pending-state placeholder -----------------------------------------
850
+ // Nothing below paints until the SSR payload AND the destination's chunk are
851
+ // both in hand, which on a cold route is long enough for the click to look
852
+ // ignored. Hand the page area to the destination's loading.tsx once the nav
853
+ // outlives the flicker threshold. Skipped when a seed already painted the
854
+ // real destination — that's strictly better than a skeleton.
855
+ let placeholderTimer = null;
856
+ if (!didOptimistic && activeRoot) {
857
+ placeholderTimer = setTimeout(() => {
858
+ placeholderTimer = null;
859
+ // loadManifest resolves instantly once warm (Link prefetch primes it on
860
+ // first paint); the settled check covers the cold first navigation,
861
+ // where the fetch can win the race with it.
862
+ loadManifest().then((manifest) => {
863
+ if (navState.settled || myEpoch !== navEpoch) return;
864
+ if (renderPendingPlaceholder(url.pathname, manifest, myEpoch)) {
865
+ window.scrollTo(0, 0);
866
+ }
867
+ });
868
+ }, PENDING_PLACEHOLDER_MS);
869
+ }
870
+
691
871
  // ---- Real fetch + render -----------------------------------------------
692
872
  let html;
693
873
  try {
694
- const res = await fetch(target, {
695
- credentials: "same-origin",
696
- headers: { Accept: "text/html" },
697
- });
698
- if (!res.ok) {
699
- window.location.href = href;
700
- return;
874
+ // A hover-prefetch usually has this in hand, or in flight — either way the
875
+ // click joins it instead of opening a second request for the same page.
876
+ const prefetched = navPayloads.take(target);
877
+ html = prefetched ? await prefetched : null;
878
+ if (html == null) {
879
+ const res = await fetch(target, {
880
+ credentials: "same-origin",
881
+ headers: { Accept: "text/html" },
882
+ });
883
+ if (!res.ok) {
884
+ fullLoad();
885
+ return;
886
+ }
887
+ html = await res.text();
701
888
  }
702
- html = await res.text();
703
889
  } catch {
704
- window.location.href = href;
890
+ fullLoad();
705
891
  return;
706
892
  }
707
893
  if (myEpoch !== navEpoch) return;
708
894
  const doc = new DOMParser().parseFromString(html, "text/html");
709
895
  const dataEl = doc.getElementById("__PYLON_DATA__");
710
896
  if (!dataEl) {
711
- window.location.href = href;
897
+ fullLoad();
712
898
  return;
713
899
  }
714
900
  let data;
715
901
  try {
716
902
  data = JSON.parse(dataEl.textContent || "{}");
717
903
  } catch {
718
- window.location.href = href;
904
+ fullLoad();
719
905
  return;
720
906
  }
721
907
  let route;
@@ -723,10 +909,12 @@ async function navigate(href, opts) {
723
909
  route = await loadRouteEntry(data.component);
724
910
  } catch (e) {
725
911
  console.warn("[pylon ssr] nav fallback (entry load failed):", e);
726
- window.location.href = href;
912
+ fullLoad();
727
913
  return;
728
914
  }
729
915
  if (myEpoch !== navEpoch) return;
916
+ navState.settled = true;
917
+ if (placeholderTimer) clearTimeout(placeholderTimer);
730
918
  document.title = doc.title || document.title;
731
919
  syncHeadMeta(doc);
732
920
  setNavParams(data);
@@ -749,6 +937,11 @@ async function navigate(href, opts) {
749
937
  // (instead of leaving the URL changed but the page unswapped). Cleared on the
750
938
  // next macrotask once the commit has settled with no error.
751
939
  pendingNav = target;
940
+ currentComponent = data.component;
941
+ // Drop every other prefetched payload once a navigation commits: they were
942
+ // rendered against the previous page's state, and whatever the user does
943
+ // here can invalidate them. Hovering on the new page re-warms in one request.
944
+ navPayloads.clear();
752
945
  activeRoot.render(tree);
753
946
  setTimeout(() => {
754
947
  if (pendingNav === target) pendingNav = null;
@@ -1244,6 +1437,28 @@ async function _doBuildInner(
1244
1437
  "utf8",
1245
1438
  );
1246
1439
 
1440
+ // Same pattern for the prefetch payload cache — the runtime imports it as
1441
+ // `./nav-cache`; its source of truth is ssr-nav-cache.ts, unit-tested
1442
+ // directly (expiry, eviction, single-use).
1443
+ fs.writeFileSync(
1444
+ path.join(stageDir, "nav-cache.ts"),
1445
+ fs.readFileSync(path.join(here, "ssr-nav-cache.ts"), "utf8"),
1446
+ "utf8",
1447
+ );
1448
+
1449
+ // The app's loading.tsx modules, gathered into one module the runtime
1450
+ // imports statically (`./loading-registry`) so a pending navigation can
1451
+ // paint a skeleton without fetching anything. Always written — an app with
1452
+ // no loading.tsx gets an empty registry, since the runtime's import of it
1453
+ // is unconditional.
1454
+ fs.writeFileSync(
1455
+ path.join(stageDir, "loading-registry.ts"),
1456
+ generateLoadingRegistry(
1457
+ discoverLoadingModules(fs, path, cwd, appDirRel),
1458
+ ),
1459
+ "utf8",
1460
+ );
1461
+
1247
1462
  const entryPaths: string[] = [];
1248
1463
  // entryPath (absolute) → component path (for manifest lookup).
1249
1464
  const entryToComponent = new Map<string, string>();
@@ -0,0 +1,146 @@
1
+ // Regression tests for the client-navigation payload cache.
2
+ //
3
+ // The bug this exists to fix: `<Link>` prefetched pages with a
4
+ // `<link rel="prefetch">` that could never be reused. Measured on a real app,
5
+ // the same URL was fetched twice — 16742 bytes both times, full transferSize
6
+ // on the second — because SSR pages are sent "private, no-store" and an
7
+ // as="document" prefetch only feeds real navigations, not fetch(). So each
8
+ // prefetch was a full server render, thrown away, and the click still paid.
9
+
10
+ import { describe, expect, it } from "bun:test";
11
+ import { createNavPayloadCache } from "./ssr-nav-cache";
12
+
13
+ /** A cache with a controllable clock and a fetch that records its calls. */
14
+ function harness(opts: { ttlMs?: number; max?: number } = {}) {
15
+ let clock = 1000;
16
+ const calls: string[] = [];
17
+ let resolveNext: ((v: string | null) => void) | null = null;
18
+ const cache = createNavPayloadCache({
19
+ now: () => clock,
20
+ ttlMs: opts.ttlMs,
21
+ max: opts.max,
22
+ fetchPage: (target) => {
23
+ calls.push(target);
24
+ return new Promise<string | null>((res) => {
25
+ resolveNext = res;
26
+ // Default: resolve immediately on the microtask queue.
27
+ queueMicrotask(() => res(`<html>${target}</html>`));
28
+ });
29
+ },
30
+ });
31
+ return {
32
+ cache,
33
+ calls,
34
+ advance: (ms: number) => {
35
+ clock += ms;
36
+ },
37
+ settleWith: (v: string | null) => resolveNext?.(v),
38
+ };
39
+ }
40
+
41
+ describe("nav payload cache", () => {
42
+ it("hands the prefetched payload to the click", async () => {
43
+ const h = harness();
44
+ h.cache.prefetch("/a");
45
+ const taken = h.cache.take("/a");
46
+ expect(taken).not.toBeNull();
47
+ expect(await taken!).toBe("<html>/a</html>");
48
+ // The whole point: the click opened no request of its own.
49
+ expect(h.calls).toEqual(["/a"]);
50
+ });
51
+
52
+ it("does not re-fetch a target already in flight", () => {
53
+ const h = harness();
54
+ h.cache.prefetch("/a");
55
+ h.cache.prefetch("/a");
56
+ h.cache.prefetch("/a");
57
+ expect(h.calls).toEqual(["/a"]);
58
+ });
59
+
60
+ it("lets a click join a prefetch that hasn't landed yet", async () => {
61
+ // Hover-to-click is usually shorter than the render, so this is the
62
+ // common path, not an edge case.
63
+ const h = harness();
64
+ h.cache.prefetch("/slow");
65
+ const taken = h.cache.take("/slow");
66
+ h.settleWith("<html>late</html>");
67
+ expect(await taken!).toBe("<html>late</html>");
68
+ expect(h.calls).toEqual(["/slow"]);
69
+ });
70
+
71
+ it("is single-use, so a second navigation re-renders", () => {
72
+ const h = harness();
73
+ h.cache.prefetch("/a");
74
+ expect(h.cache.take("/a")).not.toBeNull();
75
+ expect(h.cache.take("/a")).toBeNull();
76
+ });
77
+
78
+ it("refuses a payload past its TTL", () => {
79
+ const h = harness({ ttlMs: 15000 });
80
+ h.cache.prefetch("/a");
81
+ h.advance(15000);
82
+ expect(h.cache.take("/a")).toBeNull();
83
+ });
84
+
85
+ it("still serves a payload just inside its TTL", async () => {
86
+ const h = harness({ ttlMs: 15000 });
87
+ h.cache.prefetch("/a");
88
+ h.advance(14999);
89
+ expect(await h.cache.take("/a")!).toBe("<html>/a</html>");
90
+ });
91
+
92
+ it("re-warms an expired target instead of holding the stale one", async () => {
93
+ const h = harness({ ttlMs: 100 });
94
+ h.cache.prefetch("/a");
95
+ h.advance(200);
96
+ h.cache.prefetch("/a");
97
+ expect(h.calls).toEqual(["/a", "/a"]);
98
+ expect(await h.cache.take("/a")!).toBe("<html>/a</html>");
99
+ });
100
+
101
+ it("evicts the oldest entry at the cap", () => {
102
+ const h = harness({ max: 2 });
103
+ h.cache.prefetch("/a");
104
+ h.cache.prefetch("/b");
105
+ h.cache.prefetch("/c");
106
+ expect(h.cache.size()).toBe(2);
107
+ expect(h.cache.take("/a")).toBeNull();
108
+ expect(h.cache.take("/c")).not.toBeNull();
109
+ });
110
+
111
+ it("drops everything when a navigation commits", () => {
112
+ // Entries were rendered against the page the user just left.
113
+ const h = harness();
114
+ h.cache.prefetch("/a");
115
+ h.cache.prefetch("/b");
116
+ h.cache.clear();
117
+ expect(h.cache.size()).toBe(0);
118
+ expect(h.cache.take("/a")).toBeNull();
119
+ });
120
+
121
+ it("yields null when the fetch fails, so the click refetches", async () => {
122
+ const calls: string[] = [];
123
+ const cache = createNavPayloadCache({
124
+ now: () => 0,
125
+ fetchPage: (t) => {
126
+ calls.push(t);
127
+ return Promise.reject(new Error("offline"));
128
+ },
129
+ });
130
+ cache.prefetch("/a");
131
+ // A rejection must not escape as an unhandled rejection when nobody clicks.
132
+ expect(await cache.take("/a")!).toBeNull();
133
+ expect(calls).toEqual(["/a"]);
134
+ });
135
+
136
+ it("caches nothing for a response the fetch rejected as unusable", async () => {
137
+ // fetchPage returns null for a non-200 or a redirect — the URL that
138
+ // answered isn't the one navigate() would commit.
139
+ const cache = createNavPayloadCache({
140
+ now: () => 0,
141
+ fetchPage: () => Promise.resolve(null),
142
+ });
143
+ cache.prefetch("/a");
144
+ expect(await cache.take("/a")!).toBeNull();
145
+ });
146
+ });
@@ -0,0 +1,97 @@
1
+ // Prefetched page payloads for client-side navigation.
2
+ //
3
+ // `<Link>` warms a destination on hover; the click then consumes what the
4
+ // hover fetched instead of opening its own request. Before this existed the
5
+ // prefetch was a `<link rel="prefetch">` that could never be reused — SSR
6
+ // pages are sent "private, no-store", so nothing was storable, and an
7
+ // `as="document"` prefetch only feeds real navigations anyway, not `fetch()`.
8
+ // Every prefetch was therefore a full server render, executed and discarded,
9
+ // and the click still paid full price.
10
+ //
11
+ // So the cache lives HERE, in memory, for the length of the tab: honoring
12
+ // no-store means never handing the response to a disk cache, and it means a
13
+ // reload always re-renders.
14
+ //
15
+ // Kept in its own module (not inlined in CLIENT_RUNTIME_SOURCE) so its
16
+ // expiry, eviction and single-use rules are unit-testable — the same reason
17
+ // client-boundary and route-match were pulled out. The bundler stages a copy
18
+ // next to the generated runtime, which imports it as "./nav-cache".
19
+
20
+ export interface NavPayloadCacheDeps {
21
+ /** Fetch a page, resolving to its HTML or null when it shouldn't be reused. */
22
+ fetchPage: (target: string) => Promise<string | null>;
23
+ /** Injected so tests drive expiry without sleeping. */
24
+ now: () => number;
25
+ /** Long enough to cover hover-to-click, short enough that nobody reads a
26
+ * page rendered from data this old. */
27
+ ttlMs?: number;
28
+ /** Cap on retained payloads; the oldest is evicted first. Pages are whole
29
+ * HTML documents, so this bounds memory on a link-dense page. */
30
+ max?: number;
31
+ }
32
+
33
+ export interface NavPayloadCache {
34
+ /** Warm `target`, unless a fresh entry is already present or in flight. */
35
+ prefetch(target: string): void;
36
+ /** Hand over `target`'s payload, or null. Single-use: a prefetch
37
+ * accelerates the NEXT click, and holding it past that would serve
38
+ * navigations from an increasingly stale render. */
39
+ take(target: string): Promise<string | null> | null;
40
+ /** Drop everything — called when a navigation commits, since entries were
41
+ * rendered against the page the user just left. */
42
+ clear(): void;
43
+ /** Retained entry count (tests + diagnostics). */
44
+ size(): number;
45
+ }
46
+
47
+ export function createNavPayloadCache(
48
+ deps: NavPayloadCacheDeps,
49
+ ): NavPayloadCache {
50
+ const { fetchPage, now } = deps;
51
+ const ttlMs = deps.ttlMs ?? 15000;
52
+ const max = deps.max ?? 8;
53
+ // Holds the in-flight PROMISE, not just the resolved text, so a click that
54
+ // lands mid-prefetch joins that request rather than starting a second one —
55
+ // the common case, since hover-to-click is usually shorter than the render.
56
+ const entries = new Map<string, { at: number; promise: Promise<string | null> }>();
57
+
58
+ return {
59
+ prefetch(target: string): void {
60
+ const hit = entries.get(target);
61
+ if (hit && now() - hit.at < ttlMs) return;
62
+ // Re-warming an expired target: drop it first so the refreshed entry
63
+ // re-enters at the END of the insertion order. Map.set on an existing
64
+ // key keeps its original position, which would make a repeatedly
65
+ // re-warmed target the first one evicted. No test covers this: a
66
+ // re-warm only happens after expiry, so everything ahead of it in the
67
+ // order has expired too and evicting it costs nothing today. Keeping
68
+ // the LRU order honest anyway, so this stays true if the TTL does not.
69
+ entries.delete(target);
70
+ while (entries.size >= max) {
71
+ const oldest = entries.keys().next();
72
+ if (oldest.done) break;
73
+ entries.delete(oldest.value);
74
+ }
75
+ // A rejected fetch must not surface as an unhandled rejection when
76
+ // nobody ends up clicking; resolve to null and let the click refetch.
77
+ const promise = fetchPage(target).catch(() => null);
78
+ entries.set(target, { at: now(), promise });
79
+ },
80
+
81
+ take(target: string): Promise<string | null> | null {
82
+ const hit = entries.get(target);
83
+ if (!hit) return null;
84
+ entries.delete(target);
85
+ if (now() - hit.at >= ttlMs) return null;
86
+ return hit.promise;
87
+ },
88
+
89
+ clear(): void {
90
+ entries.clear();
91
+ },
92
+
93
+ size(): number {
94
+ return entries.size;
95
+ },
96
+ };
97
+ }
@@ -1,5 +1,9 @@
1
1
  import { describe, expect, it } from "bun:test";
2
- import { matchRoute, type MatchableManifest } from "./ssr-route-match";
2
+ import {
3
+ matchRoute,
4
+ prefetchTargets,
5
+ type MatchableManifest,
6
+ } from "./ssr-route-match";
3
7
 
4
8
  const manifest: MatchableManifest = {
5
9
  routes: {
@@ -81,3 +85,99 @@ describe("matchRoute", () => {
81
85
  expect(matchRoute({ routes: {} }, "/")).toBeNull();
82
86
  });
83
87
  });
88
+
89
+ // ---------------------------------------------------------------------------
90
+ // prefetchTargets — what <Link prefetch> warms.
91
+ //
92
+ // Regression: prefetch emitted the page payload and the shared chunks but
93
+ // never the destination's OWN entry chunk, so every click paid a full
94
+ // round-trip (126-281ms measured across a 12-link sidebar) before the route
95
+ // could render. The payload it did prefetch was the half that was already fast.
96
+ // ---------------------------------------------------------------------------
97
+
98
+ const chunked: MatchableManifest = {
99
+ routes: {
100
+ "app/page": {
101
+ path: "/",
102
+ file: "client-entry-app__page-a1.js",
103
+ imports: ["chunks/shared-1.js"],
104
+ },
105
+ "app/speakers/page": {
106
+ path: "/speakers",
107
+ file: "client-entry-app__speakers__page-b2.js",
108
+ imports: ["chunks/shared-1.js", "chunks/editor-2.js"],
109
+ },
110
+ "app/events/[id]/page": {
111
+ path: "/events/[id]",
112
+ file: "client-entry-app__events____id____page-c3.js",
113
+ imports: ["chunks/shared-1.js"],
114
+ },
115
+ // A boundary module: no path, so it never matches a href.
116
+ "app/not-found": { file: "client-entry-app__not_found-d4.js", imports: [] },
117
+ },
118
+ };
119
+
120
+ describe("prefetchTargets", () => {
121
+ it("names the destination's OWN entry chunk", () => {
122
+ // The whole point: without `file`, the click still blocks on this fetch.
123
+ expect(prefetchTargets(chunked, "/speakers").file).toBe(
124
+ "client-entry-app__speakers__page-b2.js",
125
+ );
126
+ });
127
+
128
+ it("includes chunks belonging to the destination alone", () => {
129
+ // editor-2 is imported only by /speakers. It has to be in the warm set
130
+ // whether it's reached as the destination's own import or via the
131
+ // all-routes union.
132
+ expect(prefetchTargets(chunked, "/speakers").imports).toContain(
133
+ "chunks/editor-2.js",
134
+ );
135
+ });
136
+
137
+ it("warms the shared chunks every route needs", () => {
138
+ expect(prefetchTargets(chunked, "/").imports).toContain("chunks/shared-1.js");
139
+ });
140
+
141
+ it("resolves a dynamic route to its entry", () => {
142
+ expect(prefetchTargets(chunked, "/events/42").file).toBe(
143
+ "client-entry-app__events____id____page-c3.js",
144
+ );
145
+ });
146
+
147
+ it("yields no entry for an href that matches no page route", () => {
148
+ // e.g. an API path or a route this build doesn't serve. Shared chunks are
149
+ // still worth warming; the caller skips an empty `file`.
150
+ const t = prefetchTargets(chunked, "/api/webhooks/stripe");
151
+ expect(t.file).toBe("");
152
+ expect(t.imports).toContain("chunks/shared-1.js");
153
+ });
154
+
155
+ it("never resolves to a boundary module's entry", () => {
156
+ expect(prefetchTargets(chunked, "/not-found").file).toBe("");
157
+ });
158
+
159
+ it("deduplicates chunks shared across routes", () => {
160
+ const imports = prefetchTargets(chunked, "/speakers").imports;
161
+ expect(imports.filter((i) => i === "chunks/shared-1.js")).toHaveLength(1);
162
+ });
163
+
164
+ it("is null-safe on an absent or empty manifest", () => {
165
+ expect(prefetchTargets(null, "/")).toEqual({
166
+ file: "",
167
+ imports: [],
168
+
169
+ });
170
+ expect(prefetchTargets({ routes: {} }, "/")).toEqual({
171
+ file: "",
172
+ imports: [],
173
+
174
+ });
175
+ });
176
+
177
+ it("tolerates a manifest whose routes carry no chunk fields", () => {
178
+ // Older build output — must degrade, not throw.
179
+ expect(prefetchTargets({ routes: { "app/page": { path: "/" } } }, "/")).toEqual(
180
+ { file: "", imports: [] },
181
+ );
182
+ });
183
+ });
@@ -18,9 +18,18 @@ export interface RouteMatch {
18
18
  params: Record<string, string>;
19
19
  }
20
20
 
21
- /** The slice of the build manifest this matcher needs. */
21
+ /** The slice of the build manifest this module needs. */
22
22
  export interface MatchableManifest {
23
- routes: Record<string, { path?: string }>;
23
+ routes: Record<
24
+ string,
25
+ {
26
+ path?: string;
27
+ /** The route's own entry chunk (outdir-relative). */
28
+ file?: string;
29
+ /** Shared chunks the browser needs before that entry runs. */
30
+ imports?: string[];
31
+ }
32
+ >;
24
33
  }
25
34
 
26
35
  function splitPath(p: string): string[] {
@@ -102,3 +111,53 @@ export function matchRoute(
102
111
  }
103
112
  return best ? best.match : null;
104
113
  }
114
+
115
+ /** What `<Link prefetch>` should warm for a destination href. */
116
+ export interface PrefetchTargets {
117
+ /** The destination route's own entry chunk, or "" when no page route matches. */
118
+ file: string;
119
+ /** Chunks to warm alongside it. */
120
+ imports: string[];
121
+ }
122
+
123
+ /**
124
+ * The chunks a click on `pathname` will need before anything can render: the
125
+ * destination route's entry, plus the chunks (React, the client runtime,
126
+ * common layouts) it pulls in.
127
+ *
128
+ * Warming the page payload alone leaves the entry chunk to be fetched after
129
+ * the click, and the route cannot render until it lands — so the prefetch
130
+ * covers only the half that was already fast.
131
+ *
132
+ * Size is deliberately NOT a factor. Build output is content-hashed and served
133
+ * immutable, so warming a heavy route costs its bytes once per browser and
134
+ * makes every later visit to it instant; skipping it would trade a permanent
135
+ * win for a one-time saving, and would skip exactly the routes slowest to
136
+ * fetch on demand. The warm is deferred to the load event, so those bytes
137
+ * never compete with the current page's own render.
138
+ *
139
+ * An href matching no page route (an API path, a route this build doesn't
140
+ * serve) yields the union of every route's chunks: no destination is known,
141
+ * but those are needed by any navigation.
142
+ */
143
+ export function prefetchTargets(
144
+ manifest: MatchableManifest | null | undefined,
145
+ pathname: string,
146
+ ): PrefetchTargets {
147
+ if (!manifest || !manifest.routes) return { file: "", imports: [] };
148
+ const matched = matchRoute(manifest, pathname);
149
+ const route = matched ? manifest.routes[matched.component] : null;
150
+ if (route) {
151
+ // The route's OWN transitive chunks — everything it needs, and nothing
152
+ // belonging to routes the user isn't heading for.
153
+ return {
154
+ file: route.file || "",
155
+ imports: Array.from(new Set(route.imports || [])),
156
+ };
157
+ }
158
+ const imports = new Set<string>();
159
+ for (const r of Object.values(manifest.routes)) {
160
+ for (const i of r?.imports || []) imports.add(i);
161
+ }
162
+ return { file: "", imports: Array.from(imports) };
163
+ }