@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 +7 -0
- package/internal/routing.js +20 -0
- package/internal/runtime.js +409 -33
- package/native.js +17 -0
- package/package.json +3 -3
- package/server.js +7 -7
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,
|
package/internal/routing.js
CHANGED
|
@@ -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. */
|
package/internal/runtime.js
CHANGED
|
@@ -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) =>
|
|
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
|
|
1016
|
+
return null;
|
|
925
1017
|
}
|
|
926
1018
|
const loaded = layouts.filter(Boolean);
|
|
927
1019
|
if (loaded.length !== layouts.length) {
|
|
928
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
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 =
|
|
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(
|
|
2231
|
+
window.history.replaceState(state, "", next + target.hash);
|
|
1923
2232
|
} else {
|
|
1924
|
-
window.history.pushState(
|
|
2233
|
+
window.history.pushState(state, "", next + target.hash);
|
|
1925
2234
|
}
|
|
1926
2235
|
const commit = () => {
|
|
1927
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
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
|
-
|
|
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={
|
|
3053
|
+
<Page params={slot.params} searchParams={searchParams} data={undefined} />
|
|
2675
3054
|
);
|
|
2676
3055
|
const templateContext = {
|
|
2677
|
-
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.
|
|
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.
|
|
42
|
-
"@uniflowed/server": "0.0.0-alpha.
|
|
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
|
-
*
|
|
185
|
-
* that resolved `@uniflowed/server/host` for itself — from its own
|
|
186
|
-
* `node_modules`, or from outside the bundle a build produced —
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
*
|
|
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.
|