@uniflowed/router 0.5.0 → 0.7.0
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/action.js +94 -57
- package/index.js +26 -2
- package/internal/action-endpoint.js +21 -13
- package/internal/error-view.js +60 -6
- package/internal/flight-browser.js +25 -1
- package/internal/flight.js +16 -2
- package/internal/resolve.js +37 -2
- package/internal/resolved-summary.js +7 -3
- package/internal/routing.js +121 -7
- package/internal/runtime.js +108 -6
- package/internal/search-params.js +234 -0
- package/middleware.js +6 -0
- package/native.js +3 -0
- package/package.json +4 -3
- package/routing.js +4 -0
- package/rsc-client.js +2 -0
- package/rsc-ssr.js +9 -2
- package/rsc.js +57 -15
- package/server-components.js +3 -1
- package/server.js +1 -0
|
@@ -6,12 +6,12 @@ import { routeBoundaries, suspenseId } from "./boundary-data.js";
|
|
|
6
6
|
import type { RouteBoundary } from "./boundary-data.js";
|
|
7
7
|
import type { RouteParams, SearchParams } from "./routing.js";
|
|
8
8
|
|
|
9
|
-
type RouteStatus = 200 | 401 | 403 | 404 | 500;
|
|
9
|
+
type RouteStatus = 200 | 400 | 401 | 403 | 404 | 500;
|
|
10
10
|
|
|
11
11
|
type RouteMetadata = { readonly [string]: mixed, ... };
|
|
12
12
|
|
|
13
13
|
type RouteErrorLike = {
|
|
14
|
-
readonly kind: "thrown" | "unauthorized" | "forbidden",
|
|
14
|
+
readonly kind: "thrown" | "unauthorized" | "forbidden" | "badRequest",
|
|
15
15
|
...
|
|
16
16
|
};
|
|
17
17
|
|
|
@@ -71,7 +71,8 @@ export type ResolvedTemplateSummary = {|
|
|
|
71
71
|
export type ResolvedRouteErrorSummary =
|
|
72
72
|
| {| readonly kind: "thrown" |}
|
|
73
73
|
| {| readonly kind: "unauthorized" |}
|
|
74
|
-
| {| readonly kind: "forbidden" |}
|
|
74
|
+
| {| readonly kind: "forbidden" |}
|
|
75
|
+
| {| readonly kind: "badRequest" |};
|
|
75
76
|
|
|
76
77
|
export type ResolvedErrorBoundarySummary = {|
|
|
77
78
|
readonly above: number,
|
|
@@ -195,5 +196,8 @@ function summarizeError(error: ?RouteErrorLike): ?ResolvedRouteErrorSummary {
|
|
|
195
196
|
if (error.kind === "forbidden") {
|
|
196
197
|
return { kind: "forbidden" };
|
|
197
198
|
}
|
|
199
|
+
if (error.kind === "badRequest") {
|
|
200
|
+
return { kind: "badRequest" };
|
|
201
|
+
}
|
|
198
202
|
return { kind: "thrown" };
|
|
199
203
|
}
|
package/internal/routing.js
CHANGED
|
@@ -10,12 +10,34 @@
|
|
|
10
10
|
/** One parameter a route path captures. */
|
|
11
11
|
export type RouteParamSpec = {| readonly name: string, readonly catchAll: boolean |};
|
|
12
12
|
|
|
13
|
-
/**
|
|
13
|
+
/**
|
|
14
|
+
* The parameters captured from a URL. A catch-all captures the rest as a list:
|
|
15
|
+
* `[...slug]` at least one segment, `[[...slug]]` any number, none included.
|
|
16
|
+
*/
|
|
14
17
|
export type RouteParams = { readonly [string]: string | $ReadOnlyArray<string> };
|
|
15
18
|
|
|
16
|
-
/** The query string, as a read-only map. */
|
|
19
|
+
/** The query string, as a read-only map. A repeated key keeps its last value. */
|
|
17
20
|
export type SearchParams = { readonly [string]: string };
|
|
18
21
|
|
|
22
|
+
/**
|
|
23
|
+
* The query string with every value of a repeated key kept, in order:
|
|
24
|
+
* `?tag=a&tag=b` is `{ tag: ["a", "b"] }`. See [`parseSearchAll`].
|
|
25
|
+
*/
|
|
26
|
+
export type SearchParamsAll = { readonly [string]: $ReadOnlyArray<string> };
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* One reason a query did not fit the schema a page declared for it.
|
|
30
|
+
*
|
|
31
|
+
* The shape of `@uniflowed/validator`'s `Issue`, written out here so that this
|
|
32
|
+
* file stays free of imports: `path` is where in the query the problem is —
|
|
33
|
+
* `["tags", "1"]` for the second `tag`.
|
|
34
|
+
*/
|
|
35
|
+
export type SearchParamsIssue = {|
|
|
36
|
+
readonly code: string,
|
|
37
|
+
readonly message: string,
|
|
38
|
+
readonly path?: $ReadOnlyArray<string>,
|
|
39
|
+
|};
|
|
40
|
+
|
|
19
41
|
/** A lazy route module entry from the generated route table. */
|
|
20
42
|
export type RouteModule<TModule = mixed> = () => Promise<TModule>;
|
|
21
43
|
|
|
@@ -165,11 +187,18 @@ export type RouteMatch<TRoute extends { readonly path: string, ... } = UnknownRo
|
|
|
165
187
|
export type RouteError =
|
|
166
188
|
| {| readonly kind: "thrown", readonly error: mixed |}
|
|
167
189
|
| {| readonly kind: "unauthorized" |}
|
|
168
|
-
| {| readonly kind: "forbidden" |}
|
|
190
|
+
| {| readonly kind: "forbidden" |}
|
|
191
|
+
/**
|
|
192
|
+
* The query did not fit the schema the page exports as `searchParams`.
|
|
193
|
+
* The request's fault rather than the page's, so a `400` — and the issues
|
|
194
|
+
* say which parameter and why, so an `$error.js` can say so too.
|
|
195
|
+
*/
|
|
196
|
+
| {| readonly kind: "badRequest", readonly issues: $ReadOnlyArray<SearchParamsIssue> |};
|
|
169
197
|
|
|
170
198
|
/** The status a `RouteError` answers with. */
|
|
171
|
-
export function routeErrorStatus(error: RouteError): 401 | 403 | 500 {
|
|
199
|
+
export function routeErrorStatus(error: RouteError): 400 | 401 | 403 | 500 {
|
|
172
200
|
return match (error) {
|
|
201
|
+
{kind: "badRequest", ...} => 400,
|
|
173
202
|
{kind: "unauthorized"} => 401,
|
|
174
203
|
{kind: "forbidden"} => 403,
|
|
175
204
|
{kind: "thrown", ...} => 500,
|
|
@@ -192,6 +221,24 @@ export class UnauthorizedError extends Error {
|
|
|
192
221
|
}
|
|
193
222
|
}
|
|
194
223
|
|
|
224
|
+
/**
|
|
225
|
+
* Thrown when a query does not fit the page's `searchParams` schema; the
|
|
226
|
+
* renderer answers with the error boundary, as a `400`.
|
|
227
|
+
*/
|
|
228
|
+
export class SearchParamsError extends Error {
|
|
229
|
+
issues: $ReadOnlyArray<SearchParamsIssue>;
|
|
230
|
+
|
|
231
|
+
constructor(issues: $ReadOnlyArray<SearchParamsIssue>) {
|
|
232
|
+
super(
|
|
233
|
+
`the query string does not fit this page's searchParams schema: ${issues
|
|
234
|
+
.map((issue) => `${(issue.path ?? []).join(".") || "(query)"}: ${issue.message}`)
|
|
235
|
+
.join("; ")}`,
|
|
236
|
+
);
|
|
237
|
+
this.name = "SearchParamsError";
|
|
238
|
+
this.issues = issues;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
195
242
|
/** Thrown by `forbidden()`; the renderer answers with the error boundary. */
|
|
196
243
|
export class ForbiddenError extends Error {
|
|
197
244
|
constructor() {
|
|
@@ -281,13 +328,21 @@ export class RedirectError extends Error {
|
|
|
281
328
|
type Segment =
|
|
282
329
|
| {| readonly kind: "static", readonly value: string |}
|
|
283
330
|
| {| readonly kind: "param", readonly name: string |}
|
|
284
|
-
| {| readonly kind: "catchAll", readonly name: string |}
|
|
331
|
+
| {| readonly kind: "catchAll", readonly name: string |}
|
|
332
|
+
| {| readonly kind: "optionalCatchAll", readonly name: string |};
|
|
285
333
|
|
|
334
|
+
/**
|
|
335
|
+
* A route path, as segments: `posts` is static, `:slug` a parameter, `:slug*`
|
|
336
|
+
* a catch-all (`[...slug]`) and `:slug*?` an optional one (`[[...slug]]`).
|
|
337
|
+
*/
|
|
286
338
|
function compile(routePath: string): $ReadOnlyArray<Segment> {
|
|
287
339
|
return routePath
|
|
288
340
|
.split("/")
|
|
289
341
|
.filter((segment) => segment !== "")
|
|
290
342
|
.map((segment): Segment => {
|
|
343
|
+
if (segment.startsWith(":") && segment.endsWith("*?")) {
|
|
344
|
+
return { kind: "optionalCatchAll", name: segment.slice(1, -2) };
|
|
345
|
+
}
|
|
291
346
|
if (segment.startsWith(":") && segment.endsWith("*")) {
|
|
292
347
|
return { kind: "catchAll", name: segment.slice(1, -1) };
|
|
293
348
|
}
|
|
@@ -300,7 +355,8 @@ function compile(routePath: string): $ReadOnlyArray<Segment> {
|
|
|
300
355
|
|
|
301
356
|
/**
|
|
302
357
|
* How specific a route is, for ranking: a static segment outranks a parameter,
|
|
303
|
-
* which outranks a catch-all,
|
|
358
|
+
* which outranks a catch-all, which outranks an optional one, and a longer
|
|
359
|
+
* path outranks a shorter one.
|
|
304
360
|
*/
|
|
305
361
|
function specificity(segments: $ReadOnlyArray<Segment>): number {
|
|
306
362
|
let score = 0;
|
|
@@ -309,6 +365,7 @@ function specificity(segments: $ReadOnlyArray<Segment>): number {
|
|
|
309
365
|
{kind: "static", ...} => 3,
|
|
310
366
|
{kind: "param", ...} => 2,
|
|
311
367
|
{kind: "catchAll", ...} => 1,
|
|
368
|
+
{kind: "optionalCatchAll", ...} => 0,
|
|
312
369
|
};
|
|
313
370
|
}
|
|
314
371
|
return score;
|
|
@@ -336,6 +393,17 @@ function matchSegments(
|
|
|
336
393
|
index += 1;
|
|
337
394
|
}
|
|
338
395
|
{kind: "catchAll", name: const name} => {
|
|
396
|
+
// `[...slug]` needs something to take: `/docs` is not
|
|
397
|
+
// `/docs/[...slug]`, which is what `[[...slug]]` is for. Without
|
|
398
|
+
// this a catch-all outranked the page at its parent path — one more
|
|
399
|
+
// segment is one more point — and answered `/docs` in its place.
|
|
400
|
+
if (index >= parts.length) {
|
|
401
|
+
return null;
|
|
402
|
+
}
|
|
403
|
+
params[name] = parts.slice(index).map(decodeSegment);
|
|
404
|
+
index = parts.length;
|
|
405
|
+
}
|
|
406
|
+
{kind: "optionalCatchAll", name: const name} => {
|
|
339
407
|
params[name] = parts.slice(index).map(decodeSegment);
|
|
340
408
|
index = parts.length;
|
|
341
409
|
}
|
|
@@ -377,6 +445,26 @@ export function buildRoute(routePath: string, params?: RouteParams): string {
|
|
|
377
445
|
describeParam(value),
|
|
378
446
|
);
|
|
379
447
|
}
|
|
448
|
+
// The URL an empty list builds is the parent path, which this route
|
|
449
|
+
// does not serve — a link that 404s. An optional catch-all does.
|
|
450
|
+
if (value.length === 0) {
|
|
451
|
+
throw new Error(
|
|
452
|
+
`route ${routePath} takes at least one segment for :${name}*, and got an empty ` +
|
|
453
|
+
`array; a route that also serves its parent path is [[...${name}]], :${name}*?`,
|
|
454
|
+
);
|
|
455
|
+
}
|
|
456
|
+
for (const part of value) {
|
|
457
|
+
parts.push(encodeURIComponent(part));
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
{kind: "optionalCatchAll", name: const name} => {
|
|
461
|
+
const value = values[name];
|
|
462
|
+
if (value == null || typeof value === "string") {
|
|
463
|
+
throw new Error(
|
|
464
|
+
`route ${routePath} takes an array of segments for :${name}*?, and got ` +
|
|
465
|
+
describeParam(value),
|
|
466
|
+
);
|
|
467
|
+
}
|
|
380
468
|
for (const part of value) {
|
|
381
469
|
parts.push(encodeURIComponent(part));
|
|
382
470
|
}
|
|
@@ -455,7 +543,8 @@ function covers(segments: $ReadOnlyArray<Segment>, parts: $ReadOnlyArray<string>
|
|
|
455
543
|
const next = match (segment) {
|
|
456
544
|
{kind: "static", value: const value} => parts[index] === value ? index + 1 : -1,
|
|
457
545
|
{kind: "param", ...} => index < parts.length ? index + 1 : -1,
|
|
458
|
-
{kind: "catchAll", ...} => parts.length,
|
|
546
|
+
{kind: "catchAll", ...} => index < parts.length ? parts.length : -1,
|
|
547
|
+
{kind: "optionalCatchAll", ...} => parts.length,
|
|
459
548
|
};
|
|
460
549
|
if (next === -1) {
|
|
461
550
|
return false;
|
|
@@ -517,6 +606,31 @@ export function parseSearch(search: string): SearchParams {
|
|
|
517
606
|
return params;
|
|
518
607
|
}
|
|
519
608
|
|
|
609
|
+
/**
|
|
610
|
+
* Every value of every key in a query string, in order.
|
|
611
|
+
*
|
|
612
|
+
* [`parseSearch`] keeps the last value of a repeated key, which is what a page
|
|
613
|
+
* that reads `searchParams.q` wants and loses `?tag=a&tag=b`'s first tag. This
|
|
614
|
+
* is the same query with nothing dropped, for a page that declares no schema
|
|
615
|
+
* and needs the repeats; a page that declares one gets arrays wherever the
|
|
616
|
+
* schema says array.
|
|
617
|
+
*/
|
|
618
|
+
export function parseSearchAll(search: string): SearchParamsAll {
|
|
619
|
+
// A `Map` and then `Object.fromEntries`, which defines own properties: a
|
|
620
|
+
// query is written by whoever sent the request, and `?__proto__=x` must be a
|
|
621
|
+
// key like any other rather than a lookup that finds `Object.prototype`.
|
|
622
|
+
const params = new Map<string, Array<string>>();
|
|
623
|
+
for (const [key, value] of new URLSearchParams(search)) {
|
|
624
|
+
const values = params.get(key);
|
|
625
|
+
if (values == null) {
|
|
626
|
+
params.set(key, [value]);
|
|
627
|
+
} else {
|
|
628
|
+
values.push(value);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
return Object.fromEntries(params);
|
|
632
|
+
}
|
|
633
|
+
|
|
520
634
|
/** Stop rendering the current page and show the not-found page instead. */
|
|
521
635
|
export function notFound(): empty {
|
|
522
636
|
throw new NotFoundError();
|
package/internal/runtime.js
CHANGED
|
@@ -101,6 +101,7 @@ import {
|
|
|
101
101
|
routeNavigations,
|
|
102
102
|
} from "./navigation-cache.js";
|
|
103
103
|
import {
|
|
104
|
+
NotFoundError,
|
|
104
105
|
hasClientPage,
|
|
105
106
|
matchRoute,
|
|
106
107
|
nearestBoundary,
|
|
@@ -112,17 +113,27 @@ import {
|
|
|
112
113
|
beneath,
|
|
113
114
|
interceptingRoutes,
|
|
114
115
|
loadOnce,
|
|
116
|
+
pageSearchParams,
|
|
117
|
+
resolveFailure,
|
|
115
118
|
resolveInterception,
|
|
116
119
|
resolveMatch,
|
|
117
120
|
} from "./resolve.js";
|
|
118
121
|
import type { Metadata, ResolvedRoute, RouteTable } from "./resolve.js";
|
|
119
122
|
|
|
120
|
-
export type {
|
|
123
|
+
export type {
|
|
124
|
+
RouteError,
|
|
125
|
+
RouteParamSpec,
|
|
126
|
+
RouteParams,
|
|
127
|
+
SearchParams,
|
|
128
|
+
SearchParamsAll,
|
|
129
|
+
SearchParamsIssue,
|
|
130
|
+
} from "./routing.js";
|
|
121
131
|
|
|
122
132
|
export {
|
|
123
133
|
ForbiddenError,
|
|
124
134
|
NotFoundError,
|
|
125
135
|
RedirectError,
|
|
136
|
+
SearchParamsError,
|
|
126
137
|
UnauthorizedError,
|
|
127
138
|
buildRoute,
|
|
128
139
|
forbidden,
|
|
@@ -130,6 +141,7 @@ export {
|
|
|
130
141
|
matchRoute,
|
|
131
142
|
notFound,
|
|
132
143
|
parseSearch,
|
|
144
|
+
parseSearchAll,
|
|
133
145
|
permanentRedirect,
|
|
134
146
|
redirect,
|
|
135
147
|
routeErrorStatus,
|
|
@@ -370,18 +382,60 @@ export type Router = {|
|
|
|
370
382
|
*/
|
|
371
383
|
let mountedRouter: Router | null = null;
|
|
372
384
|
|
|
373
|
-
/**
|
|
374
|
-
|
|
385
|
+
/**
|
|
386
|
+
* How the provider on screen shows its not-found page for the URL it is on, or
|
|
387
|
+
* `null` when none is mounted. See [`showNotFoundPage`].
|
|
388
|
+
*/
|
|
389
|
+
let mountedNotFound: ShowNotFound | null = null;
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Show the not-found page for the URL on screen, and run `alongside` in the
|
|
393
|
+
* same transition as the commit that shows it. Resolves `false`, having shown
|
|
394
|
+
* nothing, when there is no not-found page to be had for it.
|
|
395
|
+
*/
|
|
396
|
+
type ShowNotFound = (alongside: () => void) => Promise<boolean>;
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Publish `router` as [`mountedRouter`], and its not-found page as
|
|
400
|
+
* [`mountedNotFound`], while its provider is mounted.
|
|
401
|
+
*/
|
|
402
|
+
hook useMountedRouter(router: Router, showNotFound: ShowNotFound): void {
|
|
375
403
|
useEffect(() => {
|
|
376
404
|
mountedRouter = router;
|
|
405
|
+
mountedNotFound = showNotFound;
|
|
377
406
|
return () => {
|
|
378
407
|
if (mountedRouter === router) {
|
|
379
408
|
mountedRouter = null;
|
|
409
|
+
mountedNotFound = null;
|
|
380
410
|
}
|
|
381
411
|
};
|
|
382
412
|
});
|
|
383
413
|
}
|
|
384
414
|
|
|
415
|
+
/**
|
|
416
|
+
* Show the page a loader's `notFound()` would have shown for the URL on screen,
|
|
417
|
+
* because something below the route's error boundary threw `NotFoundError`.
|
|
418
|
+
*
|
|
419
|
+
* What a loader's `notFound()` shows is not a boundary catching it: the
|
|
420
|
+
* resolver resolves the route again as its not-found page (`resolveFailure`).
|
|
421
|
+
* A hydrated server action that calls `notFound()` throws the same error in the
|
|
422
|
+
* browser (`../action.js`), React hands it to the nearest boundary, and the
|
|
423
|
+
* boundary asks for that same resolution here rather than rendering the error
|
|
424
|
+
* view (ubugeeei-prod/uf#1489). A page rendered from its modules resolves it
|
|
425
|
+
* in the browser; a page React Server Components rendered asks the server for
|
|
426
|
+
* the not-found payload of its URL, because only the server has the module.
|
|
427
|
+
*
|
|
428
|
+
* `alongside` is the boundary letting go of the error, run in the transition
|
|
429
|
+
* that commits the not-found page so the page that threw is never rendered
|
|
430
|
+
* again in between. `false` when there is nothing to show — no router mounted,
|
|
431
|
+
* or a host that did not answer with a not-found payload — and the boundary
|
|
432
|
+
* then shows its error view, as it did before.
|
|
433
|
+
*/
|
|
434
|
+
export function showNotFoundPage(alongside: () => void): Promise<boolean> {
|
|
435
|
+
const show = mountedNotFound;
|
|
436
|
+
return show == null ? Promise.resolve(false) : show(alongside);
|
|
437
|
+
}
|
|
438
|
+
|
|
385
439
|
/**
|
|
386
440
|
* Go where a server action's `redirect()` pointed.
|
|
387
441
|
*
|
|
@@ -1046,7 +1100,25 @@ component ModuleRouter(url: string, initial: ResolvedRoute, children: React.Node
|
|
|
1046
1100
|
},
|
|
1047
1101
|
};
|
|
1048
1102
|
|
|
1049
|
-
|
|
1103
|
+
// The URL on screen resolved as its not-found page, in the page: the table
|
|
1104
|
+
// this router resolves from has the not-found modules too. No history entry
|
|
1105
|
+
// is written, because the URL is still the one the visitor is on.
|
|
1106
|
+
const showNotFound = async (alongside: () => void): Promise<boolean> => {
|
|
1107
|
+
if (!isBrowser()) {
|
|
1108
|
+
return false;
|
|
1109
|
+
}
|
|
1110
|
+
const here =
|
|
1111
|
+
(applicationPathOf(window.location.pathname) ?? window.location.pathname) +
|
|
1112
|
+
window.location.search;
|
|
1113
|
+
const nextResolved = await resolveFailure(routeTable(), here, new NotFoundError());
|
|
1114
|
+
startTransition(() => {
|
|
1115
|
+
show(nextResolved);
|
|
1116
|
+
alongside();
|
|
1117
|
+
});
|
|
1118
|
+
return true;
|
|
1119
|
+
};
|
|
1120
|
+
|
|
1121
|
+
useMountedRouter(router, showNotFound);
|
|
1050
1122
|
|
|
1051
1123
|
const value: RouterState = {
|
|
1052
1124
|
route: routeState(resolved),
|
|
@@ -1320,7 +1392,37 @@ component FlightRouter(flight: Promise<FlightRoot>, children: React.Node) {
|
|
|
1320
1392
|
},
|
|
1321
1393
|
};
|
|
1322
1394
|
|
|
1323
|
-
|
|
1395
|
+
// The URL on screen as its not-found page, asked of the server: the payload
|
|
1396
|
+
// for this URL with `notFound`, which renders the not-found resolution a
|
|
1397
|
+
// loader's `notFound()` would have. Not kept in the navigation cache, because
|
|
1398
|
+
// it is not what a navigation to this URL shows. Anything that is not such a
|
|
1399
|
+
// payload — a document, a payload another build rendered, a host that ignored
|
|
1400
|
+
// the request and answered the page — is nothing to show, and the boundary
|
|
1401
|
+
// falls back to its error view.
|
|
1402
|
+
const showNotFound = async (alongside: () => void): Promise<boolean> => {
|
|
1403
|
+
if (!isBrowser()) {
|
|
1404
|
+
return false;
|
|
1405
|
+
}
|
|
1406
|
+
const fetched = await fetchFlight(window.location.pathname + window.location.search, {
|
|
1407
|
+
...flightFetchOptions(root.route.interception?.from ?? null),
|
|
1408
|
+
notFound: true,
|
|
1409
|
+
});
|
|
1410
|
+
if (fetched.kind === "document") {
|
|
1411
|
+
return false;
|
|
1412
|
+
}
|
|
1413
|
+
const payload = fetched.root;
|
|
1414
|
+
const nextRoot = await payload;
|
|
1415
|
+
if (fromAnotherDeployment(nextRoot.deployment) || nextRoot.route.status !== 404) {
|
|
1416
|
+
return false;
|
|
1417
|
+
}
|
|
1418
|
+
startTransition(() => {
|
|
1419
|
+
show(payload, nextRoot);
|
|
1420
|
+
alongside();
|
|
1421
|
+
});
|
|
1422
|
+
return true;
|
|
1423
|
+
};
|
|
1424
|
+
|
|
1425
|
+
useMountedRouter(router, showNotFound);
|
|
1324
1426
|
|
|
1325
1427
|
const value: RouterState = {
|
|
1326
1428
|
route: root.route,
|
|
@@ -1616,7 +1718,7 @@ component RenderedPage(data: mixed) {
|
|
|
1616
1718
|
// uf-lint-disable react-compiler/static-components
|
|
1617
1719
|
return (
|
|
1618
1720
|
<>
|
|
1619
|
-
<Page params={resolved.params} searchParams={resolved
|
|
1721
|
+
<Page params={resolved.params} searchParams={pageSearchParams(resolved)} data={data} />
|
|
1620
1722
|
{payloadElements(data)}
|
|
1621
1723
|
</>
|
|
1622
1724
|
);
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// Internal to `@uniflowed/router`: a page's `searchParams` schema, run against
|
|
4
|
+
// the query string.
|
|
5
|
+
//
|
|
6
|
+
// A page that exports
|
|
7
|
+
//
|
|
8
|
+
// export const searchParams = object({ page: number(), tag: array(string()) });
|
|
9
|
+
//
|
|
10
|
+
// is handed the schema's output as its `searchParams` prop instead of the
|
|
11
|
+
// string map every other page gets. Two things stand between a query string
|
|
12
|
+
// and that schema, and both are this file.
|
|
13
|
+
//
|
|
14
|
+
// **Shape.** A query is a list of pairs, and a key may repeat. Which repeats
|
|
15
|
+
// are a list and which are one value is not something the query says — it is
|
|
16
|
+
// what the schema says, so the schema is asked: a field described as an array
|
|
17
|
+
// (or a set, or a tuple) gets every value in order, and any other field gets
|
|
18
|
+
// the last one, which is what `parseSearch` has always kept. A field that is a
|
|
19
|
+
// bare array and whose key is absent gets `[]`, because no `tag` in the query
|
|
20
|
+
// is no tags rather than a malformed request.
|
|
21
|
+
//
|
|
22
|
+
// **Type.** Every value in a query is a string, and `number()` refuses a string.
|
|
23
|
+
// A field described as a number, a boolean, a bigint or a date is given the
|
|
24
|
+
// value the string spells when it spells one — `"2"` is `2`, `"true"` is
|
|
25
|
+
// `true` — and the string itself when it does not, so the schema's own message
|
|
26
|
+
// is what a malformed value is reported with. A field the schema reads as a
|
|
27
|
+
// string is left alone, which is why `pipe(string(), transform(Number))` still
|
|
28
|
+
// sees the string it asked for.
|
|
29
|
+
//
|
|
30
|
+
// Everything past that is the schema's: optionality, defaults, refinements,
|
|
31
|
+
// transforms. A query that does not fit is a `SearchParamsError`, which the
|
|
32
|
+
// resolver turns into the error boundary with a `400` — the request's fault,
|
|
33
|
+
// not the page's.
|
|
34
|
+
|
|
35
|
+
// The two subpaths rather than the package: a browser bundle of the router
|
|
36
|
+
// carries the parser and the description reader, and none of the builders.
|
|
37
|
+
import { safeParseAsync } from "@uniflowed/validator/parse";
|
|
38
|
+
import { describe } from "@uniflowed/validator/schema";
|
|
39
|
+
import type { Description, Schema } from "@uniflowed/validator/schema";
|
|
40
|
+
|
|
41
|
+
import { SearchParamsError, parseSearchAll } from "./routing.js";
|
|
42
|
+
import type { SearchParamsAll } from "./routing.js";
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The value a page's `searchParams` schema produces for `search`.
|
|
46
|
+
*
|
|
47
|
+
* Throws a [`SearchParamsError`] carrying every issue when the query does not
|
|
48
|
+
* fit, rather than returning them: the resolver's catch is what turns a thrown
|
|
49
|
+
* router error into a boundary, and this is one.
|
|
50
|
+
*/
|
|
51
|
+
export async function parseSearchParams(
|
|
52
|
+
schema: Schema<mixed, mixed>,
|
|
53
|
+
search: string,
|
|
54
|
+
): Promise<mixed> {
|
|
55
|
+
const input = searchParamsInput(describe(schema), parseSearchAll(search));
|
|
56
|
+
const result = await safeParseAsync(schema, input);
|
|
57
|
+
if (!result.ok) {
|
|
58
|
+
throw new SearchParamsError(result.issues);
|
|
59
|
+
}
|
|
60
|
+
return result.value;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The object a schema described by `description` is given for a query.
|
|
65
|
+
*
|
|
66
|
+
* Exported for the tests, which hold the shape and the coercion to what the
|
|
67
|
+
* header says without a route table around them.
|
|
68
|
+
*/
|
|
69
|
+
export function searchParamsInput(
|
|
70
|
+
description: Description,
|
|
71
|
+
query: SearchParamsAll,
|
|
72
|
+
): { readonly [string]: mixed } {
|
|
73
|
+
const fields = objectFields(description);
|
|
74
|
+
// Built as a `Map` and turned into an object once, by `Object.fromEntries`,
|
|
75
|
+
// which defines own properties: `?__proto__=x` is a key like any other.
|
|
76
|
+
const input = new Map<string, mixed>();
|
|
77
|
+
for (const key of Object.keys(query)) {
|
|
78
|
+
const values = query[key];
|
|
79
|
+
const field = fields.get(key);
|
|
80
|
+
if (field == null) {
|
|
81
|
+
// A key the schema does not name: an `object()` strips it and a
|
|
82
|
+
// `strictObject()` refuses it, and a `looseObject()` keeps it as the
|
|
83
|
+
// query had it — one value, or every value of a repeated key.
|
|
84
|
+
input.set(key, values.length === 1 ? values[0] : values);
|
|
85
|
+
} else {
|
|
86
|
+
input.set(key, fieldValue(field, values));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
for (const [key, field] of fields) {
|
|
90
|
+
if (!input.has(key) && isList(plain(field))) {
|
|
91
|
+
input.set(key, []);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return Object.fromEntries(input);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The fields a schema names, when it is an object or an intersection of them. */
|
|
98
|
+
function objectFields(description: Description): Map<string, Description> {
|
|
99
|
+
const found = new Map<string, Description>();
|
|
100
|
+
const visit = (node: Description): void => {
|
|
101
|
+
const unwrapped = unwrap(node);
|
|
102
|
+
if (unwrapped.kind === "object") {
|
|
103
|
+
for (const [key, field] of unwrapped.entries) {
|
|
104
|
+
found.set(key, field);
|
|
105
|
+
}
|
|
106
|
+
} else if (unwrapped.kind === "intersect") {
|
|
107
|
+
for (const part of unwrapped.parts) {
|
|
108
|
+
visit(part);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
visit(description);
|
|
113
|
+
return found;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** What one field is given: every value for a list, the last for anything else. */
|
|
117
|
+
function fieldValue(field: Description, values: $ReadOnlyArray<string>): mixed {
|
|
118
|
+
const described = unwrap(field);
|
|
119
|
+
if (described.kind === "array" || described.kind === "set") {
|
|
120
|
+
const item = described.item;
|
|
121
|
+
return values.map((value) => coerce(item, value));
|
|
122
|
+
}
|
|
123
|
+
if (described.kind === "tuple") {
|
|
124
|
+
const items = described.items;
|
|
125
|
+
return values.map((value, index) =>
|
|
126
|
+
index < items.length ? coerce(items[index], value) : value,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
return coerce(described, values[values.length - 1]);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The value `raw` spells for a field described by `description`, or `raw`. */
|
|
133
|
+
function coerce(description: Description, raw: string): mixed {
|
|
134
|
+
const described = unwrap(description);
|
|
135
|
+
switch (described.kind) {
|
|
136
|
+
case "number":
|
|
137
|
+
return numeric(raw) ?? raw;
|
|
138
|
+
case "bigint":
|
|
139
|
+
return /^[-+]?\d+$/.test(raw.trim()) ? BigInt(raw.trim()) : raw;
|
|
140
|
+
case "boolean":
|
|
141
|
+
return raw === "true" ? true : raw === "false" ? false : raw;
|
|
142
|
+
case "date": {
|
|
143
|
+
const date = new Date(raw);
|
|
144
|
+
return Number.isNaN(date.getTime()) ? raw : date;
|
|
145
|
+
}
|
|
146
|
+
case "literal": {
|
|
147
|
+
const value = described.value;
|
|
148
|
+
if (typeof value === "number") return numeric(raw) === value ? value : raw;
|
|
149
|
+
if (typeof value === "boolean") return raw === String(value) ? value : raw;
|
|
150
|
+
if (value === null) return raw === "null" ? null : raw;
|
|
151
|
+
return raw;
|
|
152
|
+
}
|
|
153
|
+
case "union": {
|
|
154
|
+
// The string itself when some option takes it as it is, so
|
|
155
|
+
// `union([literal("all"), number()])` reads `all` as `"all"` and `3` as 3.
|
|
156
|
+
const options = described.options.map(unwrap);
|
|
157
|
+
if (options.some((option) => acceptsAsIs(option, raw))) {
|
|
158
|
+
return raw;
|
|
159
|
+
}
|
|
160
|
+
for (const option of options) {
|
|
161
|
+
const coerced = coerce(option, raw);
|
|
162
|
+
if (coerced !== raw) return coerced;
|
|
163
|
+
}
|
|
164
|
+
return raw;
|
|
165
|
+
}
|
|
166
|
+
default:
|
|
167
|
+
return raw;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Whether a field described this way takes `raw` without coercion. */
|
|
172
|
+
function acceptsAsIs(described: Description, raw: string): boolean {
|
|
173
|
+
switch (described.kind) {
|
|
174
|
+
case "string":
|
|
175
|
+
case "unknown":
|
|
176
|
+
return true;
|
|
177
|
+
case "enum":
|
|
178
|
+
return described.values.includes(raw);
|
|
179
|
+
case "literal":
|
|
180
|
+
return described.value === raw;
|
|
181
|
+
default:
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** A finite number `raw` spells, or `null`. `""` is not zero. */
|
|
187
|
+
function numeric(raw: string): ?number {
|
|
188
|
+
if (raw.trim() === "") return null;
|
|
189
|
+
const value = Number(raw);
|
|
190
|
+
return Number.isFinite(value) ? value : null;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* A description with every wrapper that does not change what the value *is*
|
|
195
|
+
* taken off: optionality, defaults, `pipe` steps and `lazy`.
|
|
196
|
+
*/
|
|
197
|
+
function unwrap(description: Description): Description {
|
|
198
|
+
const inner = innerOf(description, true);
|
|
199
|
+
return inner == null ? description : unwrap(inner);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* The same, keeping optionality and defaults: whether a field that is absent
|
|
204
|
+
* from the query should be an empty list is a question about the field as it
|
|
205
|
+
* was written, and `optional(array(…))` answers it differently from
|
|
206
|
+
* `array(…)`.
|
|
207
|
+
*/
|
|
208
|
+
function plain(description: Description): Description {
|
|
209
|
+
const inner = innerOf(description, false);
|
|
210
|
+
return inner == null ? description : plain(inner);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** What a wrapper wraps, or `null` for a description that is not one. */
|
|
214
|
+
function innerOf(description: Description, optionality: boolean): ?Description {
|
|
215
|
+
switch (description.kind) {
|
|
216
|
+
case "transformed":
|
|
217
|
+
case "constrained":
|
|
218
|
+
return description.inner;
|
|
219
|
+
case "lazy":
|
|
220
|
+
return description.inner();
|
|
221
|
+
case "optional":
|
|
222
|
+
case "nullable":
|
|
223
|
+
case "nullish":
|
|
224
|
+
case "default":
|
|
225
|
+
case "fallback":
|
|
226
|
+
return optionality ? description.inner : null;
|
|
227
|
+
default:
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function isList(description: Description): boolean {
|
|
233
|
+
return description.kind === "array" || description.kind === "set";
|
|
234
|
+
}
|
package/middleware.js
CHANGED
|
@@ -455,6 +455,12 @@ function matchPrefix(routePath: string, pathname: string): RouteParams | null {
|
|
|
455
455
|
|
|
456
456
|
for (let index = 0; index < wanted.length; index += 1) {
|
|
457
457
|
const segment = wanted[index];
|
|
458
|
+
// A prefix, so the rest of the path — none of it included — is either
|
|
459
|
+
// catch-all's: `[[...slug]]` is `:slug*?`, `[...slug]` is `:slug*`.
|
|
460
|
+
if (segment.startsWith(":") && segment.endsWith("*?")) {
|
|
461
|
+
params[segment.slice(1, -2)] = given.slice(index);
|
|
462
|
+
return params as $FlowFixMe;
|
|
463
|
+
}
|
|
458
464
|
if (segment.startsWith(":") && segment.endsWith("*")) {
|
|
459
465
|
params[segment.slice(1, -1)] = given.slice(index);
|
|
460
466
|
return params as $FlowFixMe;
|
package/native.js
CHANGED
|
@@ -207,6 +207,9 @@ export function nativeScreenName(routePath: string): string {
|
|
|
207
207
|
}
|
|
208
208
|
const name = segments
|
|
209
209
|
.map((segment) => {
|
|
210
|
+
if (segment.startsWith(":") && segment.endsWith("*?")) {
|
|
211
|
+
return `Any${titlePart(segment.slice(1, -2))}`;
|
|
212
|
+
}
|
|
210
213
|
if (segment.startsWith(":") && segment.endsWith("*")) {
|
|
211
214
|
return `All${titlePart(segment.slice(1, -1))}`;
|
|
212
215
|
}
|