@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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pylonsync/functions",
3
- "version": "0.3.308",
3
+ "version": "0.3.309",
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",
@@ -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 = { segments: string[]; component: string; layouts: string[] };
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
- currentParams = (data && data.props && data.props.params) || {};
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(url.pathname + url.search, {
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
- navEpoch++;
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
- const target = url.pathname + url.search;
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
- navEpoch,
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 (opts && opts.replace) {
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 if (push) {
592
- history.pushState({ component: data.component }, "", target);
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
- navigate(href);
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
+ }