@uniflowed/router 0.0.0-alpha.32 → 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.
@@ -463,11 +463,24 @@ export type RouteRecord = RoutingRouteRecord<
463
463
  LayoutModule,
464
464
  TemplateModule,
465
465
  LoadingModule,
466
+ ErrorModule,
466
467
  >;
467
468
 
468
- export type SlotRecord = RoutingSlotRecord<PageModule, LayoutModule>;
469
+ export type SlotRecord = RoutingSlotRecord<
470
+ PageModule,
471
+ LayoutModule,
472
+ TemplateModule,
473
+ LoadingModule,
474
+ ErrorModule,
475
+ >;
469
476
 
470
- export type SlotRouteRecord = RoutingSlotRouteRecord<PageModule, LayoutModule>;
477
+ export type SlotRouteRecord = RoutingSlotRouteRecord<
478
+ PageModule,
479
+ LayoutModule,
480
+ TemplateModule,
481
+ LoadingModule,
482
+ ErrorModule,
483
+ >;
471
484
 
472
485
  export type TemplateRecord = RoutingTemplateRecord<TemplateModule>;
473
486
 
@@ -487,6 +500,21 @@ export type RouteTable = RoutingRouteTable<
487
500
 
488
501
  export type RouteMatch = RoutingRouteMatch<RouteRecord>;
489
502
 
503
+ type ResolvedTemplate = {|
504
+ readonly above: number,
505
+ readonly module: TemplateModule,
506
+ |};
507
+
508
+ type ResolvedSlotErrorBoundary = {|
509
+ readonly above: number,
510
+ readonly module: ?ErrorModule,
511
+ |};
512
+
513
+ type SlotErrorBoundaryLoader = {|
514
+ readonly above: number,
515
+ readonly module: () => Promise<ErrorModule>,
516
+ |};
517
+
490
518
  /**
491
519
  * A match whose modules are loaded and whose loader has run or is running — or,
492
520
  * when `error` is set, the error page that stands in for it.
@@ -567,10 +595,7 @@ export type ResolvedRoute = {|
567
595
  * than walked to, and templates are accumulated on the walk down to a route
568
596
  * the URL never reached.
569
597
  */
570
- readonly templates: $ReadOnlyArray<{|
571
- readonly above: number,
572
- readonly module: TemplateModule,
573
- |}>,
598
+ readonly templates: $ReadOnlyArray<ResolvedTemplate>,
574
599
  /**
575
600
  * The slots this route renders, outermost first, already imported.
576
601
  *
@@ -580,6 +605,41 @@ export type ResolvedRoute = {|
580
605
  * belongs to the segment the walk went through.
581
606
  */
582
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,
583
643
  |};
584
644
 
