@uniflowed/router 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/index.js CHANGED
@@ -22,6 +22,12 @@
22
22
  // URL the page is, so one URL renders two subtrees at once, and
23
23
  // `$default.js` is what a slot renders when the URL matched none of its
24
24
  // routes. See ubugeeei-prod/uf#267.
25
+ //
26
+ // A directory named `(.)photo` inside a slot is an intercepting route: a client
27
+ // navigation that starts on a page the slot is on, and reaches the URL the
28
+ // directory stands in for, renders it in the slot and leaves the page
29
+ // underneath where it was. A document request for that URL — a reload, a
30
+ // shared link, a prerender — renders the ordinary page.
25
31
 
26
32
  import * as React from "react";
27
33
 
@@ -31,6 +37,7 @@ export type {
31
37
  AppProps,
32
38
  ErrorBoundary,
33
39
  ErrorModule,
40
+ Interception,
34
41
  JsonLd,
35
42
  LayoutModule,
36
43
  LinkPrefetch,
@@ -52,6 +52,26 @@ export type SlotRecord<
52
52
  readonly defaultMdx?: boolean,
53
53
  readonly defaultErrorBoundary?: ?SlotErrorBoundaryRecord<TError>,
54
54
  readonly routes: $ReadOnlyArray<SlotRouteRecord<TPage, TLayout, TTemplate, TLoading, TError>>,
55
+ /**
56
+ * The routes this slot renders when a client navigation *intercepts* the URL
57
+ * each one names — `app/feed/@modal/(.)photo/[id]/$page.js` is the one for
58
+ * `/feed/photo/:id` — keyed by that URL.
59
+ *
60
+ * A second list rather than a flag on `routes`, because the two are read by
61
+ * different callers at different times and neither may see the other's.
62
+ * `routes` is matched against every URL the segment renders, by the server and
63
+ * by the browser alike. These are matched only by a navigation that starts on
64
+ * a page this slot is already rendered on, and nothing on a server reads them
65
+ * — which is the whole of why a document request for an intercepted URL
66
+ * renders the ordinary page. `resolveInterception`, beside the rest of the
67
+ * rendering, is the one reader.
68
+ *
69
+ * Absent on a slot that intercepts nothing, which is every slot a table
70
+ * written before interception existed.
71
+ */
72
+ readonly intercepts?: $ReadOnlyArray<
73
+ SlotRouteRecord<TPage, TLayout, TTemplate, TLoading, TError>,
74
+ >,
55
75
  |};
56
76
 
57
77
  /** One page inside a slot. */
@@ -605,6 +605,41 @@ export type ResolvedRoute = {|
605
605
  * belongs to the segment the walk went through.
606
606
  */
607
607
  readonly slots: $ReadOnlyArray<ResolvedSlot>,
608
+ /**
609
+ * Set when a client navigation was intercepted: this is then the route the
610
+ * navigation came from, still on screen, with the intercepting route in one
611
+ * of its slots.
612
+ *
613
+ * `null` or absent on every other resolution — which includes every one a
614
+ * server makes, because a document request is never intercepted. See
615
+ * [`resolveInterception`].
616
+ */
617
+ readonly interception?: ?Interception,
618
+ |};
619
+
620
+ /**
621
+ * What an intercepted navigation went to, and what it went there from.
622
+ *
623
+ * A route is resolved *for* a URL, and an intercepted navigation is the one
624
+ * case where the URL in the address bar is not the URL the page on screen was
625
+ * resolved for. The `ResolvedRoute` that carries this is the page the
626
+ * navigation started on — its `pathname`, `params` and `data` are that page's,
627
+ * because that page is what `children` still renders and what `useRoute()`
628
+ * still describes — and this is the other half: where the address bar went.
629
+ *
630
+ * `base` is the same page as it was before anything intercepted it. Kept rather
631
+ * than re-derived, because a second interception from inside the first — the
632
+ * next photo, from a photo already open in the modal — has to start from the
633
+ * page underneath rather than from a page that already has a modal in it, and
634
+ * going back from the second to the first has to find that page where it left
635
+ * it.
636
+ */
637
+ export type Interception = {|
638
+ /** The URL the navigation went to: the one in the address bar. */
639
+ readonly pathname: string,
640
+ readonly search: string,
641
+ /** The route the navigation came from, as it was before it was intercepted. */
642
+ readonly base: ResolvedRoute,
608
643
  |};
609
644
 
