@uniflowed/router 0.0.0-alpha.31 → 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.
@@ -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
+ }
@@ -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 own markers, and no others
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}`;
@@ -113,7 +113,7 @@ export function createPayloadReader(
113
113
  ): PayloadReader {
114
114
  const slots: Map<number, Slot> = new Map();
115
115
  let disconnect: (() => void) | null = null;
116
- let watching = false;
116
+ let reading = false;
117
117
 
118
118
  function slotFor(id: number): Slot {
119
119
  const existing = slots.get(id);
@@ -192,7 +192,7 @@ export function createPayloadReader(
192
192
  }
193
193
 
194
194
  function stop(): void {
195
- watching = false;
195
+ reading = false;
196
196
  const off = disconnect;
197
197
  disconnect = null;
198
198
  if (off != null) {
@@ -200,28 +200,39 @@ export function createPayloadReader(
200
200
  }
201
201
  }
202
202
 
203
+ function observeRest(): void {
204
+ if (!reading || disconnect != null || finished() || observe == null) {
205
+ return;
206
+ }
207
+ disconnect = observe(sweep);
208
+ if (disconnect == null) {
209
+ reading = false;
210
+ }
211
+ }
212
+
203
213
  return {
204
214
  resolve(id: number): Promise<mixed> {
205
215
  const slot = slotFor(id);
206
216
  // A reference discovered after the watch started — a client navigation
207
217
  // decoding a payload of its own — still gets whatever is already in the
208
218
  // document, so the order the two calls happen in does not matter.
209
- if (watching) {
219
+ if (reading) {
210
220
  sweep();
221
+ observeRest();
211
222
  }
212
223
  return slot.promise;
213
224
  },
214
225
  watch(): void {
215
- if (watching || slots.size === 0) {
226
+ if (reading) {
216
227
  return;
217
228
  }
218
- watching = true;
229
+ reading = true;
219
230
  sweep();
220
- if (finished() || observe == null) {
221
- watching = false;
231
+ if (slots.size > 0 && finished()) {
232
+ reading = false;
222
233
  return;
223
234
  }
224
- disconnect = observe(sweep);
235
+ observeRest();
225
236
  },
226
237
  stop,
227
238
  };
@@ -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
+ }
@@ -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<TPage = mixed, TLayout = mixed> = {|
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 routes: $ReadOnlyArray<SlotRouteRecord<TPage, TLayout>>,
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<TPage = mixed, TLayout = mixed> = {|
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 slots: $ReadOnlyArray<SlotRecord<TPage, TLayout>>,
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<TPage = mixed, TLayout = mixed, TTemplate = mixed, TLoading = mixed> = {|
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> = {|
@@ -463,11 +463,24 @@ export type RouteRecord = RoutingRouteRecord<
463
463
  LayoutModule,
464
464
  TemplateModule,
465
465
  LoadingModule,
466
+ ErrorModule,
466
467
  >;
467
468
 
468
- export type SlotRecord = RoutingSlotRecord<PageModule, LayoutModule>;
469
+ export type SlotRecord = RoutingSlotRecord<
470
+ PageModule,
471
+ LayoutModule,
472
+ TemplateModule,
473
+ LoadingModule,
474
+ ErrorModule,
475
+ >;
469
476
 
470
- export type SlotRouteRecord = RoutingSlotRouteRecord<PageModule, LayoutModule>;
477
+ export type SlotRouteRecord = RoutingSlotRouteRecord<
478
+ PageModule,
479
+ LayoutModule,
480
+ TemplateModule,
481
+ LoadingModule,
482
+ ErrorModule,
483
+ >;
471
484
 
472
485
  export type TemplateRecord = RoutingTemplateRecord<TemplateModule>;
473
486
 
@@ -487,6 +500,21 @@ export type RouteTable = RoutingRouteTable<
487
500
 
488
501
  export type RouteMatch = RoutingRouteMatch<RouteRecord>;
489
502
 
503
+ type ResolvedTemplate = {|
504
+ readonly above: number,
505
+ readonly module: TemplateModule,
506
+ |};
507
+
508
+ type ResolvedSlotErrorBoundary = {|
509
+ readonly above: number,
510
+ readonly module: ?ErrorModule,
511
+ |};
512
+
513
+ type SlotErrorBoundaryLoader = {|
514
+ readonly above: number,
515
+ readonly module: () => Promise<ErrorModule>,
516
+ |};
517
+
490
518
  /**
491
519
  * A match whose modules are loaded and whose loader has run or is running — or,
492
520
  * when `error` is set, the error page that stands in for it.
@@ -567,10 +595,7 @@ export type ResolvedRoute = {|
567
595
  * than walked to, and templates are accumulated on the walk down to a route
568
596
  * the URL never reached.
569
597
  */
570
- readonly templates: $ReadOnlyArray<{|
571
- readonly above: number,
572
- readonly module: TemplateModule,
573
- |}>,
598
+ readonly templates: $ReadOnlyArray<ResolvedTemplate>,
574
599
  /**
575
600
  * The slots this route renders, outermost first, already imported.
576
601
  *
@@ -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 { ...empty, page: withoutLoader(module, record.defaultFile ?? record.name) };
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<{| readonly above: number, readonly module: TemplateModule |}>> {
967
- const records = route.templates ?? [];
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
- const records = route.loading ?? [];
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(element: React.Node, resolved: ResolvedRoute, depth: number): React.Node {
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
- for (let depth = slot.layouts.length; depth > 0; depth -= 1) {
2597
- const Layout = layoutComponent(slot.layouts[depth - 1]);
2598
- element = (
2599
- <Layout {...slotsAt(slot.slots, depth)} params={slot.params}>
2600
- {element}
2601
- </Layout>
2602
- );
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.31",
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.31",
40
- "@uniflowed/server": "0.0.0-alpha.31"
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";