585
645
  /**
@@ -600,7 +660,36 @@ export type ResolvedSlot = {|
600
660
  readonly page: ?PageModule,
601
661
  readonly params: RouteParams,
602
662
  readonly layouts: $ReadOnlyArray<LayoutModule>,
663
+ readonly loading: $ReadOnlyArray<{| readonly above: number, readonly module: LoadingModule |}>,
664
+ readonly templates: $ReadOnlyArray<ResolvedTemplate>,
665
+ readonly errorBoundary: ?ResolvedSlotErrorBoundary,
603
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,
604
693
  |};
605
694
 
606
695
  // ---------------------------------------------------------------------------
@@ -835,12 +924,15 @@ async function resolveSlots(
835
924
  pathname: string,
836
925
  layoutCount: number,
837
926
  fallbackParams: RouteParams,
927
+ intercepted?: ?InterceptedUrl,
838
928
  ): Promise<$ReadOnlyArray<ResolvedSlot>> {
839
929
  if (records.length === 0) {
840
930
  return [];
841
931
  }
842
932
  return Promise.all(
843
- records.map((record) => resolveSlot(record, pathname, layoutCount, fallbackParams)),
933
+ records.map((record) =>
934
+ resolveSlot(record, pathname, layoutCount, fallbackParams, intercepted),
935
+ ),
844
936
  );
845
937
  }
846
938
 
@@ -849,6 +941,7 @@ async function resolveSlot(
849
941
  pathname: string,
850
942
  layoutCount: number,
851
943
  fallbackParams: RouteParams,
944
+ intercepted?: ?InterceptedUrl,
852
945
  ): Promise<ResolvedSlot> {
853
946
  // Clamped exactly as a template's `above` is, and for the same reason: a
854
947
  // hand-written table, or a `(group)` between the layout and the route, can
@@ -860,7 +953,12 @@ async function resolveSlot(
860
953
  page: null,
861
954
  params: fallbackParams,
862
955
  layouts: [],
956
+ loading: [],
957
+ templates: [],
958
+ errorBoundary: null,
863
959
  slots: [],
960
+ record,
961
+ intercepted,
864
962
  };
865
963
 
866
964
  const matched = matchIn(record.routes, pathname);
@@ -875,9 +973,38 @@ async function resolveSlot(
875
973
  if (module == null) {
876
974
  return empty;
877
975
  }
878
- return { ...empty, page: withoutLoader(module, record.defaultFile ?? record.name) };
976
+ return {
977
+ ...empty,
978
+ page: withoutLoader(module, record.defaultFile ?? record.name),
979
+ errorBoundary: await resolveSlotErrorBoundary(record.defaultErrorBoundary ?? null, 0),
980
+ };
879
981
  }
880
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> {
881
1008
  const route = matched.route;
882
1009
  // Started together and awaited apart, so the two `await`s are not a
883
1010
  // waterfall and each keeps the type its loader had.
@@ -886,24 +1013,236 @@ async function resolveSlot(
886
1013
  const page = await pending;
887
1014
  const layouts = await pendingLayouts;
888
1015
  if (page == null) {
889
- return empty;
1016
+ return null;
890
1017
  }
891
1018
  const loaded = layouts.filter(Boolean);
892
1019
  if (loaded.length !== layouts.length) {
893
- return empty;
1020
+ return null;
894
1021
  }
1022
+ const loading = await resolveLoadingRecords(route.loading ?? [], loaded.length);
1023
+ const templates = await resolveTemplateRecords(route.templates ?? [], loaded.length);
1024
+ const errorBoundary = await resolveSlotErrorBoundary(route.errorBoundary ?? null, loaded.length);
895
1025
  return {
896
1026
  name: record.name,
897
1027
  above,
898
1028
  page: withoutLoader(page, route.file),
899
1029
  params: matched.params,
900
1030
  layouts: loaded,
1031
+ loading,
1032
+ templates,
1033
+ errorBoundary,
901
1034
  // The slot's own layouts are what a nested slot is measured against, so
902
- // the count handed down is this slot's rather than the route's.
903
- 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 },
904
1171
  };
905
1172
  }
906
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
+
1228
+ async function resolveSlotErrorBoundary(
1229
+ boundary: ?SlotErrorBoundaryLoader,
1230
+ layoutCount: number,
1231
+ ): Promise<?ResolvedSlotErrorBoundary> {
1232
+ if (boundary == null) {
1233
+ return null;
1234
+ }
1235
+ const above = Math.min(boundary.above, layoutCount);
1236
+ try {
1237
+ return { module: await loadOnce(boundary.module), above };
1238
+ } catch {
1239
+ // Keep the declared depth even when the custom file fails to import. The
1240
+ // framework fallback still contains the slot instead of escalating the
1241
+ // page beside it.
1242
+ return { module: null, above };
1243
+ }
1244
+ }
1245
+
907
1246
  /**
908
1247
  * A module, or `null` when it would not import.
909
1248
  *
@@ -963,8 +1302,14 @@ function withoutLoader(module: PageModule, file: string): PageModule {
963
1302
  async function resolveTemplates(
964
1303
  route: RouteRecord,
965
1304
  layoutCount: number,
966
- ): Promise<$ReadOnlyArray<{| readonly above: number, readonly module: TemplateModule |}>> {
967
- const records = route.templates ?? [];
1305
+ ): Promise<$ReadOnlyArray<ResolvedTemplate>> {
1306
+ return resolveTemplateRecords(route.templates ?? [], layoutCount);
1307
+ }
1308
+
1309
+ async function resolveTemplateRecords(
1310
+ records: $ReadOnlyArray<TemplateRecord>,
1311
+ layoutCount: number,
1312
+ ): Promise<$ReadOnlyArray<ResolvedTemplate>> {
968
1313
  if (records.length === 0) {
969
1314
  return [];
970
1315
  }
@@ -1000,7 +1345,13 @@ async function resolveLoading(
1000
1345
  route: RouteRecord,
1001
1346
  layoutCount: number,
1002
1347
  ): Promise<$ReadOnlyArray<{| readonly above: number, readonly module: LoadingModule |}>> {
1003
- const records = route.loading ?? [];
1348
+ return resolveLoadingRecords(route.loading ?? [], layoutCount);
1349
+ }
1350
+
1351
+ async function resolveLoadingRecords(
1352
+ records: $ReadOnlyArray<LoadingRecord>,
1353
+ layoutCount: number,
1354
+ ): Promise<$ReadOnlyArray<{| readonly above: number, readonly module: LoadingModule |}>> {
1004
1355
  if (records.length === 0) {
1005
1356
  return [];
1006
1357
  }
@@ -1812,6 +2163,20 @@ export component RouterProvider(url: string, initial: ResolvedRoute, children: R
1812
2163
  // that asked at click time would be asking a question whose answer decided
1813
2164
  // what it rendered.
1814
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
+ };
1815
2180
 
1816
2181
  const navigate = async (to: string, options?: NavigateOptions): Promise<void> => {
1817
2182
  if (!isBrowser()) {
@@ -1832,6 +2197,13 @@ export component RouterProvider(url: string, initial: ResolvedRoute, children: R
1832
2197
  }
1833
2198
  return;
1834
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;
1835
2207
  // The half of the split that is not about bytes. A route whose page is not
1836
2208
  // in this bundle is not a route this router can render, and pretending
1837
2209
  // otherwise is the silent break: the navigation would resolve to nothing
@@ -1839,21 +2211,29 @@ export component RouterProvider(url: string, initial: ResolvedRoute, children: R
1839
2211
  // has the document, so the browser does the navigation — which is what a
1840
2212
  // link does when there is no JavaScript at all, and what the anchor
1841
2213
  // `Link` renders would have done on its own.
1842
- const matched = matchRoute(routeTable().routes, target.pathname);
1843
- if (matched != null && !hasClientPage(matched.route)) {
1844
- window.location.assign(target.href);
1845
- 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
+ }
1846
2220
  }
1847
2221
  setPending(true);
1848
2222
  try {
1849
- 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);
1850
2230
  if (options?.replace === true) {
1851
- window.history.replaceState(null, "", next + target.hash);
2231
+ window.history.replaceState(state, "", next + target.hash);
1852
2232
  } else {
1853
- window.history.pushState(null, "", next + target.hash);
2233
+ window.history.pushState(state, "", next + target.hash);
1854
2234
  }
1855
2235
  const commit = () => {
1856
- setResolved(nextResolved);
2236
+ show(nextResolved);
1857
2237
  setPending(false);
1858
2238
  };
1859
2239
  if (options?.transition === false) {
@@ -1861,7 +2241,13 @@ export component RouterProvider(url: string, initial: ResolvedRoute, children: R
1861
2241
  } else {
1862
2242
  withViewTransition(nextResolved.viewTransition, commit);
1863
2243
  }
1864
- 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) {
1865
2251
  if (target.hash !== "") {
1866
2252
  const element = document.getElementById(target.hash.slice(1));
1867
2253
  if (element != null) {
@@ -1881,6 +2267,16 @@ export component RouterProvider(url: string, initial: ResolvedRoute, children: R
1881
2267
  if (!isBrowser()) {
1882
2268
  return undefined;
1883
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
+ }
1884
2280
  // Nothing pushed a history entry, so there is nothing to pop back into: a
1885
2281
  // document-navigating application left this page when the link was
1886
2282
  // followed, and the back button asks the browser for the previous document
@@ -1890,8 +2286,37 @@ export component RouterProvider(url: string, initial: ResolvedRoute, children: R
1890
2286
  if (navigation === "document") {
1891
2287
  return undefined;
1892
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
+ };
1893
2297
  const onPopState = () => {
1894
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
+ }
1895
2320
  // Back into a route this bundle has no page for. The history entry is
1896
2321
  // already the browser's — it moved before this listener ran — so the
1897
2322
  // document that belongs to it is what has to be fetched.
@@ -1900,14 +2325,7 @@ export component RouterProvider(url: string, initial: ResolvedRoute, children: R
1900
2325
  window.location.reload();
1901
2326
  return;
1902
2327
  }
1903
- resolveMatch(routeTable(), next).then((nextResolved) => {
1904
- // The back button is a navigation, and a navigation that animates in
1905
- // one direction and cuts in the other would read as a bug in the
1906
- // animation rather than as a decision.
1907
- withViewTransition(nextResolved.viewTransition, () => {
1908
- setResolved(nextResolved);
1909
- });
1910
- });
2328
+ resolveMatch(routeTable(), next).then(arrive);
1911
2329
  };
1912
2330
  window.addEventListener("popstate", onPopState);
1913
2331
  return () => {
@@ -1928,6 +2346,19 @@ export component RouterProvider(url: string, initial: ResolvedRoute, children: R
1928
2346
  return;
1929
2347
  }
1930
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
+ }
1931
2362
  const matched = matchRoute(routeTable().routes, target.pathname);
1932
2363
  const load = matched?.route.page;
1933
2364
  if (matched == null || load == null) {
@@ -1950,16 +2381,28 @@ export component RouterProvider(url: string, initial: ResolvedRoute, children: R
1950
2381
  window.location.reload();
1951
2382
  return;
1952
2383
  }
1953
- const nextResolved = await resolveMatch(
1954
- routeTable(),
1955
- window.location.pathname + window.location.search,
1956
- );
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)));
1957
2400
  // No view transition, and it is the one place that is right: a refresh
1958
2401
  // is the same URL resolved again, so a transition would animate a page
1959
2402
  // into itself — a cross-fade between two frames of the same thing,
1960
2403
  // which is a flicker with a name.
1961
2404
  startTransition(() => {
1962
- setResolved(nextResolved);
2405
+ show(nextResolved);
1963
2406
  });
1964
2407
  },
1965
2408
  back: () => {
@@ -2510,7 +2953,16 @@ function loadingComponent(module: LoadingModule): React.ComponentType<{||}> {
2510
2953
  * the React Compiler's aliasing inference gave up on, and a component it
2511
2954
  * cannot compile is a component it does not memoise.
2512
2955
  */
