@uniflowed/vite 0.0.0-alpha.33 → 0.0.0-alpha.34

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/driver.js CHANGED
@@ -1356,9 +1356,9 @@ async function deploy() {
1356
1356
  * out — `server.js` below does exactly that through
1357
1357
  * `@uniflowed/server/node`, and a worker hands `settle` to `ctx.waitUntil`.
1358
1358
  * It comes from the bundle rather than from the host's own
1359
- * `@uniflowed/server`, because the request lives in an `AsyncLocalStorage`
1360
- * belonging to a module instance and the instance the application reads is the
1361
- * one inlined here. See ubugeeei-prod/uf#389.
1359
+ * `@uniflowed/server`, because the request store is shared only by copies of
1360
+ * one release of that package and the release the application reads is the one
1361
+ * inlined here. See ubugeeei-prod/uf#389.
1362
1362
  *
1363
1363
  * The document's script and stylesheet URLs are baked in here because they
1364
1364
  * come from the client manifest, which exists at this moment and not in the
@@ -39,24 +39,44 @@ export const RESERVED = Object.freeze({
39
39
  * spelling one router refuses and the other serves as a URL is exactly the
40
40
  * disagreement that made this necessary.
41
41
  *
42
- * `(.)photo` is Next.js's intercepting route. `@team`, its parallel-route
43
- * slot, was on this list too: until #267 both fell through to "a literal URL
44
- * segment", so `@team` became `/@team`, `(.)photo` became `/(.)photo` — the
45
- * test for a `(group)` is that the segment *ends* in `)` — and the generated
46
- * `RoutePath` union contained them, so `route("/@team", …)` type checked. A
42
+ * Until #267 neither `@team` nor `(.)photo` meant anything to this scan, so
43
+ * both fell through to "a literal URL segment": `@team` became `/@team`,
44
+ * `(.)photo` became `/(.)photo` — the test for a `(group)` is that the segment
45
+ * *ends* in `)` — and the generated `RoutePath` union contained them. A
47
46
  * convention served as nonsense is worse than one that is refused, because the
48
47
  * project looks like it works.
49
48
  *
50
- * A slot is a route this router serves now; see {@link scanRoutes}. An
51
- * interception is not: it needs a navigation to carry where it came from,
52
- * which is a change to what a navigation is rather than to this scan.
49
+ * Both are routes this router serves now: a slot everywhere, and an
50
+ * interception inside a slot; see {@link INTERCEPTION_SEGMENTS}. What is left
51
+ * here is what is spelled like an interception and cannot be one — a marker
52
+ * that climbs nowhere, or a marker with no URL segment after it.
53
53
  */
54
54
  export const UNSUPPORTED_SEGMENTS = Object.freeze([
55
+ "(.)(.)photo",
56
+ "(.)(..)photo",
57
+ "(...)(..)photo",
58
+ "(....)photo",
59
+ "(.)(gallery)",
60
+ "(.)@photo",
61
+ ]);
62
+
63
+ /**
64
+ * Directory names this router serves inside a `@slot` and refuses outside one:
65
+ * intercepting routes.
66
+ *
67
+ * The same list as `uf_router::RouteSegment::SLOT_ONLY_EXAMPLES`, held to it by
68
+ * `crates/uf_router/tests/reserved_names.rs`. A second list rather than more
69
+ * entries on {@link UNSUPPORTED_SEGMENTS}, because the refusal is a different
70
+ * sentence: these are spelled correctly and are in the wrong place, and telling
71
+ * somebody to rename a correct directory is the worse of the two mistakes.
72
+ */
73
+ export const INTERCEPTION_SEGMENTS = Object.freeze([
55
74
  "(.)photo",
56
75
  "(..)photo",
57
76
  "(...)photo",
58
77
  "(..)(..)photo",
59
78
  "(..)(..)(..)photo",
79
+ "(.)[id]",
60
80
  ]);
61
81
 
62
82
  /**
@@ -183,6 +203,12 @@ const MAX_DEPTH = 32;
183
203
  * renders nothing, which is what an unaddressed slot on a soft navigation does
184
204
  * in Next.js too.
185
205
  *
206
+ * `intercepts` is what the slot renders for a client navigation that starts on
207
+ * a page it is on and reaches the URL each entry names — the pages under an
208
+ * interception directory such as `@modal/(.)photo/[id]/`, each at the URL it
209
+ * stands in for. A list of its own, because nothing that matches `routes`
210
+ * may reach one: the server renders the ordinary page for that URL, always.
211
+ *
186
212
  * @typedef {object} Slot
187
213
  * @property {string} name the slot's name, without the `@`
188
214
  * @property {number} above how many of the route's layouts are outside it
@@ -191,6 +217,8 @@ const MAX_DEPTH = 32;
191
217
  * @property {?{above: number, module: string}} defaultErrorBoundary the
192
218
  * `$error.js` boundary that catches the default page in the browser
193
219
  * @property {ReadonlyArray<SlotRoute>} routes what the slot may render, by URL
220
+ * @property {ReadonlyArray<SlotRoute>} intercepts what the slot renders when a
221
+ * client navigation is intercepted, by the URL it stands in for
194
222
  */
195
223
 
196
224
  /**
@@ -311,17 +339,18 @@ const MAX_DEPTH = 32;
311
339
  * Directories that do not exist yield an empty table rather than an error: a
312
340
  * library project has no router root, and that is not a mistake.
313
341
  *
314
- * Throws for a directory named the way an intercepting route is spelled: uf
315
- * does not have interception, and the spelling used to become a literal URL
316
- * segment. See {@link UNSUPPORTED_SEGMENTS}.
317
- *
318
342
  * A `@slot` directory is a parallel route and is scanned; see {@link Slot}. It
319
- * throws for the three ways one can be written without being renderable: a
320
- * slot on a segment with no layout of its own, a `$default.js` that is not
321
- * directly inside a slot, a not-found boundary or handler inside a slot, and
343
+ * throws for the ways one can be written without being renderable: a slot on a
344
+ * segment with no layout of its own, a `$default.js` that is not directly
345
+ * inside a slot, a not-found boundary or handler inside a slot, and
322
346
  * boundary-like files with names uf does not open. Each is a file the router
323
347
  * would otherwise never open, which is the failure #267 is about.
324
348
  *
349
+ * An interception directory — `(.)photo` — is scanned inside a slot, into that
350
+ * slot's `intercepts`, and throws everywhere it cannot be one: outside a slot,
351
+ * spelled so nothing reads it ({@link UNSUPPORTED_SEGMENTS}), climbing past the
352
+ * router root, or standing in for a URL no page serves.
353
+ *
325
354
  * @param {string} appRoot absolute path of the router root (`app/`)
326
355
  * @param {{target?: "web" | "native" | "ios" | "android"}} [options]
327
356
  * @returns {{
@@ -484,8 +513,10 @@ export function scanRoutes(appRoot, options = {}) {
484
513
  if (classifyRouteSegment(entry.name).kind === "slot") continue;
485
514
  // Checked before descending, and after the private-directory test for
486
515
  // the same reason `uf_router` prunes them: `app/_drafts/(.)photo/` is not
487
- // a route uf would have served, so it is not one to refuse.
488
- const refused = unsupportedSegmentReason(entry.name);
516
+ // a route uf would have served, so it is not one to refuse. This walk is
517
+ // everywhere a slot is not, so a correctly spelled interception is
518
+ // refused here too — for where it is rather than how it is written.
519
+ const refused = unsupportedSegmentReason(entry.name) ?? outsideSlotReason(entry.name);
489
520
  if (refused != null) {
490
521
  throw new Error(`${path.join(directory, entry.name)}: ${refused}`);
491
522
  }
@@ -546,9 +577,88 @@ export function scanRoutes(appRoot, options = {}) {
546
577
  // parallel-route trees uf does not have yet; see ubugeeei-prod/uf#267.
547
578
  notFound.sort(byPath);
548
579
  errors.sort(byPath);
580
+ // Last, because it is about the table rather than a directory: an
581
+ // intercepting page is only as good as the ordinary page that serves its URL
582
+ // to everybody the interception does not.
583
+ refuseInterceptionsWithoutPages(appRoot, routes);
549
584
  return { routes, handlers, middleware, notFound, errors };
550
585
  }
551
586
 
587
+ /**
588
+ * Refuse an intercepting route whose URL no page serves.
589
+ *
590
+ * An interception renders in its slot only for a client navigation that starts
591
+ * on a page the slot is on. Everybody else who arrives at the URL — a reload, a
592
+ * shared link, a crawler, the prerender — is given the page the URL names, and
593
+ * with none the photo a reader opened in a modal is a 404 the moment they reload
594
+ * it or send it to somebody. Mirrors `uf_router`'s `check_interceptions`.
595
+ *
596
+ * Each slot record is visited once, because a record is shared by every route
597
+ * under the segment that declares it.
598
+ */
599
+ function refuseInterceptionsWithoutPages(appRoot, routes) {
600
+ const seen = new Set();
601
+ const visit = (slots) => {
602
+ for (const slot of slots) {
603
+ if (seen.has(slot)) continue;
604
+ seen.add(slot);
605
+ for (const intercepting of slot.intercepts ?? []) {
606
+ if (!routes.some((route) => servesEveryUrlOf(route.path, intercepting.path))) {
607
+ const directories = intercepting.path
608
+ .split("/")
609
+ .filter((part) => part !== "")
610
+ .map((part) =>
611
+ part.startsWith(":") && part.endsWith("*")
612
+ ? `[...${part.slice(1, -1)}]`
613
+ : part.startsWith(":")
614
+ ? `[${part.slice(1)}]`
615
+ : part,
616
+ );
617
+ const ordinary = path.join(appRoot, ...directories, `${RESERVED.page}.js`);
618
+ throw new Error(
619
+ `${intercepting.page}: this intercepting route stands in for \`${intercepting.path}\` ` +
620
+ "when a client navigation reaches it, and no page serves " +
621
+ `\`${intercepting.path}\`, so a reload of that URL, a link to it and the prerender ` +
622
+ `would all be a 404. Add \`${ordinary}\`, the page everybody who does not arrive by ` +
623
+ "that navigation gets, or remove the interception.",
624
+ );
625
+ }
626
+ visit(intercepting.slots);
627
+ }
628
+ for (const route of slot.routes) {
629
+ visit(route.slots);
630
+ }
631
+ }
632
+ };
633
+ for (const route of routes) {
634
+ visit(route.slots ?? []);
635
+ }
636
+ }
637
+
638
+ /**
639
+ * Whether every URL the route path `intercepted` matches is one `ordinary`
640
+ * serves: segment by segment, the way the runtime's matcher reads both. A
641
+ * static segment serves only itself, a parameter any one segment but not a
642
+ * catch-all's many, and a catch-all whatever is left as long as something is.
643
+ * Mirrors `uf_router`'s `serves_every_url_of`.
644
+ */
645
+ function servesEveryUrlOf(ordinary, intercepted) {
646
+ const theirs = ordinary.split("/").filter((part) => part !== "");
647
+ const ours = intercepted.split("/").filter((part) => part !== "");
648
+ for (let index = 0; index < theirs.length; index += 1) {
649
+ const segment = theirs[index];
650
+ if (segment.startsWith(":") && segment.endsWith("*")) return ours.length > index;
651
+ const other = ours[index];
652
+ if (other === undefined) return false;
653
+ const otherIsCatchAll = other.startsWith(":") && other.endsWith("*");
654
+ const serves = segment.startsWith(":")
655
+ ? !otherIsCatchAll
656
+ : !other.startsWith(":") && other === segment;
657
+ if (!serves) return false;
658
+ }
659
+ return ours.length === theirs.length;
660
+ }
661
+
552
662
  /**
553
663
  * One `@slot` directory, scanned into a {@link Slot}.
554
664
  *
@@ -600,6 +710,12 @@ function scanSlot(parent, directoryName, name, segments, ownLayout, above, targe
600
710
  }
601
711
 
602
712
  const routes = [];
713
+ // What the slot renders *instead of* the page a client navigation reaches:
714
+ // the pages under an interception directory. A list of its own rather than
715
+ // more `routes`, because `routes` is matched against every URL the segment
716
+ // renders — by the server as much as the browser — and nothing but a
717
+ // navigation that starts on a page this slot is on may render one of these.
718
+ const intercepts = [];
603
719
  const defaultPage = findModule(directory, RESERVED.default, PAGE_EXTENSIONS, target);
604
720
  const defaultError = findModule(directory, RESERVED.error, MODULE_EXTENSIONS, target);
605
721
 
@@ -611,6 +727,7 @@ function scanSlot(parent, directoryName, name, segments, ownLayout, above, targe
611
727
  templates,
612
728
  errorBoundary,
613
729
  atSlotRoot,
730
+ intercepting,
614
731
  currentDepth,
615
732
  ) => {
616
733
  if (currentDepth > MAX_DEPTH) return;
@@ -710,8 +827,11 @@ function scanSlot(parent, directoryName, name, segments, ownLayout, above, targe
710
827
 
711
828
  const page = findModule(current, RESERVED.page, PAGE_EXTENSIONS, target);
712
829
  if (page) {
830
+ // Under an interception directory the path is the URL the page stands in
831
+ // for — `routeFromSegments` applies the climb — and the page goes in the
832
+ // slot's other list.
713
833
  const { path: routePath, params } = routeFromSegments(currentSegments);
714
- routes.push({
834
+ (intercepting ? intercepts : routes).push({
715
835
  path: routePath,
716
836
  params,
717
837
  page,
@@ -729,9 +849,18 @@ function scanSlot(parent, directoryName, name, segments, ownLayout, above, targe
729
849
  if (entry.name.startsWith(".") || entry.name.startsWith("_")) continue;
730
850
  const classified = classifyRouteSegment(entry.name);
731
851
  if (classified.kind === "slot") continue;
732
- const refused = unsupportedSegmentReason(entry.name);
733
- if (refused != null) {
734
- throw new Error(`${path.join(current, entry.name)}: ${refused}`);
852
+ if (classified.kind === "interception") {
853
+ // Inside a slot, so the place is right. What is left to refuse is a
854
+ // spelling nothing reads and a climb past the router root, and the
855
+ // depth is what the directories above it really contribute, climbs
856
+ // applied.
857
+ const depthHere = routeFromSegments(currentSegments)
858
+ .path.split("/")
859
+ .filter((part) => part !== "").length;
860
+ const refused = unsupportedSegmentReason(entry.name) ?? climbReason(entry.name, depthHere);
861
+ if (refused != null) {
862
+ throw new Error(`${path.join(current, entry.name)}: ${refused}`);
863
+ }
735
864
  }
736
865
  walkSlot(
737
866
  path.join(current, entry.name),
@@ -741,24 +870,30 @@ function scanSlot(parent, directoryName, name, segments, ownLayout, above, targe
741
870
  nextTemplates,
742
871
  nextErrorBoundary,
743
872
  false,
873
+ intercepting || classified.kind === "interception",
744
874
  currentDepth + 1,
745
875
  );
746
876
  }
747
877
  };
748
878
 
879
+ // The slot's own directory is in the segments from here down. It adds nothing
880
+ // to a path, and it is how `routeFromSegments` knows that an interception
881
+ // below it is inside a slot.
749
882
  walkSlot(
750
883
  directory,
751
- segments,
884
+ [...segments, directoryName],
752
885
  [],
753
886
  [],
754
887
  [],
755
888
  defaultError == null ? null : { above: 0, module: defaultError },
756
889
  true,
890
+ false,
757
891
  depth + 1,
758
892
  );
759
893
 
760
894
  const byPath = (a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
761
895
  routes.sort(byPath);
896
+ intercepts.sort(byPath);
762
897
  return {
763
898
  name,
764
899
  above,
@@ -766,6 +901,7 @@ function scanSlot(parent, directoryName, name, segments, ownLayout, above, targe
766
901
  defaultMdx: defaultPage != null && defaultPage.endsWith(".mdx"),
767
902
  defaultErrorBoundary: defaultError == null ? null : { above: 0, module: defaultError },
768
903
  routes,
904
+ intercepts,
769
905
  };
770
906
  }
771
907
 
@@ -848,10 +984,12 @@ export function classifyRouteSegment(segment) {
848
984
  /**
849
985
  * The `(.)`-style prefix of `segment` and the route after it, or `null`.
850
986
  *
851
- * One or more of `(.)`, `(..)` and `(...)` — every marker Next.js defines;
852
- * `(..)(..)` is two of them rather than a fourth — followed by something for
853
- * them to intercept. A marker with nothing after it names no route and is the
854
- * `(group)` it has always been.
987
+ * One or more parenthesised runs of dots, followed by something for them to
988
+ * intercept. Which runs *mean* anything is {@link interceptionClimb}'s question
989
+ * and deliberately not this one: `(....)photo` is the shape of an interception
990
+ * written by somebody who miscounted, and reading it as a literal URL segment is
991
+ * how the miscount becomes a page at `/(....)photo`. A marker with nothing after
992
+ * it names no route and is the `(group)` it has always been.
855
993
  */
856
994
  function interceptionMarker(segment) {
857
995
  let consumed = 0;
@@ -859,7 +997,7 @@ function interceptionMarker(segment) {
859
997
  const close = segment.indexOf(")", consumed);
860
998
  if (close === -1) break;
861
999
  const inner = segment.slice(consumed + 1, close);
862
- if (inner.length === 0 || inner.length > 3 || /[^.]/.test(inner)) break;
1000
+ if (inner.length === 0 || /[^.]/.test(inner)) break;
863
1001
  consumed = close + 1;
864
1002
  }
865
1003
  if (consumed === 0 || consumed === segment.length) return null;
@@ -867,28 +1005,124 @@ function interceptionMarker(segment) {
867
1005
  }
868
1006
 
869
1007
  /**
870
- * Why uf refuses a directory named `segment`, or `null` when it serves it.
1008
+ * How far a marker climbs, in URL segments: a number for `(.)` and `(..)`
1009
+ * repeated, `"root"` for `(...)`, and `null` for a marker uf does not read.
1010
+ *
1011
+ * Mirrors `uf_router::interception_climb`. `(.)` and `(...)` only as the whole
1012
+ * marker, because each already says where the climb ends; `(..)` as many times
1013
+ * as there are levels to climb.
1014
+ *
1015
+ * @param {string} marker
1016
+ * @returns {number | "root" | null}
1017
+ */
1018
+ export function interceptionClimb(marker) {
1019
+ const runs = marker.match(/\(\.+\)/g) ?? [];
1020
+ if (runs.length === 0 || runs.join("") !== marker) return null;
1021
+ if (runs.length === 1 && runs[0] === "(.)") return 0;
1022
+ if (runs.length === 1 && runs[0] === "(...)") return "root";
1023
+ return runs.every((run) => run === "(..)") ? runs.length : null;
1024
+ }
1025
+
1026
+ /**
1027
+ * The interception a classified directory name is, when it is one uf reads: a
1028
+ * marker that climbs, with a URL segment after it to stand in for.
1029
+ *
1030
+ * @returns {{climb: number | "root", route: {kind: string, name: string}} | null}
1031
+ */
1032
+ function readInterception(classified) {
1033
+ if (classified.kind !== "interception") return null;
1034
+ const climb = interceptionClimb(classified.marker);
1035
+ const route = classifyRouteSegment(classified.route);
1036
+ if (climb == null) return null;
1037
+ if (route.kind !== "literal" && route.kind !== "param" && route.kind !== "catchAll") {
1038
+ return null;
1039
+ }
1040
+ return { climb, route };
1041
+ }
1042
+
1043
+ /**
1044
+ * Why uf refuses a directory named `segment` wherever it is, or `null` when it
1045
+ * serves it somewhere.
871
1046
  *
872
1047
  * The message is this router's own rather than `uf_router`'s, because the two
873
1048
  * are reached differently: the Rust one fails `uf build` and `uf dev` through
874
1049
  * the route manifest, and this one fails a project driving Vite itself. Both
875
- * say the same two things — which feature the spelling belongs to, and that it
876
- * is refused rather than served as a URL.
1050
+ * say the same things — what is wrong with the spelling, and that it is refused
1051
+ * rather than served as a URL.
1052
+ *
1053
+ * A correctly spelled interception has two more refusals, about where it is
1054
+ * rather than how it is written: {@link outsideSlotReason} and `climbReason`.
877
1055
  */
878
1056
  export function unsupportedSegmentReason(segment) {
879
1057
  const classified = classifyRouteSegment(segment);
880
- if (classified.kind === "interception") {
1058
+ if (classified.kind !== "interception") return null;
1059
+ const { marker, route } = classified;
1060
+ if (interceptionClimb(marker) == null) {
1061
+ return (
1062
+ `\`${segment}\` is spelled like an intercepting route and \`${marker}\` is not a marker uf ` +
1063
+ "reads. The markers are `(.)` for the level the directory is at, `(..)` for one above it — " +
1064
+ "repeated for each further level — and `(...)` for the router root. It is refused rather " +
1065
+ `than served as the URL segment \`/${segment}\`, which is what it used to become. Spell the ` +
1066
+ "marker as one of those and put the directory inside a `@slot`, or rename it to the literal " +
1067
+ `segment \`${route}\`. https://github.com/ubugeeei-prod/uf/issues/267`
1068
+ );
1069
+ }
1070
+ if (readInterception(classified) == null) {
881
1071
  return (
882
- `\`${segment}\` is an intercepting route, and uf does not have interception — a navigation ` +
883
- "carries where it is going and not where it came from, so nothing here could match " +
884
- `\`${classified.route}\`. It is refused rather than served as the URL segment ` +
885
- `\`/${segment}\`, which is what it used to become. Move the route to the path it belongs ` +
886
- "at, or rename the directory. https://github.com/ubugeeei-prod/uf/issues/267"
1072
+ `\`${segment}\` is spelled like an intercepting route, and \`${route}\` after the marker is ` +
1073
+ "not a URL segment, so there is no path for it to intercept: an interception names the " +
1074
+ `segment it stands in for, the way \`${marker}photo\` and \`${marker}[id]\` do. It is ` +
1075
+ `refused rather than served as the URL segment \`/${segment}\`, which is what it used to ` +
1076
+ "become. Put a segment name after the marker, or rename the directory. " +
1077
+ "https://github.com/ubugeeei-prod/uf/issues/267"
887
1078
  );
888
1079
  }
889
1080
  return null;
890
1081
  }
891
1082
 
1083
+ /**
1084
+ * Why uf refuses the intercepting route `segment` outside a `@slot`, where it
1085
+ * would serve it inside one; `null` for any other directory name.
1086
+ *
1087
+ * A sentence of its own rather than another case of
1088
+ * {@link unsupportedSegmentReason}, because this one is spelled correctly and
1089
+ * placed wrongly, and telling its author to rename it would be wrong.
1090
+ */
1091
+ export function outsideSlotReason(segment) {
1092
+ const classified = classifyRouteSegment(segment);
1093
+ if (readInterception(classified) == null) return null;
1094
+ return (
1095
+ `\`${segment}\` is an intercepting route, and an intercepting route renders into a \`@slot\`: ` +
1096
+ "it is what a client navigation shows in a named place instead of the page its URL names, " +
1097
+ "and outside a slot there is no named place for it to show in. It is refused rather than " +
1098
+ `served as the URL segment \`/${segment}\`, which is what it used to become. Move it inside a ` +
1099
+ "slot directory beside the layout that renders the slot, or rename the directory to the " +
1100
+ `literal segment \`${classified.route}\`. https://github.com/ubugeeei-prod/uf/issues/267`
1101
+ );
1102
+ }
1103
+
1104
+ /**
1105
+ * Why an interception `depth` URL segments below the router root climbs past
1106
+ * it, or `null` when it does not. Mirrors `RouteSegment::climb_reason`.
1107
+ */
1108
+ function climbReason(segment, depth) {
1109
+ const read = readInterception(classifyRouteSegment(segment));
1110
+ if (read == null || read.climb === "root" || read.climb <= depth) return null;
1111
+ const climbs = read.climb === 1 ? "one level" : `${read.climb} levels`;
1112
+ const sits =
1113
+ depth === 0
1114
+ ? "at the router root"
1115
+ : depth === 1
1116
+ ? "one level below it"
1117
+ : `${depth} levels below it`;
1118
+ return (
1119
+ `\`${segment}\` climbs ${climbs} from the directory it is in, which is ${sits}, so the URL it ` +
1120
+ "intercepts would be above the router root, and there is no such URL. It is refused rather " +
1121
+ "than read as a climb to the root. Remove a `(..)`, or write `(...)` to intercept from the " +
1122
+ "router root. https://github.com/ubugeeei-prod/uf/issues/267"
1123
+ );
1124
+ }
1125
+
892
1126
  /**
893
1127
  * Turn directory segments into a route path and its parameters.
894
1128
  *
@@ -896,32 +1130,48 @@ export function unsupportedSegmentReason(segment) {
896
1130
  * captures one segment, and `[...name]` captures the rest of the path. A
897
1131
  * `@slot` contributes nothing either — it is a named place a route renders
898
1132
  * into, matched against the URL of the segment that declares it — so a slot's
899
- * pages are matched against ordinary paths and add none of their own. An
900
- * interception never reaches here: {@link scanRoutes} refuses the directory
901
- * before it walks into it.
1133
+ * pages are matched against ordinary paths and add none of their own.
1134
+ *
1135
+ * An intercepting route is where "one directory, one segment" stops holding.
1136
+ * Inside a slot, `(..)photo` takes a segment *away* before it adds its own, so
1137
+ * `["feed", "@modal", "(..)photo", "[id]"]` is `/photo/:id`: the URL the
1138
+ * interception stands in for. That is `uf_router`'s `path_segments`, spelled
1139
+ * again. Wherever an interception cannot be — outside a slot, climbing past the
1140
+ * root, written so nothing reads it — this throws the refusal
1141
+ * {@link scanRoutes} would give the directory rather than build a URL from it.
902
1142
  */
903
1143
  export function routeFromSegments(segments) {
904
- const params = [];
905
- const out = [];
1144
+ let out = [];
1145
+ let insideSlot = false;
906
1146
  for (const segment of segments) {
907
1147
  const classified = classifyRouteSegment(segment);
908
- if (classified.kind === "group" || classified.kind === "slot") continue;
909
- if (classified.kind === "catchAll") {
910
- params.push({ name: classified.name, catchAll: true });
911
- out.push(`:${classified.name}*`);
912
- continue;
913
- }
914
- if (classified.kind === "param") {
915
- params.push({ name: classified.name, catchAll: false });
916
- out.push(`:${classified.name}`);
1148
+ if (classified.kind === "group") continue;
1149
+ if (classified.kind === "slot") {
1150
+ insideSlot = true;
917
1151
  continue;
918
1152
  }
1153
+ let named = classified;
919
1154
  if (classified.kind === "interception") {
920
- throw new Error(unsupportedSegmentReason(segment));
1155
+ const refused =
1156
+ unsupportedSegmentReason(segment) ??
1157
+ (insideSlot ? climbReason(segment, out.length) : outsideSlotReason(segment));
1158
+ if (refused != null) {
1159
+ throw new Error(refused);
1160
+ }
1161
+ const read = readInterception(classified);
1162
+ out = read.climb === "root" ? [] : out.slice(0, out.length - read.climb);
1163
+ named = read.route;
1164
+ }
1165
+ if (named.kind === "catchAll") {
1166
+ out.push({ spelling: `:${named.name}*`, param: { name: named.name, catchAll: true } });
1167
+ } else if (named.kind === "param") {
1168
+ out.push({ spelling: `:${named.name}`, param: { name: named.name, catchAll: false } });
1169
+ } else {
1170
+ out.push({ spelling: named.name, param: null });
921
1171
  }
922
- out.push(segment);
923
1172
  }
924
- const routePath = out.length === 0 ? "/" : `/${out.join("/")}`;
1173
+ const routePath = out.length === 0 ? "/" : `/${out.map((entry) => entry.spelling).join("/")}`;
1174
+ const params = out.flatMap((entry) => (entry.param == null ? [] : [entry.param]));
925
1175
  return { path: routePath, pattern: routePath.replace(/:(\w+)\*/g, "*$1"), params };
926
1176
  }
927
1177
 
@@ -1101,18 +1351,16 @@ export function routesModuleSource(table, options = {}) {
1101
1351
  boundary == null
1102
1352
  ? "null"
1103
1353
  : `{ above: ${boundary.above}, module: () => import(${JSON.stringify(boundary.module)}) }`;
1104
- const slotId = (slot) => {
1105
- let id = slotIds.get(slot);
1106
- if (id !== undefined) {
1107
- return id;
1108
- }
1109
- const routes = slot.routes.map((route) => {
1110
- slotFiles.add(route.page);
1111
- for (const file of route.layouts) slotFiles.add(file);
1112
- for (const entry of route.loading ?? []) slotFiles.add(entry.module);
1113
- const nested = route.slots.map(slotId);
1114
- if (route.errorBoundary != null) slotFiles.add(route.errorBoundary.module);
1115
- return ` {
1354
+ // One route a slot may render, as source. The same shape for a slot's own
1355
+ // routes and for its interceptions, because an intercepting page is composed
1356
+ // exactly the way every other page in the slot is.
1357
+ const slotRoute = (route) => {
1358
+ slotFiles.add(route.page);
1359
+ for (const file of route.layouts) slotFiles.add(file);
1360
+ for (const entry of route.loading ?? []) slotFiles.add(entry.module);
1361
+ const nested = route.slots.map(slotId);
1362
+ if (route.errorBoundary != null) slotFiles.add(route.errorBoundary.module);
1363
+ return ` {
1116
1364
  path: ${JSON.stringify(route.path)},
1117
1365
  params: ${JSON.stringify(route.params)},
1118
1366
  mdx: ${route.mdx},
@@ -1128,7 +1376,14 @@ export function routesModuleSource(table, options = {}) {
1128
1376
  errorBoundary: ${slotErrorBoundary(route.errorBoundary ?? null)},
1129
1377
  slots: [${nested.join(", ")}],
1130
1378
  }`;
1131
- });
1379
+ };
1380
+ const slotId = (slot) => {
1381
+ let id = slotIds.get(slot);
1382
+ if (id !== undefined) {
1383
+ return id;
1384
+ }
1385
+ const routes = slot.routes.map(slotRoute);
1386
+ const intercepts = (slot.intercepts ?? []).map(slotRoute);
1132
1387
  // After the routes, so a nested slot's `const` is already emitted.
1133
1388
  id = `slot${slotIds.size}`;
1134
1389
  slotIds.set(slot, id);
@@ -1143,6 +1398,15 @@ export function routesModuleSource(table, options = {}) {
1143
1398
  ? " defaultPage: null,"
1144
1399
  : ` defaultPage: () => import(${JSON.stringify(slot.defaultPage)}),
1145
1400
  defaultFile: ${JSON.stringify(displayFile(slot.defaultPage))},`;
1401
+ // Only when there is one, so a slot that intercepts nothing is emitted byte
1402
+ // for byte the way it was before interception existed.
1403
+ const intercepting =
1404
+ intercepts.length === 0
1405
+ ? ""
1406
+ : `
1407
+ intercepts: [
1408
+ ${intercepts.join(",\n")}
1409
+ ],`;
1146
1410
  slotDefinitions.push(`const ${id} = {
1147
1411
  name: ${JSON.stringify(slot.name)},
1148
1412
  above: ${slot.above},
@@ -1151,7 +1415,7 @@ ${fallback}
1151
1415
  defaultErrorBoundary: ${slotErrorBoundary(slot.defaultErrorBoundary ?? null)},
1152
1416
  routes: [
1153
1417
  ${routes.join(",\n")}
1154
- ],
1418
+ ],${intercepting}
1155
1419
  };`);
1156
1420
  return id;
1157
1421
  };
@@ -1316,7 +1580,9 @@ function slotModuleFiles(slots) {
1316
1580
  for (const slot of slots) {
1317
1581
  if (slot.defaultPage != null) files.push(slot.defaultPage);
1318
1582
  if (slot.defaultErrorBoundary != null) files.push(slot.defaultErrorBoundary.module);
1319
- for (const route of slot.routes) {
1583
+ // An interception's modules with the slot's own: its stylesheet is part of
1584
+ // what the page looks like when a navigation opens it over this route.
1585
+ for (const route of [...slot.routes, ...(slot.intercepts ?? [])]) {
1320
1586
  files.push(
1321
1587
  route.page,
1322
1588
  ...route.layouts,
@@ -1432,14 +1698,15 @@ ${mount}({ App, routes, notFound, errors${strictMode}${navigation} });
1432
1698
  * has nothing to do with a document that is no route's.
1433
1699
  *
1434
1700
  * `beginRequest` is the fourth, and it is re-exported rather than imported by
1435
- * the host for a reason that is easy to get wrong: `@uniflowed/server` keeps
1436
- * the request in an `AsyncLocalStorage` held by *its module*, and a bundled
1437
- * application has its own copy of that module inlined. A host that imported
1438
- * `beginRequest` from its own `node_modules` would establish a request in a
1439
- * second storage, and every `cookies()` in the application would still be
1440
- * outside one. So the bundle hands the host the entry point that belongs to
1441
- * the bundle. `uf preview`, `uf start`, `uf dev` and the compiled binary all
1442
- * take it from here; see ubugeeei-prod/uf#389.
1701
+ * the host for a reason that is easy to get wrong: `@uniflowed/server` shares
1702
+ * its request store between copies of one *release* of itself, and a bundled
1703
+ * application has its own copy inlined. A host that imported `beginRequest`
1704
+ * from its own `node_modules` could be holding another release, would
1705
+ * establish a request in a store the application never reads, and every
1706
+ * `cookies()` in the application would still be outside one. So the bundle
1707
+ * hands the host the entry point that belongs to the bundle. `uf preview`,
1708
+ * `uf start`, `uf dev` and the compiled binary all take it from here; see
1709
+ * ubugeeei-prod/uf#389.
1443
1710
  *
1444
1711
  * Through `@uniflowed/router/server` rather than `@uniflowed/server/host`,
1445
1712
  * because this source is resolved from the *project's* directory and a project
package/internal/rsc.js CHANGED
@@ -202,7 +202,13 @@ function slotPredicate(needed) {
202
202
  const needsSlot = (slot) => {
203
203
  const cached = cache.get(slot);
204
204
  if (cached !== undefined) return cached;
205
- let answer = false;
205
+ // A slot that intercepts anything needs the client router on every page it
206
+ // is rendered on, whatever its modules are. An intercepting route renders
207
+ // only for a client navigation that starts on such a page, and a page
208
+ // served as a document with no router hydrated on it is a page no
209
+ // navigation starts from — so dropping it would leave the interception a
210
+ // file nothing ever renders, which is the silence #267 is about.
211
+ let answer = (slot.intercepts ?? []).length > 0;
206
212
  if (slot.defaultPage != null && needed(slot.defaultPage)) {
207
213
  answer = true;
208
214
  }
package/internal/serve.js CHANGED
@@ -64,12 +64,12 @@
64
64
  // settles it on the line after the last byte.
65
65
  //
66
66
  // It has to be the *entry's* `beginRequest` rather than one imported here: the
67
- // request lives in an `AsyncLocalStorage` belonging to one copy of
68
- // `@uniflowed/server`, and the copy that matters is the one inside the
69
- // application bundle. A host that resolved its own would begin a request the
70
- // application cannot see, and nothing would fail loudly — the guard would run,
71
- // the page would render, and every `cookies()` in it would throw as though no
72
- // host had run at all. See ubugeeei-prod/uf#389.
67
+ // request store is shared by every copy of one release of `@uniflowed/server`,
68
+ // and the release that matters is the one inside the application bundle. A
69
+ // host that resolved its own could be holding another release, would begin a
70
+ // request the application cannot see, and nothing would fail loudly — the
71
+ // guard would run, the page would render, and every `cookies()` in it would
72
+ // throw as though no host had run at all. See ubugeeei-prod/uf#389.
73
73
 
74
74
  import { readFile, stat } from "node:fs/promises";
75
75
  import path from "node:path";
@@ -348,10 +348,10 @@ export function assetsFromManifest(manifest) {
348
348
  * its callback whether the render threw or not, and `drainDeferred` already
349
349
  * reports a failing task rather than propagating it.
350
350
  *
351
- * `entry.beginRequest` and not an import: the request lives in an
352
- * `AsyncLocalStorage` belonging to one copy of `@uniflowed/server`, and the
353
- * copy that matters is the one inside the application bundle. See
354
- * `serverModuleSource` in `./routes.js`.
351
+ * `entry.beginRequest` and not an import: the request store is shared by every
352
+ * copy of one release of `@uniflowed/server`, and the release that matters is
353
+ * the one inside the application bundle. See `serverModuleSource` in
354
+ * `./routes.js`.
355
355
  *
356
356
  * The one case this cannot be exact about is a request uf hands back rather
357
357
  * than answers: a caller whose `catch` is `next(error)` gives the response to
@@ -385,10 +385,10 @@ export async function withRequest(entry, request, body) {
385
385
  * `finally` here. A caller that always answers should use [`withRequest`] and
386
386
  * not think about it.
387
387
  *
388
- * `entry.beginRequest` and not an import: the request lives in an
389
- * `AsyncLocalStorage` belonging to one copy of `@uniflowed/server`, and the
390
- * copy that matters is the one inside the application bundle. See
391
- * `serverModuleSource` in `./routes.js`.
388
+ * `entry.beginRequest` and not an import: the request store is shared by every
389
+ * copy of one release of `@uniflowed/server`, and the release that matters is
390
+ * the one inside the application bundle. See `serverModuleSource` in
391
+ * `./routes.js`.
392
392
  *
393
393
  * What this host can do is put on the request the way `createFetchHandler`
394
394
  * puts it on the one it owns. `uf dev` and `uf build --compile` reach a route
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniflowed/vite",
3
- "version": "0.0.0-alpha.33",
3
+ "version": "0.0.0-alpha.34",
4
4
  "description": "Vite, driven by uf.config.js: every Flow module through `uf transform`, MDX, the file-system router and static rendering as Vite plugins.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -34,10 +34,10 @@
34
34
  "dependencies": {
35
35
  "@mdx-js/rollup": "^3.1.1",
36
36
  "@shikijs/rehype": "^3.23.0",
37
- "@uniflowed/host": "0.0.0-alpha.33",
38
- "@uniflowed/router": "0.0.0-alpha.33",
39
- "@uniflowed/server": "0.0.0-alpha.33",
40
- "@uniflowed/validator": "0.0.0-alpha.33",
37
+ "@uniflowed/host": "0.0.0-alpha.34",
38
+ "@uniflowed/router": "0.0.0-alpha.34",
39
+ "@uniflowed/server": "0.0.0-alpha.34",
40
+ "@uniflowed/validator": "0.0.0-alpha.34",
41
41
  "rehype-slug": "^6.0.0",
42
42
  "remark-frontmatter": "^5.0.0",
43
43
  "remark-gfm": "^4.0.1",