@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.
- package/index.js +7 -0
- package/internal/boundaries.js +11 -110
- package/internal/boundary-data.js +88 -0
- package/internal/inspector.js +12 -1
- package/internal/resolved-summary.js +199 -0
- package/internal/routing.js +56 -8
- package/internal/runtime.js +529 -46
- package/native.js +408 -0
- package/package.json +5 -3
- package/routing.js +9 -0
- package/server.js +7 -7
package/internal/runtime.js
CHANGED
|
@@ -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<
|
|
469
|
+
export type SlotRecord = RoutingSlotRecord<
|
|
470
|
+
PageModule,
|
|
471
|
+
LayoutModule,
|
|
472
|
+
TemplateModule,
|
|
473
|
+
LoadingModule,
|
|
474
|
+
ErrorModule,
|
|
475
|
+
>;
|
|
469
476
|
|
|
470
|
-
export type SlotRouteRecord = RoutingSlotRouteRecord<
|
|
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) =>
|
|
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 {
|
|
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
|
|
1016
|
+
return null;
|
|
890
1017
|
}
|
|
891
1018
|
const loaded = layouts.filter(Boolean);
|
|
892
1019
|
if (loaded.length !== layouts.length) {
|
|
893
|
-
return
|
|
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
|
-
|
|
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<
|
|
967
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
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 =
|
|
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(
|
|
2231
|
+
window.history.replaceState(state, "", next + target.hash);
|
|
1852
2232
|
} else {
|
|
1853
|
-
window.history.pushState(
|
|
2233
|
+
window.history.pushState(state, "", next + target.hash);
|
|
1854
2234
|
}
|
|
1855
2235
|
const commit = () => {
|
|
1856
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
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
|
-
|
|
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(
|
|
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={
|
|
3053
|
+
<Page params={slot.params} searchParams={searchParams} data={undefined} />
|
|
2595
3054
|
);
|
|
2596
|
-
|
|
2597
|
-
|
|
2598
|
-
|
|
2599
|
-
|
|
2600
|
-
|
|
2601
|
-
|
|
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
|
}
|