2513
- function insideTemplates(element: React.Node, resolved: ResolvedRoute, depth: number): React.Node {
2956
+ function insideTemplates(
2957
+ element: React.Node,
2958
+ resolved: {
2959
+ readonly pathname: string,
2960
+ readonly params: RouteParams,
2961
+ readonly templates: $ReadOnlyArray<ResolvedTemplate>,
2962
+ ...
2963
+ },
2964
+ depth: number,
2965
+ ): React.Node {
2514
2966
  let out = element;
2515
2967
  for (let index = resolved.templates.length - 1; index >= 0; index -= 1) {
2516
2968
  const entry = resolved.templates[index];
@@ -2589,17 +3041,48 @@ component SlotView(slot: ResolvedSlot) {
2589
3041
  if (page == null) {
2590
3042
  return null;
2591
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;
2592
3051
  const Page = pageComponent(page);
2593
3052
  let element: React.Node = (
2594
- <Page params={slot.params} searchParams={resolved.searchParams} data={undefined} />
3053
+ <Page params={slot.params} searchParams={searchParams} data={undefined} />
2595
3054
  );
2596
- for (let depth = slot.layouts.length; depth > 0; depth -= 1) {
2597
- const Layout = layoutComponent(slot.layouts[depth - 1]);
2598
- element = (
2599
- <Layout {...slotsAt(slot.slots, depth)} params={slot.params}>
2600
- {element}
2601
- </Layout>
2602
- );
3055
+ const templateContext = {
3056
+ pathname,
3057
+ params: slot.params,
3058
+ templates: slot.templates,
3059
+ };
3060
+ for (let depth = slot.layouts.length; depth >= 0; depth -= 1) {
3061
+ for (let index = slot.loading.length - 1; index >= 0; index -= 1) {
3062
+ const boundary = slot.loading[index];
3063
+ if (boundary.above !== depth) {
3064
+ continue;
3065
+ }
3066
+ const Fallback = loadingComponent(boundary.module);
3067
+ element = <Suspense fallback={<Fallback />}>{element}</Suspense>;
3068
+ }
3069
+ const errorBoundary = slot.errorBoundary;
3070
+ if (errorBoundary != null && errorBoundary.above === depth) {
3071
+ element = (
3072
+ <RouteErrorBoundary module={errorBoundary.module} resetKey={`${pathname}:${slot.name}`}>
3073
+ {element}
3074
+ </RouteErrorBoundary>
3075
+ );
3076
+ }
3077
+ element = insideTemplates(element, templateContext, depth);
3078
+ if (depth > 0) {
3079
+ const Layout = layoutComponent(slot.layouts[depth - 1]);
3080
+ element = (
3081
+ <Layout {...slotsAt(slot.slots, depth)} params={slot.params}>
3082
+ {element}
3083
+ </Layout>
3084
+ );
3085
+ }
2603
3086
  }
2604
3087
  return element;
2605
3088
  }