610
645
  /**
@@ -629,6 +664,32 @@ export type ResolvedSlot = {|
629
664
  readonly templates: $ReadOnlyArray<ResolvedTemplate>,
630
665
  readonly errorBoundary: ?ResolvedSlotErrorBoundary,
631
666
  readonly slots: $ReadOnlyArray<ResolvedSlot>,
667
+ /**
668
+ * The table record this slot was resolved from.
669
+ *
670
+ * Carried so a client navigation can ask the slots *on screen* whether they
671
+ * intercept where it is going. Interception is a question about the page a
672
+ * navigation starts on, and this is that page's own answer rather than a
673
+ * second match of the table that could arrive at different slots. Absent on
674
+ * a slot written by hand, which then intercepts nothing.
675
+ */
676
+ readonly record?: SlotRecord,
677
+ /**
678
+ * Set when this slot renders an intercepting route, or sits inside one: the
679
+ * URL that was intercepted.
680
+ *
681
+ * The slot's keys and its page's `searchParams` come from here rather than
682
+ * from the route on screen, because the route on screen is the page the
683
+ * navigation started on. Keying the modal's templates on that page's
684
+ * pathname would leave the second photo mounted as the first one.
685
+ */
686
+ readonly intercepted?: ?InterceptedUrl,
687
+ |};
688
+
689
+ /** The URL an intercepting route was matched against. */
690
+ type InterceptedUrl = {|
691
+ readonly pathname: string,
692
+ readonly searchParams: SearchParams,
632
693
  |};
633
694
 
634
695
  // ---------------------------------------------------------------------------
@@ -863,12 +924,15 @@ async function resolveSlots(
863
924
  pathname: string,
864
925
  layoutCount: number,
865
926
  fallbackParams: RouteParams,
927
+ intercepted?: ?InterceptedUrl,
866
928
  ): Promise<$ReadOnlyArray<ResolvedSlot>> {
867
929
  if (records.length === 0) {
868
930
  return [];
869
931
  }
870
932
  return Promise.all(
871
- records.map((record) => resolveSlot(record, pathname, layoutCount, fallbackParams)),
933
+ records.map((record) =>
934
+ resolveSlot(record, pathname, layoutCount, fallbackParams, intercepted),
935
+ ),
872
936
  );
873
937
  }
874
938
 
@@ -877,6 +941,7 @@ async function resolveSlot(
877
941
  pathname: string,
878
942
  layoutCount: number,
879
943
  fallbackParams: RouteParams,
944
+ intercepted?: ?InterceptedUrl,
880
945
  ): Promise<ResolvedSlot> {
881
946
  // Clamped exactly as a template's `above` is, and for the same reason: a
882
947
  // hand-written table, or a `(group)` between the layout and the route, can
@@ -892,6 +957,8 @@ async function resolveSlot(
892
957
  templates: [],
893
958
  errorBoundary: null,
894
959
  slots: [],
960
+ record,
961
+ intercepted,
895
962
  };
896
963
 
897
964
  const matched = matchIn(record.routes, pathname);
@@ -913,6 +980,31 @@ async function resolveSlot(
913
980
  };
914
981
  }
915
982
 
