@pylonsync/functions 0.3.308 → 0.3.309
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/ssr-client-bundler.d.ts +3 -0
- package/dist/ssr-route-match.d.ts +20 -0
- package/package.json +1 -1
- package/src/ssr-client-bundler.ts +161 -16
- package/src/ssr-route-match.test.ts +83 -0
- package/src/ssr-route-match.ts +104 -0
|
@@ -40,6 +40,9 @@ export interface PylonBundleManifest {
|
|
|
40
40
|
imports: string[];
|
|
41
41
|
/** CSS chunks (Phase 1.5f). */
|
|
42
42
|
css: string[];
|
|
43
|
+
/** URL pattern (e.g. `/p/[slug]`) — page routes only. Lets the client
|
|
44
|
+
* matcher resolve an href to this route for optimistic navigation. */
|
|
45
|
+
path?: string;
|
|
43
46
|
}>;
|
|
44
47
|
/** Self-hosted fonts (next/font parity): structured `@font-face`s + the
|
|
45
48
|
* `:root` CSS variables + the woff2 files to preload. Global (route-
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export interface RouteMatch {
|
|
2
|
+
/** Route component path — the manifest key + __PYLON_DATA__.component value. */
|
|
3
|
+
component: string;
|
|
4
|
+
/** Decoded dynamic params captured from the path (e.g. `{ slug: "shoe-x" }`). */
|
|
5
|
+
params: Record<string, string>;
|
|
6
|
+
}
|
|
7
|
+
/** The slice of the build manifest this matcher needs. */
|
|
8
|
+
export interface MatchableManifest {
|
|
9
|
+
routes: Record<string, {
|
|
10
|
+
path?: string;
|
|
11
|
+
}>;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Resolve a concrete pathname to the route that would render it, plus its
|
|
15
|
+
* decoded dynamic params. Returns null when no page route matches (the caller
|
|
16
|
+
* then falls back to a normal server round-trip). When several patterns match,
|
|
17
|
+
* the most specific wins: fewer catch-alls first, then fewer dynamic segments —
|
|
18
|
+
* so `/orders/new` beats `/orders/[id]` beats `/[...all]`.
|
|
19
|
+
*/
|
|
20
|
+
export declare function matchRoute(manifest: MatchableManifest | null | undefined, pathname: string): RouteMatch | null;
|
package/package.json
CHANGED
|
@@ -61,6 +61,12 @@ interface DiscoveredRoute {
|
|
|
61
61
|
component: string;
|
|
62
62
|
/** Layout chain root → leaf, same format as `component`. */
|
|
63
63
|
layouts: string[];
|
|
64
|
+
/**
|
|
65
|
+
* URL pattern this page serves (e.g. `/`, `/p/[slug]`). Set for `page`
|
|
66
|
+
* routes only — boundary modules (not-found/error) leave it undefined so the
|
|
67
|
+
* client route matcher (optimistic navigation) never resolves to them.
|
|
68
|
+
*/
|
|
69
|
+
pattern?: string;
|
|
64
70
|
}
|
|
65
71
|
|
|
66
72
|
/** Bun.build returns this shape (the subset we depend on). */
|
|
@@ -120,7 +126,13 @@ function discoverRoutes(
|
|
|
120
126
|
return [];
|
|
121
127
|
}
|
|
122
128
|
|
|
123
|
-
type PageHit = {
|
|
129
|
+
type PageHit = {
|
|
130
|
+
segments: string[];
|
|
131
|
+
component: string;
|
|
132
|
+
layouts: string[];
|
|
133
|
+
// URL pattern, set for real pages only (boundary modules get none).
|
|
134
|
+
pattern?: string;
|
|
135
|
+
};
|
|
124
136
|
const pages: PageHit[] = [];
|
|
125
137
|
|
|
126
138
|
function walk(dir: string, segments: string[], layouts: string[]): void {
|
|
@@ -144,6 +156,8 @@ function discoverRoutes(
|
|
|
144
156
|
segments: [...segments],
|
|
145
157
|
component: path.relative(cwd, pageHere).replace(/\.(tsx?|jsx?)$/, ""),
|
|
146
158
|
layouts: nextLayouts,
|
|
159
|
+
// e.g. [] → "/", ["p","[slug]"] → "/p/[slug]".
|
|
160
|
+
pattern: "/" + segments.join("/"),
|
|
147
161
|
});
|
|
148
162
|
}
|
|
149
163
|
// Boundary modules (not-found.tsx / error.tsx) are hydrated like pages
|
|
@@ -176,6 +190,7 @@ function discoverRoutes(
|
|
|
176
190
|
return pages.map((p) => ({
|
|
177
191
|
component: p.component,
|
|
178
192
|
layouts: p.layouts,
|
|
193
|
+
pattern: p.pattern,
|
|
179
194
|
}));
|
|
180
195
|
}
|
|
181
196
|
|
|
@@ -209,6 +224,7 @@ const CLIENT_RUNTIME_SOURCE = `// Generated by Pylon SSR (Phase 2 client runtime
|
|
|
209
224
|
import { createElement } from "react";
|
|
210
225
|
import { hydrateRoot } from "react-dom/client";
|
|
211
226
|
import { createPylonBoundary } from "./client-boundary";
|
|
227
|
+
import { matchRoute } from "./route-match";
|
|
212
228
|
|
|
213
229
|
const routeCache = Object.create(null);
|
|
214
230
|
let activeRoot = null;
|
|
@@ -252,6 +268,13 @@ let navEpoch = 0;
|
|
|
252
268
|
// (for instance) redirect to /login instead of showing the not-found page.
|
|
253
269
|
let currentPageProps = {};
|
|
254
270
|
|
|
271
|
+
// Seed data for the in-flight navigation — the object handed to <Link seed>,
|
|
272
|
+
// exposed to the destination page via useRouteSeed() so it can paint its
|
|
273
|
+
// Suspense fallback with real content before the SSR fetch lands. Set at the
|
|
274
|
+
// start of every navigation (to the seed, or null), so useRouteSeed() reads a
|
|
275
|
+
// value that's consistent for the whole nav. Null on hard load / seedless nav.
|
|
276
|
+
let currentSeed = null;
|
|
277
|
+
|
|
255
278
|
// The not-found / error boundary lives in a real module (./client-boundary,
|
|
256
279
|
// emitted next to this file by the bundler) so its render behavior is
|
|
257
280
|
// unit-testable instead of buried in this string. Wire the runtime's
|
|
@@ -329,6 +352,32 @@ function makeClientServerData(ssrData) {
|
|
|
329
352
|
return sd;
|
|
330
353
|
}
|
|
331
354
|
|
|
355
|
+
// serverData stand-in used during the OPTIMISTIC first paint of a navigation:
|
|
356
|
+
// every method returns a never-resolving thenable, so a page that use()s it
|
|
357
|
+
// suspends and shows its Suspense fallback (which useRouteSeed() renders from
|
|
358
|
+
// the clicked-link seed). The thenable is cached per key so React never sees an
|
|
359
|
+
// "uncached promise" if it re-renders the optimistic tree. It never resolves on
|
|
360
|
+
// its own — navigate() replaces the whole tree with real, fulfilled serverData
|
|
361
|
+
// the moment the SSR fetch lands, which is what actually swaps fallback→content.
|
|
362
|
+
function makePendingServerData() {
|
|
363
|
+
const pc = new Map();
|
|
364
|
+
const pending = () => ({ then() {} });
|
|
365
|
+
const wrap = (prefix) => {
|
|
366
|
+
const out = {};
|
|
367
|
+
for (const m of SERVER_DATA_METHODS) {
|
|
368
|
+
out[m] = (...args) => {
|
|
369
|
+
const key = prefix + m + ":" + stableStringify(args);
|
|
370
|
+
if (!pc.has(key)) pc.set(key, pending());
|
|
371
|
+
return pc.get(key);
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
return out;
|
|
375
|
+
};
|
|
376
|
+
const sd = wrap("");
|
|
377
|
+
sd.unsafe = wrap("u:");
|
|
378
|
+
return sd;
|
|
379
|
+
}
|
|
380
|
+
|
|
332
381
|
// Server-only response controller has no meaning on the client (the status/
|
|
333
382
|
// redirect/cookies already shipped). Give pages a no-op so a body that
|
|
334
383
|
// touches props.response during hydration doesn't crash.
|
|
@@ -353,8 +402,11 @@ function makeNoopResponse() {
|
|
|
353
402
|
// in a deep client child has a reactive source. A fresh object per nav → stable
|
|
354
403
|
// reference between navs (useSyncExternalStore needs that).
|
|
355
404
|
let currentParams = {};
|
|
405
|
+
function setNavParamsRaw(params) {
|
|
406
|
+
currentParams = params || {};
|
|
407
|
+
}
|
|
356
408
|
function setNavParams(data) {
|
|
357
|
-
|
|
409
|
+
setNavParamsRaw(data && data.props && data.props.params);
|
|
358
410
|
}
|
|
359
411
|
|
|
360
412
|
function withClientProps(data) {
|
|
@@ -530,9 +582,63 @@ async function navigate(href, opts) {
|
|
|
530
582
|
window.location.href = href;
|
|
531
583
|
return;
|
|
532
584
|
}
|
|
585
|
+
const target = url.pathname + url.search;
|
|
586
|
+
|
|
587
|
+
// Claim an epoch for this navigation. A later navigate() bumps navEpoch, so
|
|
588
|
+
// every await below can bail once it's been superseded by a newer nav —
|
|
589
|
+
// otherwise a slow fetch could render a stale destination over a newer one.
|
|
590
|
+
const myEpoch = ++navEpoch;
|
|
591
|
+
currentSeed = opts && opts.seed != null ? opts.seed : null;
|
|
592
|
+
|
|
593
|
+
// ---- Optimistic first paint --------------------------------------------
|
|
594
|
+
// With a seed AND a client-resolvable route, render the destination NOW —
|
|
595
|
+
// before the SSR fetch — with a pending serverData, so the page shows its
|
|
596
|
+
// Suspense fallback painted from the seed (via useRouteSeed). The real render
|
|
597
|
+
// below swaps in full data in place. Any failure (no route match, chunk load
|
|
598
|
+
// error) falls through to the normal blocking fetch-then-render path, so a
|
|
599
|
+
// seed can never break navigation. Requires the page to keep its serverData
|
|
600
|
+
// reads inside a <Suspense> (the store detail page does) — a bare top-level
|
|
601
|
+
// use() would suspend the whole page with no seeded fallback to show.
|
|
602
|
+
let didOptimistic = false;
|
|
603
|
+
if (currentSeed != null && activeRoot) {
|
|
604
|
+
try {
|
|
605
|
+
const manifest = await loadManifest();
|
|
606
|
+
if (myEpoch !== navEpoch) return;
|
|
607
|
+
const matched = matchRoute(manifest, url.pathname);
|
|
608
|
+
if (matched) {
|
|
609
|
+
const route = await loadRouteEntry(matched.component);
|
|
610
|
+
if (myEpoch !== navEpoch) return;
|
|
611
|
+
setNavParamsRaw(matched.params);
|
|
612
|
+
currentPageProps = {
|
|
613
|
+
params: matched.params,
|
|
614
|
+
serverData: makePendingServerData(),
|
|
615
|
+
response: makeNoopResponse(),
|
|
616
|
+
};
|
|
617
|
+
const tree = withBoundary(
|
|
618
|
+
buildTree(route.Page, route.Layouts, currentPageProps),
|
|
619
|
+
matched.component,
|
|
620
|
+
myEpoch,
|
|
621
|
+
);
|
|
622
|
+
pendingNav = target;
|
|
623
|
+
activeRoot.render(tree);
|
|
624
|
+
if (opts && opts.replace) {
|
|
625
|
+
history.replaceState({ component: matched.component }, "", target);
|
|
626
|
+
} else if (push) {
|
|
627
|
+
history.pushState({ component: matched.component }, "", target);
|
|
628
|
+
}
|
|
629
|
+
window.dispatchEvent(new Event("pylon:navigation"));
|
|
630
|
+
window.scrollTo(0, 0);
|
|
631
|
+
didOptimistic = true;
|
|
632
|
+
}
|
|
633
|
+
} catch (e) {
|
|
634
|
+
// Fall through to the normal fetch-then-render path below.
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// ---- Real fetch + render -----------------------------------------------
|
|
533
639
|
let html;
|
|
534
640
|
try {
|
|
535
|
-
const res = await fetch(
|
|
641
|
+
const res = await fetch(target, {
|
|
536
642
|
credentials: "same-origin",
|
|
537
643
|
headers: { Accept: "text/html" },
|
|
538
644
|
});
|
|
@@ -545,6 +651,7 @@ async function navigate(href, opts) {
|
|
|
545
651
|
window.location.href = href;
|
|
546
652
|
return;
|
|
547
653
|
}
|
|
654
|
+
if (myEpoch !== navEpoch) return;
|
|
548
655
|
const doc = new DOMParser().parseFromString(html, "text/html");
|
|
549
656
|
const dataEl = doc.getElementById("__PYLON_DATA__");
|
|
550
657
|
if (!dataEl) {
|
|
@@ -566,16 +673,23 @@ async function navigate(href, opts) {
|
|
|
566
673
|
window.location.href = href;
|
|
567
674
|
return;
|
|
568
675
|
}
|
|
676
|
+
if (myEpoch !== navEpoch) return;
|
|
569
677
|
document.title = doc.title || document.title;
|
|
570
678
|
syncHeadMeta(doc);
|
|
571
679
|
setNavParams(data);
|
|
572
|
-
|
|
680
|
+
// Keep currentSeed set for the rest of this nav: useRouteData renders the seed
|
|
681
|
+
// as CONTENT (not a Suspense fallback) and upgrades it to real data in place,
|
|
682
|
+
// so the seed must stay readable across the optimistic→real render. It's reset
|
|
683
|
+
// at the START of the next navigation. (Clearing it here would degrade the
|
|
684
|
+
// page to its skeleton for a frame during the swap — a visible flash.)
|
|
573
685
|
currentPageProps = withClientProps(data);
|
|
574
|
-
|
|
686
|
+
// Reuse myEpoch (do NOT bump) so the optimistic tree and the real tree share
|
|
687
|
+
// a boundary identity: React resolves the Suspense fallback into the real
|
|
688
|
+
// content in place instead of remounting the whole subtree (no flash).
|
|
575
689
|
const tree = withBoundary(
|
|
576
690
|
buildTree(route.Page, route.Layouts, currentPageProps),
|
|
577
691
|
data.component,
|
|
578
|
-
|
|
692
|
+
myEpoch,
|
|
579
693
|
);
|
|
580
694
|
// Track the in-flight destination so hydrateRoot's onUncaughtError can fall
|
|
581
695
|
// back to a full page load if this re-render throws in React's commit phase
|
|
@@ -586,17 +700,23 @@ async function navigate(href, opts) {
|
|
|
586
700
|
setTimeout(() => {
|
|
587
701
|
if (pendingNav === target) pendingNav = null;
|
|
588
702
|
}, 0);
|
|
589
|
-
if (
|
|
703
|
+
if (didOptimistic) {
|
|
704
|
+
// URL + scroll already handled during the optimistic paint; only keep the
|
|
705
|
+
// history entry's component in sync with the authoritative SSR component.
|
|
590
706
|
history.replaceState({ component: data.component }, "", target);
|
|
591
|
-
} else
|
|
592
|
-
|
|
707
|
+
} else {
|
|
708
|
+
if (opts && opts.replace) {
|
|
709
|
+
history.replaceState({ component: data.component }, "", target);
|
|
710
|
+
} else if (push) {
|
|
711
|
+
history.pushState({ component: data.component }, "", target);
|
|
712
|
+
}
|
|
713
|
+
// Notify the router hooks (useSearchParams / usePathname) so deep children
|
|
714
|
+
// re-read location after a Link click or a router.push(). popstate already
|
|
715
|
+
// covers back/forward, but pushState/replaceState fire no event.
|
|
716
|
+
window.dispatchEvent(new Event("pylon:navigation"));
|
|
717
|
+
// After a successful nav, scroll to top (Next.js default).
|
|
718
|
+
window.scrollTo(0, 0);
|
|
593
719
|
}
|
|
594
|
-
// Notify the router hooks (useSearchParams / usePathname) so deep children
|
|
595
|
-
// re-read location after a Link click or a router.push(). popstate already
|
|
596
|
-
// covers back/forward, but pushState/replaceState fire no event.
|
|
597
|
-
window.dispatchEvent(new Event("pylon:navigation"));
|
|
598
|
-
// After a successful nav, scroll to top (Next.js default).
|
|
599
|
-
window.scrollTo(0, 0);
|
|
600
720
|
}
|
|
601
721
|
|
|
602
722
|
function installNavHandlers() {
|
|
@@ -616,7 +736,12 @@ function installNavHandlers() {
|
|
|
616
736
|
return;
|
|
617
737
|
}
|
|
618
738
|
e.preventDefault();
|
|
619
|
-
|
|
739
|
+
// A <Link seed> registers its seed in window.__pylonLinkSeeds keyed by the
|
|
740
|
+
// anchor element; pass it through so navigate() can paint optimistically.
|
|
741
|
+
const seeds =
|
|
742
|
+
typeof window !== "undefined" ? window.__pylonLinkSeeds : undefined;
|
|
743
|
+
const seed = seeds ? seeds.get(link) : undefined;
|
|
744
|
+
navigate(href, seed != null ? { seed } : undefined);
|
|
620
745
|
});
|
|
621
746
|
window.addEventListener("popstate", () => {
|
|
622
747
|
navigate(location.pathname + location.search, { push: false });
|
|
@@ -677,6 +802,11 @@ const pylonGlobal = {
|
|
|
677
802
|
get params() {
|
|
678
803
|
return currentParams;
|
|
679
804
|
},
|
|
805
|
+
// Read by useRouteSeed(); the seed the active navigation was started with
|
|
806
|
+
// (from <Link seed>), or null. A getter so it always reflects the latest nav.
|
|
807
|
+
get seed() {
|
|
808
|
+
return currentSeed;
|
|
809
|
+
},
|
|
680
810
|
};
|
|
681
811
|
if (typeof window !== "undefined") {
|
|
682
812
|
window.__pylon = pylonGlobal;
|
|
@@ -759,6 +889,9 @@ export interface PylonBundleManifest {
|
|
|
759
889
|
imports: string[];
|
|
760
890
|
/** CSS chunks (Phase 1.5f). */
|
|
761
891
|
css: string[];
|
|
892
|
+
/** URL pattern (e.g. `/p/[slug]`) — page routes only. Lets the client
|
|
893
|
+
* matcher resolve an href to this route for optimistic navigation. */
|
|
894
|
+
path?: string;
|
|
762
895
|
}
|
|
763
896
|
>;
|
|
764
897
|
/** Self-hosted fonts (next/font parity): structured `@font-face`s + the
|
|
@@ -1044,6 +1177,15 @@ async function _doBuildInner(
|
|
|
1044
1177
|
"utf8",
|
|
1045
1178
|
);
|
|
1046
1179
|
|
|
1180
|
+
// Same pattern for the optimistic-navigation route matcher — the runtime
|
|
1181
|
+
// imports it as `./route-match`; its source of truth is ssr-route-match.ts,
|
|
1182
|
+
// unit-tested directly.
|
|
1183
|
+
fs.writeFileSync(
|
|
1184
|
+
path.join(stageDir, "route-match.ts"),
|
|
1185
|
+
fs.readFileSync(path.join(here, "ssr-route-match.ts"), "utf8"),
|
|
1186
|
+
"utf8",
|
|
1187
|
+
);
|
|
1188
|
+
|
|
1047
1189
|
const entryPaths: string[] = [];
|
|
1048
1190
|
// entryPath (absolute) → component path (for manifest lookup).
|
|
1049
1191
|
const entryToComponent = new Map<string, string>();
|
|
@@ -1197,6 +1339,9 @@ async function _doBuildInner(
|
|
|
1197
1339
|
file: entry.relPath,
|
|
1198
1340
|
imports: Array.from(seen),
|
|
1199
1341
|
css: [],
|
|
1342
|
+
// Page routes carry their URL pattern for the client route matcher;
|
|
1343
|
+
// boundary modules (no pattern) are omitted so they never match.
|
|
1344
|
+
...(r.pattern ? { path: r.pattern } : {}),
|
|
1200
1345
|
};
|
|
1201
1346
|
}
|
|
1202
1347
|
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { matchRoute, type MatchableManifest } from "./ssr-route-match";
|
|
3
|
+
|
|
4
|
+
const manifest: MatchableManifest = {
|
|
5
|
+
routes: {
|
|
6
|
+
"app/page": { path: "/" },
|
|
7
|
+
"app/account/page": { path: "/account" },
|
|
8
|
+
"app/checkout/page": { path: "/checkout" },
|
|
9
|
+
"app/p/[slug]/page": { path: "/p/[slug]" },
|
|
10
|
+
"app/orders/[id]/page": { path: "/orders/[id]" },
|
|
11
|
+
"app/orders/new/page": { path: "/orders/new" },
|
|
12
|
+
"app/docs/[...rest]/page": { path: "/docs/[...rest]" },
|
|
13
|
+
// Boundary modules ship without a `path` and must never match.
|
|
14
|
+
"app/p/[slug]/not-found": {},
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
describe("matchRoute", () => {
|
|
19
|
+
it("matches the index route", () => {
|
|
20
|
+
expect(matchRoute(manifest, "/")).toEqual({
|
|
21
|
+
component: "app/page",
|
|
22
|
+
params: {},
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("matches a static route", () => {
|
|
27
|
+
expect(matchRoute(manifest, "/account")).toEqual({
|
|
28
|
+
component: "app/account/page",
|
|
29
|
+
params: {},
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("captures a dynamic segment and decodes it", () => {
|
|
34
|
+
expect(matchRoute(manifest, "/p/blue-runner-9f3a2b")).toEqual({
|
|
35
|
+
component: "app/p/[slug]/page",
|
|
36
|
+
params: { slug: "blue-runner-9f3a2b" },
|
|
37
|
+
});
|
|
38
|
+
expect(matchRoute(manifest, "/p/a%20b")).toEqual({
|
|
39
|
+
component: "app/p/[slug]/page",
|
|
40
|
+
params: { slug: "a b" },
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("prefers a static route over a dynamic one at the same depth", () => {
|
|
45
|
+
expect(matchRoute(manifest, "/orders/new")).toEqual({
|
|
46
|
+
component: "app/orders/new/page",
|
|
47
|
+
params: {},
|
|
48
|
+
});
|
|
49
|
+
expect(matchRoute(manifest, "/orders/o_123")).toEqual({
|
|
50
|
+
component: "app/orders/[id]/page",
|
|
51
|
+
params: { id: "o_123" },
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("matches a catch-all across the remaining segments", () => {
|
|
56
|
+
expect(matchRoute(manifest, "/docs/guide/getting-started")).toEqual({
|
|
57
|
+
component: "app/docs/[...rest]/page",
|
|
58
|
+
params: { rest: "guide/getting-started" },
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("ignores trailing slashes", () => {
|
|
63
|
+
expect(matchRoute(manifest, "/account/")).toEqual({
|
|
64
|
+
component: "app/account/page",
|
|
65
|
+
params: {},
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("returns null when nothing matches", () => {
|
|
70
|
+
expect(matchRoute(manifest, "/does/not/exist")).toBeNull();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("never matches a boundary module (no path)", () => {
|
|
74
|
+
// /p/anything resolves to the page, never the not-found boundary.
|
|
75
|
+
const m = matchRoute(manifest, "/p/anything");
|
|
76
|
+
expect(m?.component).toBe("app/p/[slug]/page");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("is null-safe on an empty/absent manifest", () => {
|
|
80
|
+
expect(matchRoute(null, "/")).toBeNull();
|
|
81
|
+
expect(matchRoute({ routes: {} }, "/")).toBeNull();
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// Client-side route matcher for optimistic navigation.
|
|
2
|
+
//
|
|
3
|
+
// The SSR server owns the authoritative path→component mapping, but for
|
|
4
|
+
// optimistic navigation (painting a destination's Suspense fallback with a
|
|
5
|
+
// `<Link seed>` BEFORE the SSR fetch resolves) the client must resolve the
|
|
6
|
+
// destination route from the clicked href on its own. The build manifest ships
|
|
7
|
+
// each page route's `path` pattern (e.g. `/p/[slug]`) for exactly this — this
|
|
8
|
+
// module is the pure matcher over those patterns.
|
|
9
|
+
//
|
|
10
|
+
// Kept in its own module (not inlined in CLIENT_RUNTIME_SOURCE) so it has one
|
|
11
|
+
// source of truth and is unit-tested directly. The client bundler stages a copy
|
|
12
|
+
// next to the generated runtime, which imports it as `./route-match`.
|
|
13
|
+
|
|
14
|
+
export interface RouteMatch {
|
|
15
|
+
/** Route component path — the manifest key + __PYLON_DATA__.component value. */
|
|
16
|
+
component: string;
|
|
17
|
+
/** Decoded dynamic params captured from the path (e.g. `{ slug: "shoe-x" }`). */
|
|
18
|
+
params: Record<string, string>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The slice of the build manifest this matcher needs. */
|
|
22
|
+
export interface MatchableManifest {
|
|
23
|
+
routes: Record<string, { path?: string }>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function splitPath(p: string): string[] {
|
|
27
|
+
const trimmed = p.replace(/\/+$/, "");
|
|
28
|
+
return trimmed === "" ? [] : trimmed.slice(1).split("/");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface SegMatch {
|
|
32
|
+
params: Record<string, string>;
|
|
33
|
+
/** Count of `[param]` segments — lower is more specific. */
|
|
34
|
+
dynamic: number;
|
|
35
|
+
/** Count of `[...rest]` catch-alls — dominates specificity. */
|
|
36
|
+
catchAll: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Match a route pattern's segments against a concrete path's segments. Handles
|
|
40
|
+
// static segments, `[param]` (single segment), and `[...param]` (catch-all,
|
|
41
|
+
// consumes the remainder). Returns null on any mismatch.
|
|
42
|
+
function matchSegments(
|
|
43
|
+
patSegs: string[],
|
|
44
|
+
targetSegs: string[],
|
|
45
|
+
): SegMatch | null {
|
|
46
|
+
const params: Record<string, string> = {};
|
|
47
|
+
let dynamic = 0;
|
|
48
|
+
for (let i = 0; i < patSegs.length; i++) {
|
|
49
|
+
const seg = patSegs[i];
|
|
50
|
+
if (seg.startsWith("[...") && seg.endsWith("]")) {
|
|
51
|
+
const name = seg.slice(4, -1);
|
|
52
|
+
params[name] = targetSegs
|
|
53
|
+
.slice(i)
|
|
54
|
+
.map(safeDecode)
|
|
55
|
+
.join("/");
|
|
56
|
+
return { params, dynamic, catchAll: 1 };
|
|
57
|
+
}
|
|
58
|
+
if (i >= targetSegs.length) return null;
|
|
59
|
+
if (seg.startsWith("[") && seg.endsWith("]")) {
|
|
60
|
+
params[seg.slice(1, -1)] = safeDecode(targetSegs[i]);
|
|
61
|
+
dynamic++;
|
|
62
|
+
} else if (seg !== targetSegs[i]) {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
// No catch-all consumed the tail, so the lengths must line up exactly.
|
|
67
|
+
if (targetSegs.length !== patSegs.length) return null;
|
|
68
|
+
return { params, dynamic, catchAll: 0 };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function safeDecode(s: string): string {
|
|
72
|
+
try {
|
|
73
|
+
return decodeURIComponent(s);
|
|
74
|
+
} catch {
|
|
75
|
+
return s;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Resolve a concrete pathname to the route that would render it, plus its
|
|
81
|
+
* decoded dynamic params. Returns null when no page route matches (the caller
|
|
82
|
+
* then falls back to a normal server round-trip). When several patterns match,
|
|
83
|
+
* the most specific wins: fewer catch-alls first, then fewer dynamic segments —
|
|
84
|
+
* so `/orders/new` beats `/orders/[id]` beats `/[...all]`.
|
|
85
|
+
*/
|
|
86
|
+
export function matchRoute(
|
|
87
|
+
manifest: MatchableManifest | null | undefined,
|
|
88
|
+
pathname: string,
|
|
89
|
+
): RouteMatch | null {
|
|
90
|
+
if (!manifest || !manifest.routes) return null;
|
|
91
|
+
const targetSegs = splitPath(pathname);
|
|
92
|
+
let best: { match: RouteMatch; score: number } | null = null;
|
|
93
|
+
for (const component of Object.keys(manifest.routes)) {
|
|
94
|
+
const route = manifest.routes[component];
|
|
95
|
+
if (!route || typeof route.path !== "string") continue;
|
|
96
|
+
const m = matchSegments(splitPath(route.path), targetSegs);
|
|
97
|
+
if (!m) continue;
|
|
98
|
+
const score = m.catchAll * 1000 + m.dynamic;
|
|
99
|
+
if (best === null || score < best.score) {
|
|
100
|
+
best = { match: { component, params: m.params }, score };
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return best ? best.match : null;
|
|
104
|
+
}
|