@uniflowed/router 0.0.0-alpha.32 → 0.0.0-alpha.33
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/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 +36 -8
- package/internal/runtime.js +125 -18
- package/native.js +391 -0
- package/package.json +5 -3
- package/routing.js +9 -0
package/internal/boundaries.js
CHANGED
|
@@ -86,7 +86,18 @@
|
|
|
86
86
|
import * as React from "react";
|
|
87
87
|
import { useEffect, useState } from "react";
|
|
88
88
|
|
|
89
|
+
import { SYNTHESISED_SOURCE } from "./boundary-data.js";
|
|
89
90
|
import { reportDiagnostic } from "./diagnostics.js";
|
|
91
|
+
import type { RouteBoundary } from "./boundary-data.js";
|
|
92
|
+
|
|
93
|
+
export type { BoundaryKind, RouteBoundary } from "./boundary-data.js";
|
|
94
|
+
export {
|
|
95
|
+
ROOT_ERROR_ID,
|
|
96
|
+
ROUTE_ERROR_ID,
|
|
97
|
+
SYNTHESISED_SOURCE,
|
|
98
|
+
routeBoundaries,
|
|
99
|
+
suspenseId,
|
|
100
|
+
} from "./boundary-data.js";
|
|
90
101
|
|
|
91
102
|
/** The attribute a mark carries its boundary's id in. */
|
|
92
103
|
export const BOUNDARY_ATTRIBUTE: string = "data-uf-boundary";
|
|
@@ -100,116 +111,6 @@ export const SOURCE_ATTRIBUTE: string = "data-uf-boundary-source";
|
|
|
100
111
|
/** The name `uf dev` installs the on-demand report under. */
|
|
101
112
|
export const BOUNDARY_GLOBAL: string = "__ufBoundaries";
|
|
102
113
|
|
|
103
|
-
/**
|
|
104
|
-
* What `file` says for the error boundary the build synthesises.
|
|
105
|
-
*
|
|
106
|
-
* `routesModuleSource` writes this string where a declared boundary has a path,
|
|
107
|
-
* because the record has no module to name — the framework's own error page
|
|
108
|
-
* renders in its place. Written out again rather than imported for the reason
|
|
109
|
-
* `./devtools.js` gives about `DEVTOOLS_HOOK`: `@uniflowed/vite` is plain
|
|
110
|
-
* JavaScript loaded by Vite before any Flow transform exists, so the import
|
|
111
|
-
* cannot go either way. `boundaries.test.js` holds the two spellings together.
|
|
112
|
-
*/
|
|
113
|
-
export const SYNTHESISED_SOURCE: string = "@uniflowed/router";
|
|
114
|
-
|
|
115
|
-
/** Which kind of boundary a mark belongs to. */
|
|
116
|
-
export type BoundaryKind = "suspense" | "error";
|
|
117
|
-
|
|
118
|
-
/**
|
|
119
|
-
* One boundary of a resolved route, as the marks and the report name it.
|
|
120
|
-
*
|
|
121
|
-
* `id` pairs the two marks and is stable for a boundary across renders, so a
|
|
122
|
-
* navigation that keeps a boundary keeps its marks mounted. `above` is how many
|
|
123
|
-
* of the route's layouts are outside it — the same number, spelled the same
|
|
124
|
-
* way, that `ResolvedRoute["errorBoundary"].above` and every `loading` entry
|
|
125
|
-
* carry, because there is no second vocabulary for where a thing sits in the
|
|
126
|
-
* stack.
|
|
127
|
-
*
|
|
128
|
-
* `source` is the file it was declared in, and is `null` for every `<Suspense>`
|
|
129
|
-
* boundary. That asymmetry is the route table's rather than this module's: an
|
|
130
|
-
* error boundary is matched by path, so the table carries its `file`, while a
|
|
131
|
-
* `$loading.js` is carried by depth alone. Adding a path to the loading
|
|
132
|
-
* records would put one in every visitor's bundle to serve a report only
|
|
133
|
-
* `uf dev` reads.
|
|
134
|
-
*/
|
|
135
|
-
export type RouteBoundary = {|
|
|
136
|
-
readonly id: string,
|
|
137
|
-
readonly kind: BoundaryKind,
|
|
138
|
-
readonly above: number,
|
|
139
|
-
readonly source: ?string,
|
|
140
|
-
|};
|
|
141
|
-
|
|
142
|
-
/** The id of the `<Suspense>` boundary at `index` of a route's `loading`. */
|
|
143
|
-
export function suspenseId(index: number): string {
|
|
144
|
-
return `suspense:${index}`;
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
/** The id of the boundary a route's own `$error.js` renders. */
|
|
148
|
-
export const ROUTE_ERROR_ID: string = "error:route";
|
|
149
|
-
|
|
150
|
-
/**
|
|
151
|
-
* The id of the boundary that stands outside every layout.
|
|
152
|
-
*
|
|
153
|
-
* It has no module and renders the framework's page; it is what is between a
|
|
154
|
-
* throw in a root layout, or in the error component itself, and an unmounted
|
|
155
|
-
* document. Marked like any other, because "which subtree does the last resort
|
|
156
|
-
* own" is exactly as unanswerable from the page as the rest.
|
|
157
|
-
*/
|
|
158
|
-
export const ROOT_ERROR_ID: string = "error:root";
|
|
159
|
-
|
|
160
|
-
/** The part of a resolved route this module reads. */
|
|
161
|
-
type BoundedRoute = {
|
|
162
|
-
readonly errorBoundary: { readonly above: number, ... },
|
|
163
|
-
readonly loading: $ReadOnlyArray<{ readonly above: number, ... }>,
|
|
164
|
-
readonly error: mixed,
|
|
165
|
-
...
|
|
166
|
-
};
|
|
167
|
-
|
|
168
|
-
/**
|
|
169
|
-
* Every boundary a resolved route renders, in the order they nest.
|
|
170
|
-
*
|
|
171
|
-
* A `Map` rather than a list because it is read both ways: `RouteView` asks for
|
|
172
|
-
* one by id as it builds the stack, and the report walks the values. One
|
|
173
|
-
* function answering both is the point — an id `RouteView` marks and the report
|
|
174
|
-
* cannot find is a boundary that silently disappears from the map, and
|
|
175
|
-
* ubugeeei-prod/uf#636 made the same argument about an explanation that can
|
|
176
|
-
* disagree with the thing it explains.
|
|
177
|
-
*
|
|
178
|
-
* The route's own error boundary is absent when the route *is* its error page,
|
|
179
|
-
* which is exactly when `RouteView` does not render one: wrapping that page in
|
|
180
|
-
* the boundary whose component it is would answer a throw inside it with
|
|
181
|
-
* itself.
|
|
182
|
-
*
|
|
183
|
-
* @param resolved the route being rendered
|
|
184
|
-
* @param errorSource the `file` of the nearest `$error.js`, when the table
|
|
185
|
-
* has one; `null` leaves the boundary named by its depth alone
|
|
186
|
-
*/
|
|
187
|
-
export function routeBoundaries(
|
|
188
|
-
resolved: BoundedRoute,
|
|
189
|
-
errorSource: ?string,
|
|
190
|
-
): Map<string, RouteBoundary> {
|
|
191
|
-
const found: Map<string, RouteBoundary> = new Map();
|
|
192
|
-
found.set(ROOT_ERROR_ID, {
|
|
193
|
-
id: ROOT_ERROR_ID,
|
|
194
|
-
kind: "error",
|
|
195
|
-
above: 0,
|
|
196
|
-
source: SYNTHESISED_SOURCE,
|
|
197
|
-
});
|
|
198
|
-
if (resolved.error == null) {
|
|
199
|
-
found.set(ROUTE_ERROR_ID, {
|
|
200
|
-
id: ROUTE_ERROR_ID,
|
|
201
|
-
kind: "error",
|
|
202
|
-
above: resolved.errorBoundary.above,
|
|
203
|
-
source: errorSource,
|
|
204
|
-
});
|
|
205
|
-
}
|
|
206
|
-
resolved.loading.forEach((boundary, index) => {
|
|
207
|
-
const id = suspenseId(index);
|
|
208
|
-
found.set(id, { id, kind: "suspense", above: boundary.above, source: null });
|
|
209
|
-
});
|
|
210
|
-
return found;
|
|
211
|
-
}
|
|
212
|
-
|
|
213
114
|
/**
|
|
214
115
|
* Whether an edge that mounts now should be in the DOM immediately.
|
|
215
116
|
*
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// React-free names for the boundaries a resolved route renders.
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* What `file` says for the error boundary the build synthesises.
|
|
7
|
+
*
|
|
8
|
+
* `routesModuleSource` writes this string where a declared boundary has a path,
|
|
9
|
+
* because the record has no module to name: the framework's own error page
|
|
10
|
+
* renders in its place. This module is plain data so the routing surface and
|
|
11
|
+
* the dev-only DOM marker code can share one spelling without importing React.
|
|
12
|
+
*/
|
|
13
|
+
export const SYNTHESISED_SOURCE: string = "@uniflowed/router";
|
|
14
|
+
|
|
15
|
+
/** Which kind of boundary a mark or payload row belongs to. */
|
|
16
|
+
export type BoundaryKind = "suspense" | "error";
|
|
17
|
+
|
|
18
|
+
/** The id of the `<Suspense>` boundary at `index` of a route's `loading`. */
|
|
19
|
+
export function suspenseId(index: number): string {
|
|
20
|
+
return `suspense:${index}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The id of the boundary a route's own `$error.js` renders. */
|
|
24
|
+
export const ROUTE_ERROR_ID: string = "error:route";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The id of the boundary that stands outside every layout.
|
|
28
|
+
*
|
|
29
|
+
* It has no module and renders the framework's page; it is what is between a
|
|
30
|
+
* throw in a root layout, or in the error component itself, and an unmounted
|
|
31
|
+
* document.
|
|
32
|
+
*/
|
|
33
|
+
export const ROOT_ERROR_ID: string = "error:root";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* One boundary of a resolved route, in uf's vocabulary rather than React's.
|
|
37
|
+
*
|
|
38
|
+
* `id` is the stable name `RouteView` marks in the DOM today and the name a
|
|
39
|
+
* future element payload can put beside the chunk that completed it. `above`
|
|
40
|
+
* is how many layouts are outside the boundary.
|
|
41
|
+
*/
|
|
42
|
+
export type RouteBoundary = {|
|
|
43
|
+
readonly id: string,
|
|
44
|
+
readonly kind: BoundaryKind,
|
|
45
|
+
readonly above: number,
|
|
46
|
+
readonly source: ?string,
|
|
47
|
+
|};
|
|
48
|
+
|
|
49
|
+
/** The part of a resolved route the boundary map reads. */
|
|
50
|
+
type BoundedRoute = {
|
|
51
|
+
readonly errorBoundary: { readonly above: number, ... },
|
|
52
|
+
readonly loading: $ReadOnlyArray<{ readonly above: number, ... }>,
|
|
53
|
+
readonly error: mixed,
|
|
54
|
+
...
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Every boundary a resolved route renders, in the order they nest.
|
|
59
|
+
*
|
|
60
|
+
* A `Map` rather than a list because callers read it both ways: `RouteView`
|
|
61
|
+
* asks for one by id as it builds the stack, while diagnostics and payload
|
|
62
|
+
* summaries walk the values.
|
|
63
|
+
*/
|
|
64
|
+
export function routeBoundaries(
|
|
65
|
+
resolved: BoundedRoute,
|
|
66
|
+
errorSource: ?string,
|
|
67
|
+
): Map<string, RouteBoundary> {
|
|
68
|
+
const found: Map<string, RouteBoundary> = new Map();
|
|
69
|
+
found.set(ROOT_ERROR_ID, {
|
|
70
|
+
id: ROOT_ERROR_ID,
|
|
71
|
+
kind: "error",
|
|
72
|
+
above: 0,
|
|
73
|
+
source: SYNTHESISED_SOURCE,
|
|
74
|
+
});
|
|
75
|
+
if (resolved.error == null) {
|
|
76
|
+
found.set(ROUTE_ERROR_ID, {
|
|
77
|
+
id: ROUTE_ERROR_ID,
|
|
78
|
+
kind: "error",
|
|
79
|
+
above: resolved.errorBoundary.above,
|
|
80
|
+
source: errorSource,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
resolved.loading.forEach((boundary, index) => {
|
|
84
|
+
const id = suspenseId(index);
|
|
85
|
+
found.set(id, { id, kind: "suspense", above: boundary.above, source: null });
|
|
86
|
+
});
|
|
87
|
+
return found;
|
|
88
|
+
}
|
package/internal/inspector.js
CHANGED
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
// lands this is the recorder it is fed to: `inspectStream` takes chunks and
|
|
28
28
|
// knows nothing about who produced them.
|
|
29
29
|
//
|
|
30
|
-
// # It reads React's
|
|
30
|
+
// # It reads React's markers, plus uf's row marker
|
|
31
31
|
//
|
|
32
32
|
// Fizz writes a suspended boundary into the shell as an empty
|
|
33
33
|
// `<template id="B:0">` followed by the fallback and closed by a `<!--/$-->`
|
|
@@ -46,6 +46,11 @@
|
|
|
46
46
|
// content of these chunks is the application's own — with a step limit on every
|
|
47
47
|
// loop for the same reason `./boundaries.js` has a `WALK_LIMIT`.
|
|
48
48
|
//
|
|
49
|
+
// The one uf-owned marker read here is `data-uf-row` on deferred payload row
|
|
50
|
+
// scripts. The inspector does not parse the row JSON or attach semantics to
|
|
51
|
+
// the value; it only keeps the terminal report from saying a row boundary built
|
|
52
|
+
// an anonymous `script`.
|
|
53
|
+
//
|
|
49
54
|
// A marker that a chunk boundary happened to split is not attributed rather
|
|
50
55
|
// than guessed at: the chunk is still counted, still timed, and says so. The
|
|
51
56
|
// alternative is a report that is confidently wrong about which part of the
|
|
@@ -81,6 +86,8 @@
|
|
|
81
86
|
// honest report is silence. That is also what keeps this from being a request
|
|
82
87
|
// log: it fires for the pages the feature is about and for no others.
|
|
83
88
|
|
|
89
|
+
import { PAYLOAD_ROW_ATTRIBUTE } from "./payload.js";
|
|
90
|
+
|
|
84
91
|
/** How many chunks of one document are recorded before the rest are counted. */
|
|
85
92
|
const CHUNK_LIMIT = 64;
|
|
86
93
|
|
|
@@ -412,6 +419,10 @@ function topLevelElements(markup: string, from: number): $ReadOnlyArray<string>
|
|
|
412
419
|
export function describeTag(tag: string): string {
|
|
413
420
|
const space = tag.search(/[\s/>]/);
|
|
414
421
|
const name = tag.slice(1, space === -1 ? tag.length : space).toLowerCase();
|
|
422
|
+
const row = name === "script" ? attribute(tag, PAYLOAD_ROW_ATTRIBUTE) : null;
|
|
423
|
+
if (row != null && row !== "") {
|
|
424
|
+
return `deferred payload row ${row}`;
|
|
425
|
+
}
|
|
415
426
|
const id = attribute(tag, "id");
|
|
416
427
|
if (id != null && id !== "") {
|
|
417
428
|
return `${name}#${id}`;
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// Module-free summaries of resolved routes.
|
|
4
|
+
|
|
5
|
+
import { routeBoundaries, suspenseId } from "./boundary-data.js";
|
|
6
|
+
import type { RouteBoundary } from "./boundary-data.js";
|
|
7
|
+
import type { RouteParams, SearchParams } from "./routing.js";
|
|
8
|
+
|
|
9
|
+
type RouteStatus = 200 | 401 | 403 | 404 | 500;
|
|
10
|
+
|
|
11
|
+
type RouteMetadata = { readonly [string]: mixed, ... };
|
|
12
|
+
|
|
13
|
+
type RouteErrorLike = {
|
|
14
|
+
readonly kind: "thrown" | "unauthorized" | "forbidden",
|
|
15
|
+
...
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
type BoundaryLike = {
|
|
19
|
+
readonly above: number,
|
|
20
|
+
...
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
type ModuleBoundaryLike = {
|
|
24
|
+
readonly above: number,
|
|
25
|
+
readonly module?: mixed,
|
|
26
|
+
...
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
type SlotLike = {
|
|
30
|
+
readonly name: string,
|
|
31
|
+
readonly above: number,
|
|
32
|
+
readonly page: mixed,
|
|
33
|
+
readonly params: RouteParams,
|
|
34
|
+
readonly layouts: $ReadOnlyArray<mixed>,
|
|
35
|
+
readonly loading: $ReadOnlyArray<BoundaryLike>,
|
|
36
|
+
readonly templates: $ReadOnlyArray<BoundaryLike>,
|
|
37
|
+
readonly errorBoundary: ?ModuleBoundaryLike,
|
|
38
|
+
readonly slots: $ReadOnlyArray<SlotLike>,
|
|
39
|
+
...
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
type RouteLike = {
|
|
43
|
+
readonly pathname: string,
|
|
44
|
+
readonly search: string,
|
|
45
|
+
readonly path: string,
|
|
46
|
+
readonly params: RouteParams,
|
|
47
|
+
readonly searchParams: SearchParams,
|
|
48
|
+
readonly layouts: $ReadOnlyArray<mixed>,
|
|
49
|
+
readonly data: mixed,
|
|
50
|
+
readonly deferred: mixed,
|
|
51
|
+
readonly metadata: RouteMetadata,
|
|
52
|
+
readonly viewTransition: ?string,
|
|
53
|
+
readonly status: RouteStatus,
|
|
54
|
+
readonly error: ?RouteErrorLike,
|
|
55
|
+
readonly errorBoundary: ModuleBoundaryLike,
|
|
56
|
+
readonly loading: $ReadOnlyArray<BoundaryLike>,
|
|
57
|
+
readonly templates: $ReadOnlyArray<BoundaryLike>,
|
|
58
|
+
readonly slots: $ReadOnlyArray<SlotLike>,
|
|
59
|
+
...
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export type ResolvedBoundarySummary = {|
|
|
63
|
+
readonly id: string,
|
|
64
|
+
readonly above: number,
|
|
65
|
+
|};
|
|
66
|
+
|
|
67
|
+
export type ResolvedTemplateSummary = {|
|
|
68
|
+
readonly above: number,
|
|
69
|
+
|};
|
|
70
|
+
|
|
71
|
+
export type ResolvedRouteErrorSummary =
|
|
72
|
+
| {| readonly kind: "thrown" |}
|
|
73
|
+
| {| readonly kind: "unauthorized" |}
|
|
74
|
+
| {| readonly kind: "forbidden" |};
|
|
75
|
+
|
|
76
|
+
export type ResolvedErrorBoundarySummary = {|
|
|
77
|
+
readonly above: number,
|
|
78
|
+
readonly custom: boolean,
|
|
79
|
+
readonly rendered: boolean,
|
|
80
|
+
|};
|
|
81
|
+
|
|
82
|
+
export type ResolvedSlotSummary = {|
|
|
83
|
+
readonly name: string,
|
|
84
|
+
readonly above: number,
|
|
85
|
+
readonly active: boolean,
|
|
86
|
+
readonly params: RouteParams,
|
|
87
|
+
readonly layoutCount: number,
|
|
88
|
+
readonly loading: $ReadOnlyArray<ResolvedBoundarySummary>,
|
|
89
|
+
readonly templates: $ReadOnlyArray<ResolvedTemplateSummary>,
|
|
90
|
+
readonly errorBoundary: ?ResolvedErrorBoundarySummary,
|
|
91
|
+
readonly slots: $ReadOnlyArray<ResolvedSlotSummary>,
|
|
92
|
+
|};
|
|
93
|
+
|
|
94
|
+
export type ResolvedRouteSummary = {|
|
|
95
|
+
readonly pathname: string,
|
|
96
|
+
readonly search: string,
|
|
97
|
+
readonly path: string,
|
|
98
|
+
readonly params: RouteParams,
|
|
99
|
+
readonly searchParams: SearchParams,
|
|
100
|
+
readonly layoutCount: number,
|
|
101
|
+
readonly data: mixed,
|
|
102
|
+
readonly deferred: boolean,
|
|
103
|
+
readonly metadata: RouteMetadata,
|
|
104
|
+
readonly viewTransition: ?string,
|
|
105
|
+
readonly status: RouteStatus,
|
|
106
|
+
readonly error: ?ResolvedRouteErrorSummary,
|
|
107
|
+
readonly errorBoundary: ResolvedErrorBoundarySummary,
|
|
108
|
+
readonly loading: $ReadOnlyArray<ResolvedBoundarySummary>,
|
|
109
|
+
readonly templates: $ReadOnlyArray<ResolvedTemplateSummary>,
|
|
110
|
+
readonly slots: $ReadOnlyArray<ResolvedSlotSummary>,
|
|
111
|
+
readonly boundaries: $ReadOnlyArray<RouteBoundary>,
|
|
112
|
+
|};
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The part of a `ResolvedRoute` that can cross to a payload or diagnostic.
|
|
116
|
+
*
|
|
117
|
+
* A resolved route carries loaded modules so `RouteView` can render today.
|
|
118
|
+
* The element payload in ubugeeei-prod/uf#519 needs the other half: route
|
|
119
|
+
* identity, data, metadata, status, and boundary names, with page/layout/
|
|
120
|
+
* fallback modules left behind in the renderer graph.
|
|
121
|
+
*/
|
|
122
|
+
export function summarizeResolvedRoute(
|
|
123
|
+
route: RouteLike,
|
|
124
|
+
errorSource?: ?string,
|
|
125
|
+
): ResolvedRouteSummary {
|
|
126
|
+
return {
|
|
127
|
+
pathname: route.pathname,
|
|
128
|
+
search: route.search,
|
|
129
|
+
path: route.path,
|
|
130
|
+
params: route.params,
|
|
131
|
+
searchParams: route.searchParams,
|
|
132
|
+
layoutCount: route.layouts.length,
|
|
133
|
+
data: route.data,
|
|
134
|
+
deferred: route.deferred != null,
|
|
135
|
+
metadata: route.metadata,
|
|
136
|
+
viewTransition: route.viewTransition,
|
|
137
|
+
status: route.status,
|
|
138
|
+
error: summarizeError(route.error),
|
|
139
|
+
errorBoundary: summarizeErrorBoundary(route.errorBoundary, route.error == null),
|
|
140
|
+
loading: summarizeLoading(route.loading),
|
|
141
|
+
templates: summarizeTemplates(route.templates),
|
|
142
|
+
slots: route.slots.map(summarizeSlot),
|
|
143
|
+
boundaries: [...routeBoundaries(route, errorSource).values()],
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function summarizeSlot(slot: SlotLike): ResolvedSlotSummary {
|
|
148
|
+
return {
|
|
149
|
+
name: slot.name,
|
|
150
|
+
above: slot.above,
|
|
151
|
+
active: slot.page != null,
|
|
152
|
+
params: slot.params,
|
|
153
|
+
layoutCount: slot.layouts.length,
|
|
154
|
+
loading: summarizeLoading(slot.loading),
|
|
155
|
+
templates: summarizeTemplates(slot.templates),
|
|
156
|
+
errorBoundary:
|
|
157
|
+
slot.errorBoundary == null ? null : summarizeErrorBoundary(slot.errorBoundary, true),
|
|
158
|
+
slots: slot.slots.map(summarizeSlot),
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function summarizeLoading(
|
|
163
|
+
boundaries: $ReadOnlyArray<BoundaryLike>,
|
|
164
|
+
): $ReadOnlyArray<ResolvedBoundarySummary> {
|
|
165
|
+
return boundaries.map((boundary, index) => ({
|
|
166
|
+
id: suspenseId(index),
|
|
167
|
+
above: boundary.above,
|
|
168
|
+
}));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function summarizeTemplates(
|
|
172
|
+
templates: $ReadOnlyArray<BoundaryLike>,
|
|
173
|
+
): $ReadOnlyArray<ResolvedTemplateSummary> {
|
|
174
|
+
return templates.map((template) => ({ above: template.above }));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function summarizeErrorBoundary(
|
|
178
|
+
boundary: ModuleBoundaryLike,
|
|
179
|
+
rendered: boolean,
|
|
180
|
+
): ResolvedErrorBoundarySummary {
|
|
181
|
+
return {
|
|
182
|
+
above: boundary.above,
|
|
183
|
+
custom: boundary.module != null,
|
|
184
|
+
rendered,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function summarizeError(error: ?RouteErrorLike): ?ResolvedRouteErrorSummary {
|
|
189
|
+
if (error == null) {
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
if (error.kind === "unauthorized") {
|
|
193
|
+
return { kind: "unauthorized" };
|
|
194
|
+
}
|
|
195
|
+
if (error.kind === "forbidden") {
|
|
196
|
+
return { kind: "forbidden" };
|
|
197
|
+
}
|
|
198
|
+
return { kind: "thrown" };
|
|
199
|
+
}
|
package/internal/routing.js
CHANGED
|
@@ -31,29 +31,57 @@ export type LoadingRecord<TLoading = mixed> = {|
|
|
|
31
31
|
readonly module: RouteModule<TLoading>,
|
|
32
32
|
|};
|
|
33
33
|
|
|
34
|
+
/** One `$error.js` inside a slot, as the route table carries it. */
|
|
35
|
+
export type SlotErrorBoundaryRecord<TError = mixed> = {|
|
|
36
|
+
readonly above: number,
|
|
37
|
+
readonly module: RouteModule<TError>,
|
|
38
|
+
|};
|
|
39
|
+
|
|
34
40
|
/** One parallel-route slot, as the route table carries it. */
|
|
35
|
-
export type SlotRecord<
|
|
41
|
+
export type SlotRecord<
|
|
42
|
+
TPage = mixed,
|
|
43
|
+
TLayout = mixed,
|
|
44
|
+
TTemplate = mixed,
|
|
45
|
+
TLoading = mixed,
|
|
46
|
+
TError = mixed,
|
|
47
|
+
> = {|
|
|
36
48
|
readonly name: string,
|
|
37
49
|
readonly above: number,
|
|
38
50
|
readonly defaultPage: ?RouteModule<TPage>,
|
|
39
51
|
readonly defaultFile?: string,
|
|
40
52
|
readonly defaultMdx?: boolean,
|
|
41
|
-
readonly
|
|
53
|
+
readonly defaultErrorBoundary?: ?SlotErrorBoundaryRecord<TError>,
|
|
54
|
+
readonly routes: $ReadOnlyArray<SlotRouteRecord<TPage, TLayout, TTemplate, TLoading, TError>>,
|
|
42
55
|
|};
|
|
43
56
|
|
|
44
57
|
/** One page inside a slot. */
|
|
45
|
-
export type SlotRouteRecord<
|
|
58
|
+
export type SlotRouteRecord<
|
|
59
|
+
TPage = mixed,
|
|
60
|
+
TLayout = mixed,
|
|
61
|
+
TTemplate = mixed,
|
|
62
|
+
TLoading = mixed,
|
|
63
|
+
TError = mixed,
|
|
64
|
+
> = {|
|
|
46
65
|
readonly path: string,
|
|
47
66
|
readonly params: $ReadOnlyArray<RouteParamSpec>,
|
|
48
67
|
readonly mdx: boolean,
|
|
49
68
|
readonly file: string,
|
|
50
69
|
readonly page: RouteModule<TPage>,
|
|
51
70
|
readonly layouts: $ReadOnlyArray<RouteModule<TLayout>>,
|
|
52
|
-
readonly
|
|
71
|
+
readonly loading?: $ReadOnlyArray<LoadingRecord<TLoading>>,
|
|
72
|
+
readonly templates?: $ReadOnlyArray<TemplateRecord<TTemplate>>,
|
|
73
|
+
readonly errorBoundary?: ?SlotErrorBoundaryRecord<TError>,
|
|
74
|
+
readonly slots: $ReadOnlyArray<SlotRecord<TPage, TLayout, TTemplate, TLoading, TError>>,
|
|
53
75
|
|};
|
|
54
76
|
|
|
55
77
|
/** One entry of the generated route table. */
|
|
56
|
-
export type RouteRecord<
|
|
78
|
+
export type RouteRecord<
|
|
79
|
+
TPage = mixed,
|
|
80
|
+
TLayout = mixed,
|
|
81
|
+
TTemplate = mixed,
|
|
82
|
+
TLoading = mixed,
|
|
83
|
+
TError = mixed,
|
|
84
|
+
> = {|
|
|
57
85
|
readonly path: string,
|
|
58
86
|
readonly params: $ReadOnlyArray<RouteParamSpec>,
|
|
59
87
|
readonly mdx: boolean,
|
|
@@ -62,7 +90,7 @@ export type RouteRecord<TPage = mixed, TLayout = mixed, TTemplate = mixed, TLoad
|
|
|
62
90
|
readonly layouts: $ReadOnlyArray<RouteModule<TLayout>>,
|
|
63
91
|
readonly loading?: $ReadOnlyArray<LoadingRecord<TLoading>>,
|
|
64
92
|
readonly templates?: $ReadOnlyArray<TemplateRecord<TTemplate>>,
|
|
65
|
-
readonly slots?: $ReadOnlyArray<SlotRecord<TPage, TLayout>>,
|
|
93
|
+
readonly slots?: $ReadOnlyArray<SlotRecord<TPage, TLayout, TTemplate, TLoading, TError>>,
|
|
66
94
|
|};
|
|
67
95
|
|
|
68
96
|
/** One not-found boundary: the page for a path under `path` that matched nothing. */
|
|
@@ -90,12 +118,12 @@ export type RouteTable<
|
|
|
90
118
|
TLoading = mixed,
|
|
91
119
|
TError = mixed,
|
|
92
120
|
> = {|
|
|
93
|
-
readonly routes: $ReadOnlyArray<RouteRecord<TPage, TLayout, TTemplate, TLoading>>,
|
|
121
|
+
readonly routes: $ReadOnlyArray<RouteRecord<TPage, TLayout, TTemplate, TLoading, TError>>,
|
|
94
122
|
readonly notFound: $ReadOnlyArray<NotFoundBoundary<TPage, TLayout>>,
|
|
95
123
|
readonly errors: $ReadOnlyArray<ErrorBoundary<TError, TLayout>>,
|
|
96
124
|
|};
|
|
97
125
|
|
|
98
|
-
type UnknownRouteRecord = RouteRecord<mixed, mixed, mixed, mixed>;
|
|
126
|
+
type UnknownRouteRecord = RouteRecord<mixed, mixed, mixed, mixed, mixed>;
|
|
99
127
|
|
|
100
128
|
/** A URL matched against a table. */
|
|
101
129
|
export type RouteMatch<TRoute: { +path: string, ... } = UnknownRouteRecord> = {|
|
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
|
*
|
|
@@ -600,6 +625,9 @@ export type ResolvedSlot = {|
|
|
|
600
625
|
readonly page: ?PageModule,
|
|
601
626
|
readonly params: RouteParams,
|
|
602
627
|
readonly layouts: $ReadOnlyArray<LayoutModule>,
|
|
628
|
+
readonly loading: $ReadOnlyArray<{| readonly above: number, readonly module: LoadingModule |}>,
|
|
629
|
+
readonly templates: $ReadOnlyArray<ResolvedTemplate>,
|
|
630
|
+
readonly errorBoundary: ?ResolvedSlotErrorBoundary,
|
|
603
631
|
readonly slots: $ReadOnlyArray<ResolvedSlot>,
|
|
604
632
|
|};
|
|
605
633
|
|
|
@@ -860,6 +888,9 @@ async function resolveSlot(
|
|
|
860
888
|
page: null,
|
|
861
889
|
params: fallbackParams,
|
|
862
890
|
layouts: [],
|
|
891
|
+
loading: [],
|
|
892
|
+
templates: [],
|
|
893
|
+
errorBoundary: null,
|
|
863
894
|
slots: [],
|
|
864
895
|
};
|
|
865
896
|
|
|
@@ -875,7 +906,11 @@ async function resolveSlot(
|
|
|
875
906
|
if (module == null) {
|
|
876
907
|
return empty;
|
|
877
908
|
}
|
|
878
|
-
return {
|
|
909
|
+
return {
|
|
910
|
+
...empty,
|
|
911
|
+
page: withoutLoader(module, record.defaultFile ?? record.name),
|
|
912
|
+
errorBoundary: await resolveSlotErrorBoundary(record.defaultErrorBoundary ?? null, 0),
|
|
913
|
+
};
|
|
879
914
|
}
|
|
880
915
|
|
|
881
916
|
const route = matched.route;
|
|
@@ -892,18 +927,42 @@ async function resolveSlot(
|
|
|
892
927
|
if (loaded.length !== layouts.length) {
|
|
893
928
|
return empty;
|
|
894
929
|
}
|
|
930
|
+
const loading = await resolveLoadingRecords(route.loading ?? [], loaded.length);
|
|
931
|
+
const templates = await resolveTemplateRecords(route.templates ?? [], loaded.length);
|
|
932
|
+
const errorBoundary = await resolveSlotErrorBoundary(route.errorBoundary ?? null, loaded.length);
|
|
895
933
|
return {
|
|
896
934
|
name: record.name,
|
|
897
935
|
above,
|
|
898
936
|
page: withoutLoader(page, route.file),
|
|
899
937
|
params: matched.params,
|
|
900
938
|
layouts: loaded,
|
|
939
|
+
loading,
|
|
940
|
+
templates,
|
|
941
|
+
errorBoundary,
|
|
901
942
|
// The slot's own layouts are what a nested slot is measured against, so
|
|
902
943
|
// the count handed down is this slot's rather than the route's.
|
|
903
944
|
slots: await resolveSlots(route.slots, pathname, loaded.length, matched.params),
|
|
904
945
|
};
|
|
905
946
|
}
|
|
906
947
|
|
|
948
|
+
async function resolveSlotErrorBoundary(
|
|
949
|
+
boundary: ?SlotErrorBoundaryLoader,
|
|
950
|
+
layoutCount: number,
|
|
951
|
+
): Promise<?ResolvedSlotErrorBoundary> {
|
|
952
|
+
if (boundary == null) {
|
|
953
|
+
return null;
|
|
954
|
+
}
|
|
955
|
+
const above = Math.min(boundary.above, layoutCount);
|
|
956
|
+
try {
|
|
957
|
+
return { module: await loadOnce(boundary.module), above };
|
|
958
|
+
} catch {
|
|
959
|
+
// Keep the declared depth even when the custom file fails to import. The
|
|
960
|
+
// framework fallback still contains the slot instead of escalating the
|
|
961
|
+
// page beside it.
|
|
962
|
+
return { module: null, above };
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
|
|
907
966
|
/**
|
|
908
967
|
* A module, or `null` when it would not import.
|
|
909
968
|
*
|
|
@@ -963,8 +1022,14 @@ function withoutLoader(module: PageModule, file: string): PageModule {
|
|
|
963
1022
|
async function resolveTemplates(
|
|
964
1023
|
route: RouteRecord,
|
|
965
1024
|
layoutCount: number,
|
|
966
|
-
): Promise<$ReadOnlyArray<
|
|
967
|
-
|
|
1025
|
+
): Promise<$ReadOnlyArray<ResolvedTemplate>> {
|
|
1026
|
+
return resolveTemplateRecords(route.templates ?? [], layoutCount);
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
async function resolveTemplateRecords(
|
|
1030
|
+
records: $ReadOnlyArray<TemplateRecord>,
|
|
1031
|
+
layoutCount: number,
|
|
1032
|
+
): Promise<$ReadOnlyArray<ResolvedTemplate>> {
|
|
968
1033
|
if (records.length === 0) {
|
|
969
1034
|
return [];
|
|
970
1035
|
}
|
|
@@ -1000,7 +1065,13 @@ async function resolveLoading(
|
|
|
1000
1065
|
route: RouteRecord,
|
|
1001
1066
|
layoutCount: number,
|
|
1002
1067
|
): Promise<$ReadOnlyArray<{| readonly above: number, readonly module: LoadingModule |}>> {
|
|
1003
|
-
|
|
1068
|
+
return resolveLoadingRecords(route.loading ?? [], layoutCount);
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
async function resolveLoadingRecords(
|
|
1072
|
+
records: $ReadOnlyArray<LoadingRecord>,
|
|
1073
|
+
layoutCount: number,
|
|
1074
|
+
): Promise<$ReadOnlyArray<{| readonly above: number, readonly module: LoadingModule |}>> {
|
|
1004
1075
|
if (records.length === 0) {
|
|
1005
1076
|
return [];
|
|
1006
1077
|
}
|
|
@@ -2510,7 +2581,16 @@ function loadingComponent(module: LoadingModule): React.ComponentType<{||}> {
|
|
|
2510
2581
|
* the React Compiler's aliasing inference gave up on, and a component it
|
|
2511
2582
|
* cannot compile is a component it does not memoise.
|
|
2512
2583
|
*/
|
|
2513
|
-
function insideTemplates(
|
|
2584
|
+
function insideTemplates(
|
|
2585
|
+
element: React.Node,
|
|
2586
|
+
resolved: {
|
|
2587
|
+
readonly pathname: string,
|
|
2588
|
+
readonly params: RouteParams,
|
|
2589
|
+
readonly templates: $ReadOnlyArray<ResolvedTemplate>,
|
|
2590
|
+
...
|
|
2591
|
+
},
|
|
2592
|
+
depth: number,
|
|
2593
|
+
): React.Node {
|
|
2514
2594
|
let out = element;
|
|
2515
2595
|
for (let index = resolved.templates.length - 1; index >= 0; index -= 1) {
|
|
2516
2596
|
const entry = resolved.templates[index];
|
|
@@ -2593,13 +2673,40 @@ component SlotView(slot: ResolvedSlot) {
|
|
|
2593
2673
|
let element: React.Node = (
|
|
2594
2674
|
<Page params={slot.params} searchParams={resolved.searchParams} data={undefined} />
|
|
2595
2675
|
);
|
|
2596
|
-
|
|
2597
|
-
|
|
2598
|
-
|
|
2599
|
-
|
|
2600
|
-
|
|
2601
|
-
|
|
2602
|
-
)
|
|
2676
|
+
const templateContext = {
|
|
2677
|
+
pathname: resolved.pathname,
|
|
2678
|
+
params: slot.params,
|
|
2679
|
+
templates: slot.templates,
|
|
2680
|
+
};
|
|
2681
|
+
for (let depth = slot.layouts.length; depth >= 0; depth -= 1) {
|
|
2682
|
+
for (let index = slot.loading.length - 1; index >= 0; index -= 1) {
|
|
2683
|
+
const boundary = slot.loading[index];
|
|
2684
|
+
if (boundary.above !== depth) {
|
|
2685
|
+
continue;
|
|
2686
|
+
}
|
|
2687
|
+
const Fallback = loadingComponent(boundary.module);
|
|
2688
|
+
element = <Suspense fallback={<Fallback />}>{element}</Suspense>;
|
|
2689
|
+
}
|
|
2690
|
+
const errorBoundary = slot.errorBoundary;
|
|
2691
|
+
if (errorBoundary != null && errorBoundary.above === depth) {
|
|
2692
|
+
element = (
|
|
2693
|
+
<RouteErrorBoundary
|
|
2694
|
+
module={errorBoundary.module}
|
|
2695
|
+
resetKey={`${resolved.pathname}:${slot.name}`}
|
|
2696
|
+
>
|
|
2697
|
+
{element}
|
|
2698
|
+
</RouteErrorBoundary>
|
|
2699
|
+
);
|
|
2700
|
+
}
|
|
2701
|
+
element = insideTemplates(element, templateContext, depth);
|
|
2702
|
+
if (depth > 0) {
|
|
2703
|
+
const Layout = layoutComponent(slot.layouts[depth - 1]);
|
|
2704
|
+
element = (
|
|
2705
|
+
<Layout {...slotsAt(slot.slots, depth)} params={slot.params}>
|
|
2706
|
+
{element}
|
|
2707
|
+
</Layout>
|
|
2708
|
+
);
|
|
2709
|
+
}
|
|
2603
2710
|
}
|
|
2604
2711
|
return element;
|
|
2605
2712
|
}
|
package/native.js
ADDED
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// `@uniflowed/router/native`: the React Native navigator contract.
|
|
4
|
+
//
|
|
5
|
+
// Native routes use the same generated table as the web router, but not the
|
|
6
|
+
// browser's URL bar or History API. This adapter keeps the shared part small:
|
|
7
|
+
// it resolves a uf route path against the table, refuses destinations the
|
|
8
|
+
// native bundle cannot render, and hands a navigator-shaped event to the app's
|
|
9
|
+
// own navigation runtime. Rendering the tree is still the renderer/host-config
|
|
10
|
+
// half of the React Native target.
|
|
11
|
+
|
|
12
|
+
import type { RouteParams, RouteRecord, RouteTable } from "./internal/routing.js";
|
|
13
|
+
import { hasClientPage, matchRoute, splitUrl } from "./internal/routing.js";
|
|
14
|
+
|
|
15
|
+
export type NativeNavigationKind = "push" | "replace" | "prefetch";
|
|
16
|
+
|
|
17
|
+
export type NativeNavigationEvent = {|
|
|
18
|
+
readonly kind: NativeNavigationKind,
|
|
19
|
+
readonly href: string,
|
|
20
|
+
readonly pathname: string,
|
|
21
|
+
readonly search: string,
|
|
22
|
+
readonly route: string,
|
|
23
|
+
readonly params: RouteParams,
|
|
24
|
+
|};
|
|
25
|
+
|
|
26
|
+
export type NativeScreenMap = {
|
|
27
|
+
readonly [route: string]: string,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type NativeScreenEntry = {|
|
|
31
|
+
readonly screen: string,
|
|
32
|
+
readonly route: string,
|
|
33
|
+
readonly file: string,
|
|
34
|
+
|};
|
|
35
|
+
|
|
36
|
+
export type NativeScreenManifest = {|
|
|
37
|
+
readonly screens: NativeScreenMap,
|
|
38
|
+
readonly entries: $ReadOnlyArray<NativeScreenEntry>,
|
|
39
|
+
|};
|
|
40
|
+
|
|
41
|
+
export type NativeScreenNameRoute = {|
|
|
42
|
+
readonly path: string,
|
|
43
|
+
readonly file: string,
|
|
44
|
+
|};
|
|
45
|
+
|
|
46
|
+
export type NativeScreenManifestOptions = {|
|
|
47
|
+
readonly name?: (route: NativeScreenNameRoute) => string,
|
|
48
|
+
|};
|
|
49
|
+
|
|
50
|
+
export type NativeScreenPayload = {|
|
|
51
|
+
readonly screen: string,
|
|
52
|
+
readonly href: string,
|
|
53
|
+
readonly pathname: string,
|
|
54
|
+
readonly search: string,
|
|
55
|
+
readonly route: string,
|
|
56
|
+
readonly params: RouteParams,
|
|
57
|
+
|};
|
|
58
|
+
|
|
59
|
+
export type NativeScreenNavigationState = {|
|
|
60
|
+
readonly params: RouteParams,
|
|
61
|
+
readonly href: string,
|
|
62
|
+
readonly pathname: string,
|
|
63
|
+
readonly search: string,
|
|
64
|
+
readonly route: string,
|
|
65
|
+
|};
|
|
66
|
+
|
|
67
|
+
export type NativeNavigator = {|
|
|
68
|
+
readonly push?: (event: NativeNavigationEvent) => mixed | Promise<mixed>,
|
|
69
|
+
readonly replace?: (event: NativeNavigationEvent) => mixed | Promise<mixed>,
|
|
70
|
+
readonly prefetch?: (event: NativeNavigationEvent) => mixed | Promise<mixed>,
|
|
71
|
+
|};
|
|
72
|
+
|
|
73
|
+
export type NativeScreenNavigator = {|
|
|
74
|
+
readonly push?: (screen: string, state: NativeScreenNavigationState) => mixed | Promise<mixed>,
|
|
75
|
+
readonly replace?: (screen: string, state: NativeScreenNavigationState) => mixed | Promise<mixed>,
|
|
76
|
+
readonly prefetch?: (
|
|
77
|
+
screen: string,
|
|
78
|
+
state: NativeScreenNavigationState,
|
|
79
|
+
) => mixed | Promise<mixed>,
|
|
80
|
+
|};
|
|
81
|
+
|
|
82
|
+
export type NativeRouter = {|
|
|
83
|
+
readonly push: (to: string) => Promise<void>,
|
|
84
|
+
readonly replace: (to: string) => Promise<void>,
|
|
85
|
+
readonly prefetch: (to: string) => Promise<void>,
|
|
86
|
+
readonly resolve: (to: string, kind?: NativeNavigationKind) => NativeNavigationEvent,
|
|
87
|
+
|};
|
|
88
|
+
|
|
89
|
+
export type NativeNavigationErrorCode =
|
|
90
|
+
| "duplicate-screen"
|
|
91
|
+
| "external-url"
|
|
92
|
+
| "fragment"
|
|
93
|
+
| "relative-url"
|
|
94
|
+
| "missing-route"
|
|
95
|
+
| "missing-screen"
|
|
96
|
+
| "server-only-route"
|
|
97
|
+
| "missing-navigator-method";
|
|
98
|
+
|
|
99
|
+
export class NativeNavigationError extends Error {
|
|
100
|
+
code: NativeNavigationErrorCode;
|
|
101
|
+
href: string;
|
|
102
|
+
route: ?string;
|
|
103
|
+
|
|
104
|
+
constructor(code: NativeNavigationErrorCode, message: string, href: string, route?: ?string) {
|
|
105
|
+
super(message);
|
|
106
|
+
this.name = "NativeNavigationError";
|
|
107
|
+
this.code = code;
|
|
108
|
+
this.href = href;
|
|
109
|
+
this.route = route ?? null;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function createNativeRouter(
|
|
114
|
+
table: RouteTable<mixed, mixed, mixed, mixed, mixed>,
|
|
115
|
+
navigator: NativeNavigator,
|
|
116
|
+
): NativeRouter {
|
|
117
|
+
const resolve = (to: string, kind?: NativeNavigationKind = "push"): NativeNavigationEvent =>
|
|
118
|
+
resolveNativeNavigation(table, to, kind);
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
resolve,
|
|
122
|
+
push: async (to) => {
|
|
123
|
+
const event = resolve(to, "push");
|
|
124
|
+
await invokeNavigator(navigator, event);
|
|
125
|
+
},
|
|
126
|
+
replace: async (to) => {
|
|
127
|
+
const event = resolve(to, "replace");
|
|
128
|
+
await invokeNavigator(navigator, event);
|
|
129
|
+
},
|
|
130
|
+
prefetch: async (to) => {
|
|
131
|
+
const event = resolve(to, "prefetch");
|
|
132
|
+
await loadNativeRoute(table, event.pathname);
|
|
133
|
+
if (typeof navigator.prefetch === "function") {
|
|
134
|
+
await navigator.prefetch(event);
|
|
135
|
+
}
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function createNativeScreenRouter(
|
|
141
|
+
table: RouteTable<mixed, mixed, mixed, mixed, mixed>,
|
|
142
|
+
screens: NativeScreenMap,
|
|
143
|
+
navigator: NativeScreenNavigator,
|
|
144
|
+
): NativeRouter {
|
|
145
|
+
return createNativeRouter(table, {
|
|
146
|
+
push: (event) => invokeScreenNavigator(navigator, screens, event),
|
|
147
|
+
replace: (event) => invokeScreenNavigator(navigator, screens, event),
|
|
148
|
+
prefetch: (event) => {
|
|
149
|
+
if (typeof navigator.prefetch !== "function") return;
|
|
150
|
+
return invokeScreenNavigator(navigator, screens, event);
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function createNativeScreenManifest(
|
|
156
|
+
table: RouteTable<mixed, mixed, mixed, mixed, mixed>,
|
|
157
|
+
options?: NativeScreenManifestOptions,
|
|
158
|
+
): NativeScreenManifest {
|
|
159
|
+
const screens: { [string]: string } = {};
|
|
160
|
+
const entries: Array<NativeScreenEntry> = [];
|
|
161
|
+
const seen = new Map<string, string>();
|
|
162
|
+
for (const route of table.routes) {
|
|
163
|
+
if (!hasClientPage(route)) {
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
const screen = screenNameFor(route, options);
|
|
167
|
+
const already = seen.get(screen);
|
|
168
|
+
if (already != null) {
|
|
169
|
+
throw new NativeNavigationError(
|
|
170
|
+
"duplicate-screen",
|
|
171
|
+
`@uniflowed/router/native: ${route.path} and ${already} both map to native screen ${screen}`,
|
|
172
|
+
route.path,
|
|
173
|
+
route.path,
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
seen.set(screen, route.path);
|
|
177
|
+
screens[route.path] = screen;
|
|
178
|
+
entries.push({ screen, route: route.path, file: route.file });
|
|
179
|
+
}
|
|
180
|
+
return { screens, entries };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function nativeScreenName(routePath: string): string {
|
|
184
|
+
const segments = routePath.split("/").filter((segment) => segment !== "");
|
|
185
|
+
if (segments.length === 0) {
|
|
186
|
+
return "Home";
|
|
187
|
+
}
|
|
188
|
+
const name = segments
|
|
189
|
+
.map((segment) => {
|
|
190
|
+
if (segment.startsWith(":") && segment.endsWith("*")) {
|
|
191
|
+
return `All${titlePart(segment.slice(1, -1))}`;
|
|
192
|
+
}
|
|
193
|
+
if (segment.startsWith(":")) {
|
|
194
|
+
return `By${titlePart(segment.slice(1))}`;
|
|
195
|
+
}
|
|
196
|
+
return titlePart(segment);
|
|
197
|
+
})
|
|
198
|
+
.join("");
|
|
199
|
+
return name === "" ? "Screen" : name;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function resolveNativeNavigation(
|
|
203
|
+
table: RouteTable<mixed, mixed, mixed, mixed, mixed>,
|
|
204
|
+
to: string,
|
|
205
|
+
kind?: NativeNavigationKind = "push",
|
|
206
|
+
): NativeNavigationEvent {
|
|
207
|
+
const href = normalizeNativeHref(to);
|
|
208
|
+
const { pathname, search } = splitUrl(href);
|
|
209
|
+
const matched = matchRoute(table.routes, pathname);
|
|
210
|
+
if (matched == null) {
|
|
211
|
+
throw new NativeNavigationError(
|
|
212
|
+
"missing-route",
|
|
213
|
+
`@uniflowed/router/native: ${href} does not match a native route in this table`,
|
|
214
|
+
href,
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
if (!hasClientPage(matched.route)) {
|
|
218
|
+
throw new NativeNavigationError(
|
|
219
|
+
"server-only-route",
|
|
220
|
+
`@uniflowed/router/native: ${matched.route.path} is not in the native route table; ` +
|
|
221
|
+
"it has no page module for this bundle",
|
|
222
|
+
href,
|
|
223
|
+
matched.route.path,
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
return {
|
|
227
|
+
kind,
|
|
228
|
+
href,
|
|
229
|
+
pathname,
|
|
230
|
+
search,
|
|
231
|
+
route: matched.route.path,
|
|
232
|
+
params: matched.params,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export function nativeScreenPayload(
|
|
237
|
+
event: NativeNavigationEvent,
|
|
238
|
+
screens: NativeScreenMap,
|
|
239
|
+
): NativeScreenPayload {
|
|
240
|
+
const screen = screens[event.route];
|
|
241
|
+
if (typeof screen !== "string" || screen === "") {
|
|
242
|
+
throw new NativeNavigationError(
|
|
243
|
+
"missing-screen",
|
|
244
|
+
`@uniflowed/router/native: ${event.route} has no native screen mapping`,
|
|
245
|
+
event.href,
|
|
246
|
+
event.route,
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
return {
|
|
250
|
+
screen,
|
|
251
|
+
href: event.href,
|
|
252
|
+
pathname: event.pathname,
|
|
253
|
+
search: event.search,
|
|
254
|
+
route: event.route,
|
|
255
|
+
params: event.params,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export function nativeScreenNavigationState(
|
|
260
|
+
payload: NativeScreenPayload,
|
|
261
|
+
): NativeScreenNavigationState {
|
|
262
|
+
return {
|
|
263
|
+
params: payload.params,
|
|
264
|
+
href: payload.href,
|
|
265
|
+
pathname: payload.pathname,
|
|
266
|
+
search: payload.search,
|
|
267
|
+
route: payload.route,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function normalizeNativeHref(to: string): string {
|
|
272
|
+
if (/^[A-Za-z][A-Za-z0-9+.-]*:/.test(to) || to.startsWith("//")) {
|
|
273
|
+
throw new NativeNavigationError(
|
|
274
|
+
"external-url",
|
|
275
|
+
`@uniflowed/router/native: ${to} is an external URL, and a native navigator needs an app route`,
|
|
276
|
+
to,
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
if (!to.startsWith("/")) {
|
|
280
|
+
throw new NativeNavigationError(
|
|
281
|
+
"relative-url",
|
|
282
|
+
`@uniflowed/router/native: ${to} is relative, and native navigation has no document URL to resolve it against`,
|
|
283
|
+
to,
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
if (to.includes("#")) {
|
|
287
|
+
throw new NativeNavigationError(
|
|
288
|
+
"fragment",
|
|
289
|
+
`@uniflowed/router/native: ${to} contains a fragment, and native routes do not have document anchors`,
|
|
290
|
+
to,
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
return splitUrl(to).pathname + splitUrl(to).search;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function screenNameFor(
|
|
297
|
+
route: RouteRecord<mixed, mixed, mixed, mixed, mixed>,
|
|
298
|
+
options?: NativeScreenManifestOptions,
|
|
299
|
+
): string {
|
|
300
|
+
const screen =
|
|
301
|
+
options?.name?.({ path: route.path, file: route.file }) ?? nativeScreenName(route.path);
|
|
302
|
+
if (screen === "") {
|
|
303
|
+
throw new NativeNavigationError(
|
|
304
|
+
"missing-screen",
|
|
305
|
+
`@uniflowed/router/native: ${route.path} mapped to an empty native screen name`,
|
|
306
|
+
route.path,
|
|
307
|
+
route.path,
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
return screen;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function titlePart(segment: string): string {
|
|
314
|
+
const cleaned = segment
|
|
315
|
+
.replace(/^\[+|\]+$/g, "")
|
|
316
|
+
.replace(/[^A-Za-z0-9]+/g, " ")
|
|
317
|
+
.trim();
|
|
318
|
+
if (cleaned === "") {
|
|
319
|
+
return "Segment";
|
|
320
|
+
}
|
|
321
|
+
return cleaned
|
|
322
|
+
.split(/\s+/)
|
|
323
|
+
.map((part) => part.slice(0, 1).toUpperCase() + part.slice(1))
|
|
324
|
+
.join("");
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
async function invokeNavigator(
|
|
328
|
+
navigator: NativeNavigator,
|
|
329
|
+
event: NativeNavigationEvent,
|
|
330
|
+
): Promise<void> {
|
|
331
|
+
if (event.kind === "push") {
|
|
332
|
+
if (typeof navigator.push !== "function") {
|
|
333
|
+
throw missingMethod(event);
|
|
334
|
+
}
|
|
335
|
+
await navigator.push(event);
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
if (event.kind === "replace") {
|
|
339
|
+
if (typeof navigator.replace !== "function") {
|
|
340
|
+
throw missingMethod(event);
|
|
341
|
+
}
|
|
342
|
+
await navigator.replace(event);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function missingMethod(event: NativeNavigationEvent): NativeNavigationError {
|
|
347
|
+
return new NativeNavigationError(
|
|
348
|
+
"missing-navigator-method",
|
|
349
|
+
`@uniflowed/router/native: the native navigator does not implement ${event.kind}()`,
|
|
350
|
+
event.href,
|
|
351
|
+
event.route,
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async function invokeScreenNavigator(
|
|
356
|
+
navigator: NativeScreenNavigator,
|
|
357
|
+
screens: NativeScreenMap,
|
|
358
|
+
event: NativeNavigationEvent,
|
|
359
|
+
): Promise<void> {
|
|
360
|
+
const payload = nativeScreenPayload(event, screens);
|
|
361
|
+
const state = nativeScreenNavigationState(payload);
|
|
362
|
+
if (event.kind === "push") {
|
|
363
|
+
if (typeof navigator.push !== "function") {
|
|
364
|
+
throw missingMethod(event);
|
|
365
|
+
}
|
|
366
|
+
await navigator.push(payload.screen, state);
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
if (event.kind === "replace") {
|
|
370
|
+
if (typeof navigator.replace !== "function") {
|
|
371
|
+
throw missingMethod(event);
|
|
372
|
+
}
|
|
373
|
+
await navigator.replace(payload.screen, state);
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
if (typeof navigator.prefetch !== "function") {
|
|
377
|
+
throw missingMethod(event);
|
|
378
|
+
}
|
|
379
|
+
await navigator.prefetch(payload.screen, state);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
async function loadNativeRoute(
|
|
383
|
+
table: RouteTable<mixed, mixed, mixed, mixed, mixed>,
|
|
384
|
+
pathname: string,
|
|
385
|
+
): Promise<void> {
|
|
386
|
+
const matched = matchRoute(table.routes, pathname);
|
|
387
|
+
if (matched == null || !hasClientPage(matched.route)) {
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
await Promise.all([matched.route.page?.(), ...matched.route.layouts.map((layout) => layout())]);
|
|
391
|
+
}
|
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.33",
|
|
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",
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
".": "./index.js",
|
|
15
15
|
"./action": "./action.js",
|
|
16
16
|
"./client": "./client.js",
|
|
17
|
+
"./native": "./native.js",
|
|
17
18
|
"./server": "./server.js",
|
|
18
19
|
"./routing": "./routing.js",
|
|
19
20
|
"./package.json": "./package.json",
|
|
@@ -27,6 +28,7 @@
|
|
|
27
28
|
"index.js",
|
|
28
29
|
"internal",
|
|
29
30
|
"middleware.js",
|
|
31
|
+
"native.js",
|
|
30
32
|
"routing.js",
|
|
31
33
|
"server.js",
|
|
32
34
|
"!*.test.js"
|
|
@@ -36,7 +38,7 @@
|
|
|
36
38
|
"react-dom": ">=19"
|
|
37
39
|
},
|
|
38
40
|
"dependencies": {
|
|
39
|
-
"@uniflowed/hooks": "0.0.0-alpha.
|
|
40
|
-
"@uniflowed/server": "0.0.0-alpha.
|
|
41
|
+
"@uniflowed/hooks": "0.0.0-alpha.33",
|
|
42
|
+
"@uniflowed/server": "0.0.0-alpha.33"
|
|
41
43
|
}
|
|
42
44
|
}
|
package/routing.js
CHANGED
|
@@ -22,6 +22,14 @@ export type {
|
|
|
22
22
|
SlotRouteRecord,
|
|
23
23
|
TemplateRecord,
|
|
24
24
|
} from "./internal/routing.js";
|
|
25
|
+
export type {
|
|
26
|
+
ResolvedBoundarySummary,
|
|
27
|
+
ResolvedErrorBoundarySummary,
|
|
28
|
+
ResolvedRouteErrorSummary,
|
|
29
|
+
ResolvedRouteSummary,
|
|
30
|
+
ResolvedSlotSummary,
|
|
31
|
+
ResolvedTemplateSummary,
|
|
32
|
+
} from "./internal/resolved-summary.js";
|
|
25
33
|
|
|
26
34
|
export {
|
|
27
35
|
ForbiddenError,
|
|
@@ -40,3 +48,4 @@ export {
|
|
|
40
48
|
splitUrl,
|
|
41
49
|
unauthorized,
|
|
42
50
|
} from "./internal/routing.js";
|
|
51
|
+
export { summarizeResolvedRoute } from "./internal/resolved-summary.js";
|