983
+ return (await resolveSlotRoute(record, matched, above, pathname, intercepted)) ?? empty;
984
+ }
985
+
986
+ /**
987
+ * One route inside a slot, imported: what the slot renders for a match.
988
+ *
989
+ * Shared by the two ways a slot comes to render a route — one of its own
990
+ * `routes`, matched against the URL, and one of its `intercepts`, matched
991
+ * against where a client navigation is going — so an intercepting page is
992
+ * composed exactly the way every other slot page is: inside its own layouts,
993
+ * fallbacks, templates and error boundary, with the slots those layouts
994
+ * declare.
995
+ *
996
+ * `null` when the page or a layout will not import, and what that means is the
997
+ * caller's to say. For a match it is an empty slot, for the reason
998
+ * [`resolveSlots`] gives; for an interception it is no interception, and the
999
+ * navigation goes where the URL says instead.
1000
+ */
1001
+ async function resolveSlotRoute(
1002
+ record: SlotRecord,
1003
+ matched: RoutingRouteMatch<SlotRouteRecord>,
1004
+ above: number,
1005
+ pathname: string,
1006
+ intercepted: ?InterceptedUrl,
1007
+ ): Promise<?ResolvedSlot> {
916
1008
  const route = matched.route;
917
1009
  // Started together and awaited apart, so the two `await`s are not a
918
1010
  // waterfall and each keeps the type its loader had.
@@ -921,11 +1013,11 @@ async function resolveSlot(
921
1013
  const page = await pending;
922
1014
  const layouts = await pendingLayouts;
923
1015
  if (page == null) {
924
- return empty;
1016
+ return null;
925
1017
  }
926
1018
  const loaded = layouts.filter(Boolean);
927
1019
  if (loaded.length !== layouts.length) {
928
- return empty;
1020
+ return null;
929
1021
  }
930
1022
  const loading = await resolveLoadingRecords(route.loading ?? [], loaded.length);
931
1023
  const templates = await resolveTemplateRecords(route.templates ?? [], loaded.length);
@@ -940,11 +1032,199 @@ async function resolveSlot(
940
1032
  templates,
941
1033
  errorBoundary,
942
1034
  // The slot's own layouts are what a nested slot is measured against, so
943
- // the count handed down is this slot's rather than the route's.
944
- slots: await resolveSlots(route.slots, pathname, loaded.length, matched.params),
1035
+ // the count handed down is this slot's rather than the route's. A slot
1036
+ // nested inside an interception is matched against the intercepted URL,
1037
+ // and keyed on it, for the same reason the interception is.
1038
+ slots: await resolveSlots(route.slots, pathname, loaded.length, matched.params, intercepted),
1039
+ record,
1040
+ intercepted,
1041
+ };
1042
+ }
1043
+
1044
+ // ---------------------------------------------------------------------------
1045
+ // Interception
1046
+ // ---------------------------------------------------------------------------
1047
+
1048
+ /**
1049
+ * The page underneath: `resolved` itself, or what it was before an interception
1050
+ * put something in one of its slots.
1051
+ *
1052
+ * Every question about where a navigation *starts* is asked of this rather than
1053
+ * of the route on screen, because an interception is not a place a navigation
1054
+ * can start from. The next photo, opened from inside the modal, is intercepted
1055
+ * from the feed.
1056
+ */
1057
+ function beneath(resolved: ResolvedRoute): ResolvedRoute {
1058
+ return resolved.interception?.base ?? resolved;
1059
+ }
1060
+
1061
+ /**
1062
+ * The intercepting routes the slots on screen have for `pathname`.
1063
+ *
1064
+ * Only the slots on screen, and that is the whole of what "a navigation from
1065
+ * inside `/feed`" means. A page under `app/feed/$layout.js` renders the layout
1066
+ * that declares `@modal`, so the slot is in its tree and so are the slot's
1067
+ * `intercepts`. A page outside that segment has no such slot in its tree —
1068
+ * which is why a link to `/feed/photo/1` from `/about` is an ordinary
1069
+ * navigation to the photo page, not an interception with nowhere to render.
1070
+ *
1071
+ * A slot that intercepts `pathname` is not looked inside: what it holds is
1072
+ * about to be replaced, nested slots and all.
1073
+ */
1074
+ function interceptingRoutes(
1075
+ slots: $ReadOnlyArray<ResolvedSlot>,
1076
+ pathname: string,
1077
+ ): $ReadOnlyArray<SlotRouteRecord> {
1078
+ const found: Array<SlotRouteRecord> = [];
1079
+ for (const slot of slots) {
1080
+ const matched = matchIn(slot.record?.intercepts ?? [], pathname);
1081
+ if (matched != null) {
1082
+ found.push(matched.route);
1083
+ } else {
1084
+ found.push(...interceptingRoutes(slot.slots, pathname));
1085
+ }
1086
+ }
1087
+ return found;
1088
+ }
1089
+
1090
+ /**
1091
+ * `base`, with every slot on it that intercepts `url` rendering what it
1092
+ * intercepts — or `null` when none of them does.
1093
+ *
1094
+ * # What stays, and what does not
1095
+ *
1096
+ * Everything that is not an intercepting slot stays exactly as it was, and that
1097
+ * is the feature rather than a shortcut. `children` goes on rendering the page
1098
+ * the reader navigated *from* — its data, its scroll position, whatever state
1099
+ * its components are holding — and every other slot keeps what it was showing.
1100
+ * An intercepted navigation changes the address bar and the slots that
1101
+ * intercept it, and nothing else. Matching the rest against the new URL would
1102
+ * be an ordinary navigation with a modal on top of it: the page underneath
1103
+ * swapped for the page the URL names, which is precisely what interception
1104
+ * exists not to do.
1105
+ *
1106
+ * Every slot that intercepts the URL renders it, not only the first, because
1107
+ * slots are independent of each other: two named places may each have
1108
+ * something to show for one URL, the way two slots each match one URL by their
1109
+ * own routes.
1110
+ *
1111
+ * # Never on a server
1112
+ *
1113
+ * Nothing on the server calls this. A document request for an intercepted URL
1114
+ * resolves the ordinary page, because a request carries where it is going and
1115
+ * not what was on screen when it was made — which is what a reload, a shared
1116
+ * link and a crawler all are.
1117
+ *
1118
+ * # When the interception cannot render
1119
+ *
1120
+ * A slot whose intercepting page will not import keeps what it had, and when no
1121
+ * slot could render the interception this answers `null`: the navigation goes
1122
+ * ahead as an ordinary one, and the reader gets the page the URL names — what a
1123
+ * reload would have given them — rather than a click that did nothing. An
1124
+ * intercepting page that exports a `loader`, which no slot page may, is the
1125
+ * error boundary for the URL, the way any other slot page's is.
1126
+ */
1127
+ async function resolveInterception(
1128
+ table: RouteTable,
1129
+ base: ResolvedRoute,
1130
+ url: string,
1131
+ ): Promise<?ResolvedRoute> {
1132
+ const { pathname, search } = splitUrl(url);
1133
+ const intercepted: InterceptedUrl = { pathname, searchParams: parseSearch(search) };
1134
+ // The first slot that renders the interception, for the transition's name.
1135
+ let first: ?ResolvedSlot = null;
1136
+ const visit = (slots: $ReadOnlyArray<ResolvedSlot>): Promise<$ReadOnlyArray<ResolvedSlot>> =>
1137
+ Promise.all(
1138
+ slots.map(async (slot): Promise<ResolvedSlot> => {
1139
+ const record = slot.record;
1140
+ const matched = record == null ? null : matchIn(record.intercepts ?? [], pathname);
1141
+ if (record == null || matched == null) {
1142
+ return slot.slots.length === 0 ? slot : { ...slot, slots: await visit(slot.slots) };
1143
+ }
1144
+ const rendered = await resolveSlotRoute(record, matched, slot.above, pathname, intercepted);
1145
+ if (rendered == null) {
1146
+ return slot;
1147
+ }
1148
+ first = first ?? rendered;
1149
+ return rendered;
1150
+ }),
1151
+ );
1152
+
1153
+ let slots: $ReadOnlyArray<ResolvedSlot>;
1154
+ try {
1155
+ slots = await visit(base.slots);
1156
+ } catch (error) {
1157
+ return resolveFailure(table, url, error);
1158
+ }
1159
+ if (first == null) {
1160
+ return null;
1161
+ }
1162
+ return {
1163
+ ...base,
1164
+ slots,
1165
+ // The intercepting page's own name, where it or a layout inside the slot
1166
+ // declares one, so a stylesheet can tell a modal opening from a page
1167
+ // arriving. The page underneath has not moved, so its name would say
1168
+ // nothing about this arrival.
1169
+ viewTransition: resolveViewTransition(first.page ?? {}, first.layouts),
1170
+ interception: { pathname, search, base },
945
1171
  };
946
1172
  }
947
1173
 
1174
+ /**
1175
+ * The key an intercepted navigation writes into its history entry.
1176
+ *
1177
+ * One string in `history.state` rather than the resolved route, because the
1178
+ * browser structured-clones the state and keeps it across a reload: it can hold
1179
+ * a URL and nothing with a module in it. A URL is also all the entry needs —
1180
+ * where the navigation came from, resolved again when that page is not the one
1181
+ * on screen, and the entry's own URL for what intercepted it.
1182
+ */
1183
+ const INTERCEPTED_FROM = "uf:intercepted-from";
1184
+
1185
+ /**
1186
+ * The state a history entry for `resolved` is written with.
1187
+ *
1188
+ * `null` for a navigation nothing intercepted, which is what every entry this
1189
+ * router wrote was before interception existed.
1190
+ */
1191
+ function historyStateFor(resolved: ResolvedRoute): mixed {
1192
+ const interception = resolved.interception;
1193
+ if (interception == null) {
1194
+ return null;
1195
+ }
1196
+ return { [INTERCEPTED_FROM]: interception.base.pathname + interception.base.search };
1197
+ }
1198
+
1199
+ /** Where the history entry holding `state` was intercepted from, if it was. */
1200
+ function interceptedFrom(state: mixed): ?string {
1201
+ if (state == null || typeof state !== "object" || Array.isArray(state)) {
1202
+ return null;
1203
+ }
1204
+ const from = state[INTERCEPTED_FROM];
1205
+ return typeof from === "string" ? from : null;
1206
+ }
1207
+
1208
+ /**
1209
+ * `state` without the interception in it.
1210
+ *
1211
+ * What is left is handed back rather than cleared, because an entry's state is
1212
+ * not only this router's to write: another library may have put something
1213
+ * beside it.
1214
+ */
1215
+ function withoutInterception(state: mixed): mixed {
1216
+ if (state == null || typeof state !== "object" || Array.isArray(state)) {
1217
+ return state;
1218
+ }
1219
+ const rest: { [string]: mixed } = {};
1220
+ for (const key of Object.keys(state)) {
1221
+ if (key !== INTERCEPTED_FROM) {
1222
+ rest[key] = state[key];
1223
+ }
1224
+ }
1225
+ return Object.keys(rest).length === 0 ? null : rest;
1226
+ }
1227
+
948
1228
  async function resolveSlotErrorBoundary(
949
1229
  boundary: ?SlotErrorBoundaryLoader,
950
1230
  layoutCount: number,
@@ -1883,6 +2163,20 @@ export component RouterProvider(url: string, initial: ResolvedRoute, children: R
1883
2163
  // that asked at click time would be asking a question whose answer decided
1884
2164
  // what it rendered.
1885
2165
  const navigation = navigationMode();
2166
+ // The route on screen, for the code that runs after a render has finished.
2167
+ //
2168
+ // State is what renders, and a closure only sees the state of the render that
2169
+ // made it: the `popstate` listener below is installed once and would go on
2170
+ // reading the first route forever, and a navigation awaits between reading
2171
+ // what is on screen and replacing it. Interception is what needs the answer —
2172
+ // whether a navigation is intercepted is a question about the page it starts
2173
+ // on — and `show` writes both in the same breath, so the two cannot disagree
2174
+ // about what was last committed.
2175
+ const shown = React.useRef<ResolvedRoute>(initial);
2176
+ const show = (next: ResolvedRoute) => {
2177
+ shown.current = next;
2178
+ setResolved(next);
2179
+ };
1886
2180
 
1887
2181
  const navigate = async (to: string, options?: NavigateOptions): Promise<void> => {
1888
2182
  if (!isBrowser()) {
@@ -1903,6 +2197,13 @@ export component RouterProvider(url: string, initial: ResolvedRoute, children: R
1903
2197
  }
1904
2198
  return;
1905
2199
  }
2200
+ // Interception first, because it is a question about the page this
2201
+ // navigation starts on rather than about the one it reaches. A slot on
2202
+ // screen that intercepts the URL renders a page of its own, so whether the
2203
+ // URL's ordinary page is in this bundle — the paragraph below — is not a
2204
+ // question this navigation has to ask.
2205
+ const origin = beneath(shown.current);
2206
+ const intercepting = interceptingRoutes(origin.slots, target.pathname).length > 0;
1906
2207
  // The half of the split that is not about bytes. A route whose page is not
1907
2208
  // in this bundle is not a route this router can render, and pretending
1908
2209
  // otherwise is the silent break: the navigation would resolve to nothing
@@ -1910,21 +2211,29 @@ export component RouterProvider(url: string, initial: ResolvedRoute, children: R
1910
2211
  // has the document, so the browser does the navigation — which is what a
1911
2212
  // link does when there is no JavaScript at all, and what the anchor
1912
2213
  // `Link` renders would have done on its own.
1913
- const matched = matchRoute(routeTable().routes, target.pathname);
1914
- if (matched != null && !hasClientPage(matched.route)) {
1915
- window.location.assign(target.href);
1916
- return;
2214
+ if (!intercepting) {
2215
+ const matched = matchRoute(routeTable().routes, target.pathname);
2216
+ if (matched != null && !hasClientPage(matched.route)) {
2217
+ window.location.assign(target.href);
2218
+ return;
2219
+ }
1917
2220
  }
1918
2221
  setPending(true);
1919
2222
  try {
1920
- const nextResolved = await resolveMatch(routeTable(), next);
2223
+ const nextResolved =
2224
+ (intercepting ? await resolveInterception(routeTable(), origin, next) : null) ??
2225
+ (await resolveMatch(routeTable(), next));
2226
+ // An intercepted entry remembers where it was intercepted from, so back
2227
+ // and forward can put the page underneath under it again. Every other
2228
+ // entry is written the way it always was.
2229
+ const state = historyStateFor(nextResolved);
1921
2230
  if (options?.replace === true) {
1922
- window.history.replaceState(null, "", next + target.hash);
2231
+ window.history.replaceState(state, "", next + target.hash);
1923
2232
  } else {
1924
- window.history.pushState(null, "", next + target.hash);
2233
+ window.history.pushState(state, "", next + target.hash);
1925
2234
  }
1926
2235
  const commit = () => {
1927
- setResolved(nextResolved);
2236
+ show(nextResolved);
1928
2237
  setPending(false);
1929
2238
  };
1930
2239
  if (options?.transition === false) {
@@ -1932,7 +2241,13 @@ export component RouterProvider(url: string, initial: ResolvedRoute, children: R
1932
2241
  } else {
1933
2242
  withViewTransition(nextResolved.viewTransition, commit);
1934
2243
  }
1935
- if (options?.scroll !== false) {
2244
+ // An intercepted navigation leaves the page underneath where the reader
2245
+ // left it — the modal opens over the post they clicked, not over the top
2246
+ // of the feed — so it moves the window only for a caller who asks with
2247
+ // `scroll: true`. Every other navigation scrolls unless asked not to.
2248
+ const scroll =
2249
+ nextResolved.interception == null ? options?.scroll !== false : options?.scroll === true;
2250
+ if (scroll) {
1936
2251
  if (target.hash !== "") {
1937
2252
  const element = document.getElementById(target.hash.slice(1));
1938
2253
  if (element != null) {
@@ -1952,6 +2267,16 @@ export component RouterProvider(url: string, initial: ResolvedRoute, children: R
1952
2267
  if (!isBrowser()) {
1953
2268
  return undefined;
1954
2269
  }
2270
+ // An entry that says it was intercepted, under a provider that has only
2271
+ // just mounted, is an entry the browser reloaded or restored — and the
2272
+ // document on screen is what a request for its URL returned, which is the
2273
+ // ordinary page. Clearing the mark makes the entry say what the reader is
2274
+ // looking at, so coming back to it later renders this page again rather
2275
+ // than a modal over a page they never saw one on.
2276
+ const restored = window.history.state;
2277
+ if (interceptedFrom(restored) != null) {
2278
+ window.history.replaceState(withoutInterception(restored), "", window.location.href);
2279
+ }
1955
2280
  // Nothing pushed a history entry, so there is nothing to pop back into: a
1956
2281
  // document-navigating application left this page when the link was
1957
2282
  // followed, and the back button asks the browser for the previous document
@@ -1961,8 +2286,37 @@ export component RouterProvider(url: string, initial: ResolvedRoute, children: R
1961
2286
  if (navigation === "document") {
1962
2287
  return undefined;
1963
2288
  }
2289
+ const arrive = (nextResolved: ResolvedRoute) => {
2290
+ // The back button is a navigation, and a navigation that animates in
2291
+ // one direction and cuts in the other would read as a bug in the
2292
+ // animation rather than as a decision.
2293
+ withViewTransition(nextResolved.viewTransition, () => {
2294
+ show(nextResolved);
2295
+ });
2296
+ };
1964
2297
  const onPopState = () => {
1965
2298
  const next = window.location.pathname + window.location.search;
2299
+ // Back or forward into an entry an interception wrote: the page it was
2300
+ // intercepted from, with the interception over it again. That page is
2301
+ // resolved afresh only when it is not already the one underneath, so
2302
+ // back from the second photo to the first leaves the feed exactly where
2303
+ // it is.
2304
+ const from = interceptedFrom(window.history.state);
2305
+ if (from != null) {
2306
+ const underneath = beneath(shown.current);
2307
+ const origin =
2308
+ underneath.pathname + underneath.search === from
2309
+ ? Promise.resolve(underneath)
2310
+ : resolveMatch(routeTable(), from);
2311
+ origin
2312
+ .then((page) => resolveInterception(routeTable(), page, next))
2313
+ // Nothing on that page intercepts the entry's URL any more — a
2314
+ // module that will not load, a table a development server rebuilt —
2315
+ // so the entry is what its URL names.
2316
+ .then((intercepted) => intercepted ?? resolveMatch(routeTable(), next))
2317
+ .then(arrive);
2318
+ return;
2319
+ }
1966
2320
  // Back into a route this bundle has no page for. The history entry is
1967
2321
  // already the browser's — it moved before this listener ran — so the
1968
2322
  // document that belongs to it is what has to be fetched.
@@ -1971,14 +2325,7 @@ export component RouterProvider(url: string, initial: ResolvedRoute, children: R
1971
2325
  window.location.reload();
1972
2326
  return;
1973
2327
  }
1974
- resolveMatch(routeTable(), next).then((nextResolved) => {
1975
- // The back button is a navigation, and a navigation that animates in
1976
- // one direction and cuts in the other would read as a bug in the
1977
- // animation rather than as a decision.
1978
- withViewTransition(nextResolved.viewTransition, () => {
1979
- setResolved(nextResolved);
1980
- });
1981
- });
2328
+ resolveMatch(routeTable(), next).then(arrive);
1982
2329
  };
1983
2330
  window.addEventListener("popstate", onPopState);
1984
2331
  return () => {
@@ -1999,6 +2346,19 @@ export component RouterProvider(url: string, initial: ResolvedRoute, children: R
1999
2346
  return;
2000
2347
  }
2001
2348
  const target = new URL(to, window.location.href);
2349
+ // What the next render will need is decided the way the navigation will
2350
+ // decide it: a URL a slot on screen intercepts renders that slot's page,
2351
+ // so that is the module worth having, and the page the URL names is not.
2352
+ const intercepting = interceptingRoutes(beneath(shown.current).slots, target.pathname);
2353
+ if (intercepting.length > 0) {
2354
+ await Promise.all(
2355
+ intercepting.flatMap((route) => [
2356
+ loadOnce(route.page),
2357
+ ...route.layouts.map((layout) => loadOnce(layout)),
2358
+ ]),
2359
+ );
2360
+ return;
2361
+ }
2002
2362
  const matched = matchRoute(routeTable().routes, target.pathname);
2003
2363
  const load = matched?.route.page;
2004
2364
  if (matched == null || load == null) {
@@ -2021,16 +2381,28 @@ export component RouterProvider(url: string, initial: ResolvedRoute, children: R
2021
2381
  window.location.reload();
2022
2382
  return;
2023
2383
  }
2024
- const nextResolved = await resolveMatch(
2025
- routeTable(),
2026
- window.location.pathname + window.location.search,
2027
- );
2384
+ // A refresh of an intercepted page refreshes both of its halves: the
2385
+ // page underneath, resolved again for its own URL, and the interception
2386
+ // resolved again over it. Resolving only the address bar's URL would
2387
+ // close the modal, which is a navigation nobody asked for.
2388
+ const interception = shown.current.interception;
2389
+ const nextResolved =
2390
+ interception == null
2391
+ ? await resolveMatch(routeTable(), window.location.pathname + window.location.search)
2392
+ : ((await resolveInterception(
2393
+ routeTable(),
2394
+ await resolveMatch(
2395
+ routeTable(),
2396
+ interception.base.pathname + interception.base.search,
2397
+ ),
2398
+ interception.pathname + interception.search,
2399
+ )) ?? (await resolveMatch(routeTable(), interception.pathname + interception.search)));
2028
2400
  // No view transition, and it is the one place that is right: a refresh
2029
2401
  // is the same URL resolved again, so a transition would animate a page
2030
2402
  // into itself — a cross-fade between two frames of the same thing,
2031
2403
  // which is a flicker with a name.
2032
2404
  startTransition(() => {
2033
- setResolved(nextResolved);
2405
+ show(nextResolved);
2034
2406
  });
2035
2407
  },
2036
2408
  back: () => {
@@ -2669,12 +3041,19 @@ component SlotView(slot: ResolvedSlot) {
2669
3041
  if (page == null) {
2670
3042
  return null;
2671
3043
  }
3044
+ // Except in an interception, whose URL is not the one the route on screen was
3045
+ // resolved for. The page it renders reads the intercepted URL's query, and
3046
+ // its templates and error boundary are keyed on the intercepted pathname — so
3047
+ // a second photo opened in the modal remounts what the first one mounted, the
3048
+ // way a navigation between two pages does.
3049
+ const pathname = slot.intercepted?.pathname ?? resolved.pathname;
3050
+ const searchParams = slot.intercepted?.searchParams ?? resolved.searchParams;
2672
3051
  const Page = pageComponent(page);
2673
3052
  let element: React.Node = (
2674
- <Page params={slot.params} searchParams={resolved.searchParams} data={undefined} />
3053
+ <Page params={slot.params} searchParams={searchParams} data={undefined} />
2675
3054
  );
2676
3055
  const templateContext = {
2677
- pathname: resolved.pathname,
3056
+ pathname,
2678
3057
  params: slot.params,
2679
3058
  templates: slot.templates,
2680
3059
  };
@@ -2690,10 +3069,7 @@ component SlotView(slot: ResolvedSlot) {
2690
3069
  const errorBoundary = slot.errorBoundary;
2691
3070
  if (errorBoundary != null && errorBoundary.above === depth) {
2692
3071
  element = (
2693
- <RouteErrorBoundary
2694
- module={errorBoundary.module}
2695
- resetKey={`${resolved.pathname}:${slot.name}`}
2696
- >
3072
+ <RouteErrorBoundary module={errorBoundary.module} resetKey={`${pathname}:${slot.name}`}>
2697
3073
  {element}
2698
3074
  </RouteErrorBoundary>
2699
3075
  );
package/native.js CHANGED
@@ -8,6 +8,23 @@
8
8
  // native bundle cannot render, and hands a navigator-shaped event to the app's
9
9
  // own navigation runtime. Rendering the tree is still the renderer/host-config
10
10
  // half of the React Native target.
11
+ //
12
+ // # No interception here, deliberately
13
+ //
14
+ // An intercepting route — a slot's `(.)photo` — is not one of this adapter's
15
+ // features, and that is a decision rather than a gap. What interception does
16
+ // is a browser's: the address bar says `/feed/photo/1` while the page
17
+ // underneath stays on screen with the photo in a slot over it, and a reload
18
+ // renders the page the URL names instead. A native navigator has no address bar
19
+ // for the screen to disagree with, and it already has the thing interception
20
+ // imitates — a screen presented modally over the one below it, with its own
21
+ // back gesture. Doing both would give a native app two answers to "open this
22
+ // over the feed", one of them the router's and one the navigator's.
23
+ //
24
+ // So `resolveNativeNavigation` matches `table.routes` and nothing else:
25
+ // `/feed/photo/1` resolves to the ordinary `app/feed/photo/[id]` route every
26
+ // time, a slot's `intercepts` are never read, and presenting that screen as a
27
+ // modal is the navigator's call, made where the rest of its presentation is.
11
28
 
12
29
  import type { RouteParams, RouteRecord, RouteTable } from "./internal/routing.js";
13
30
  import { hasClientPage, matchRoute, splitUrl } from "./internal/routing.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniflowed/router",
3
- "version": "0.0.0-alpha.33",
3
+ "version": "0.0.0-alpha.34",
4
4
  "description": "The file-system router for Flow React applications: matching, layouts, loaders, navigation, server rendering and hydration.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -38,7 +38,7 @@
38
38
  "react-dom": ">=19"
39
39
  },
40
40
  "dependencies": {
41
- "@uniflowed/hooks": "0.0.0-alpha.33",
42
- "@uniflowed/server": "0.0.0-alpha.33"
41
+ "@uniflowed/hooks": "0.0.0-alpha.34",
42
+ "@uniflowed/server": "0.0.0-alpha.34"
43
43
  }
44
44
  }
package/server.js CHANGED
@@ -181,13 +181,13 @@ export { DATA_ID, ROOT_ID } from "./internal/document.js";
181
181
  *
182
182
  * Re-exported rather than left to the host to import, and the reason is the
183
183
  * one thing about `@uniflowed/server` that is easy to get wrong: the request
184
- * lives in an `AsyncLocalStorage` belonging to *that module instance*. A host
185
- * that resolved `@uniflowed/server/host` for itself — from its own
186
- * `node_modules`, or from outside the bundle a build produced — would begin a
187
- * request in a second storage, and every `cookies()` in the application would
188
- * still be outside one, silently. Handing it out from here makes the copy the
189
- * host begins with the copy this module dispatches and renders with, because
190
- * it is the same import.
184
+ * store is shared by every copy of one *release* of that package, and no more.
185
+ * A host that resolved `@uniflowed/server/host` for itself — from its own
186
+ * `node_modules`, or from outside the bundle a build produced — may hold a
187
+ * different release, and would begin a request in a store the application
188
+ * never reads, so every `cookies()` in it would still be outside one, silently.
189
+ * Handing it out from here makes the copy the host begins with the copy this
190
+ * module dispatches and renders with, because it is the same import.
191
191
  *
192
192
  * `run` wraps everything that decides the response; `settle` is called once
193
193
  * the response has been *written*, which is a different line in every host.