@uniflowed/router 0.0.0-alpha.9 → 0.2.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.
Files changed (51) hide show
  1. package/action.js +344 -0
  2. package/client.js +263 -7
  3. package/handler.js +113 -99
  4. package/http-client.js +104 -0
  5. package/index.js +49 -6
  6. package/instrumentation.js +92 -0
  7. package/internal/action-endpoint.js +646 -0
  8. package/internal/action-wire.js +608 -0
  9. package/internal/base-path.js +175 -0
  10. package/internal/boundaries.js +481 -0
  11. package/internal/boundary-data.js +88 -0
  12. package/internal/compose.js +490 -0
  13. package/internal/deployment.js +160 -0
  14. package/internal/devtools.js +131 -0
  15. package/internal/diagnostics.js +169 -0
  16. package/internal/error-view.js +193 -0
  17. package/internal/flight-browser.js +242 -0
  18. package/internal/flight-chunks.js +205 -0
  19. package/internal/flight-rows.js +135 -0
  20. package/internal/flight-ssr.js +91 -0
  21. package/internal/flight.js +192 -0
  22. package/internal/form-action.js +243 -0
  23. package/internal/head.js +219 -0
  24. package/internal/hydrate-options.js +38 -0
  25. package/internal/hydration.js +1085 -0
  26. package/internal/inspector.js +626 -0
  27. package/internal/native-links.js +67 -0
  28. package/internal/native-tree.js +89 -0
  29. package/internal/navigation-cache.js +181 -0
  30. package/internal/payload-rows.js +270 -0
  31. package/internal/payload.js +685 -0
  32. package/internal/prepare-document.js +54 -0
  33. package/internal/react-version.js +77 -0
  34. package/internal/resolve.js +1617 -0
  35. package/internal/resolved-summary.js +199 -0
  36. package/internal/routing.js +478 -0
  37. package/internal/runtime.js +1593 -1341
  38. package/internal/server-instrumentation.js +12 -0
  39. package/internal/server-route.js +58 -0
  40. package/internal/shell.js +132 -0
  41. package/internal/stream.js +766 -21
  42. package/middleware.js +274 -22
  43. package/native-navigation.js +217 -0
  44. package/native.js +416 -0
  45. package/package.json +48 -7
  46. package/routing.js +51 -0
  47. package/rsc-client.js +122 -0
  48. package/rsc-ssr.js +641 -0
  49. package/rsc.js +402 -0
  50. package/server-components.js +159 -0
  51. package/server.js +263 -106
@@ -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
+ }
@@ -0,0 +1,490 @@
1
+ // @flow
2
+ //
3
+ // Internal to `@uniflowed/router`: one walk from a resolved route to the tree
4
+ // that renders it.
5
+ //
6
+ // The layouts, the `$loading.js` fallbacks, the error boundaries, the
7
+ // templates and the parallel-route slots all have to be threaded into one stack
8
+ // at the depth each was declared at, and there must be exactly one place that
9
+ // does it. There used to be exactly one — `RouteView` — and it read the route
10
+ // from the router's context, which is a place a server composing a tree for
11
+ // React Server Components does not have. So the walk is a function of the
12
+ // resolved route and the page element, `RouteView` calls it, and so can the
13
+ // Flight renderer (ubugeeei-prod/uf#519): two callers of one composition rather
14
+ // than two compositions that agree until one of them moves.
15
+ //
16
+ // Nothing here reads a context or calls a hook. The components it places — a
17
+ // route's own modules, the error boundary, the dev-only boundary marks — are
18
+ // referenced, not rendered, so what they need is decided where they render.
19
+
20
+ import * as React from "react";
21
+ import { Suspense } from "react";
22
+
23
+ import { ROOT_ERROR_ID, ROUTE_ERROR_ID, suspenseId } from "./boundary-data.js";
24
+ import type { RouteBoundary } from "./boundary-data.js";
25
+ import { BoundaryEdge } from "./boundaries.js";
26
+ import { RouteErrorBoundary } from "./error-view.js";
27
+ import { Head } from "./head.js";
28
+ import type { RouteParams, SearchParams } from "./routing.js";
29
+ import type {
30
+ LayoutModule,
31
+ LayoutRenderProps,
32
+ LoadingModule,
33
+ PageModule,
34
+ PageRenderProps,
35
+ ResolvedRoute,
36
+ ResolvedSlot,
37
+ ResolvedTemplate,
38
+ TemplateModule,
39
+ } from "./resolve.js";
40
+ import { renderable } from "./resolve.js";
41
+
42
+ /** What a caller hands [`composeRoute`] beside the resolved route. */
43
+ export type ComposeOptions = {|
44
+ /**
45
+ * The innermost element: the page, and whatever the caller renders beside it.
46
+ *
47
+ * The caller's rather than this module's, because it is the one element that
48
+ * depends on where the route is being rendered — the browser reads the
49
+ * loader's answer out of its router, and a server has it in hand.
50
+ */
51
+ readonly page: React.Node,
52
+ /**
53
+ * The boundary marks `uf dev` draws, keyed by boundary id, or `null` for none.
54
+ *
55
+ * Read only behind [`BOUNDARY_MARKS`], so a build folds every use away.
56
+ */
57
+ readonly marks: ?Map<string, RouteBoundary>,
58
+ |};
59
+
60
+ /** The route a slot renders inside, as much of it as a slot reads. */
61
+ type SlotRoute = { +pathname: string, +searchParams: SearchParams, ... };
62
+
63
+ /**
64
+ * Whether this bundle marks the boundaries it renders.
65
+ *
66
+ * The same gate, spelled the same way, as `BOUNDARY_MARKS` in `./runtime.js`,
67
+ * which has the argument: `import.meta.hot` is defined while Vite serves and
68
+ * replaced with `undefined` in a build, so every branch it guards here is
69
+ * statically dead in a production bundle and `./boundaries.js` is dropped
70
+ * rather than shipped unused.
71
+ */
72
+ const BOUNDARY_MARKS: boolean = import.meta.hot != null;
73
+
74
+ /**
75
+ * The matched page inside its layouts, innermost last, with the document
76
+ * metadata as hoistable head elements.
77
+ *
78
+ * A function of a resolved route and the page element, and of nothing a render
79
+ * holds: no context is read here, so the same walk builds the tree the browser
80
+ * renders for a single-page application and the tree a server hands to React's
81
+ * Flight renderer. `RouteView` in `./runtime.js` is the browser's caller, and it
82
+ * decides the page element because that is the half that reads the router.
83
+ *
84
+ * # One walk down the layouts, not three
85
+ *
86
+ * The layouts, the error boundary and the `<Suspense>` boundaries all have to
87
+ * be threaded into the same stack at the depth each was declared at, so this
88
+ * is one descending loop over that depth rather than a pass per kind. `depth`
89
+ * counts the layouts still *outside* the element built so far, which is what
90
+ * `above` means on both a route's `errorBoundary` and each of its `loading`
91
+ * entries — one number, one meaning, one place it is compared.
92
+ *
93
+ * # Where the error boundaries go
94
+ *
95
+ * Two, and they are not the same thing twice. The inner one is the project's
96
+ * `$error.js`, placed at the depth the file sits at, so the layouts above
97
+ * it stay mounted and interactive while the subtree below is replaced — that
98
+ * placement *is* the feature. The outer one has no module and so renders the
99
+ * framework's page; it is what stands between a throw in a root layout, or in
100
+ * the error component itself, and an unmounted document. A single boundary
101
+ * cannot be both: put it outside and a page's throw takes the navigation down
102
+ * with it; put it inside and nothing catches the layout above.
103
+ *
104
+ * # Where the loading boundaries go
105
+ *
106
+ * Inside the layout of the segment that declared the file and outside
107
+ * everything under it, which is what makes the shell arrive first: a renderer
108
+ * streaming this tree can send every layout down to the boundary, and the
109
+ * fallback, before whatever the page is waiting for has resolved. A segment
110
+ * with no `$loading.js` contributes no boundary at all — it is not wrapped
111
+ * in a `<Suspense fallback={null}>` on the way past — so a project that
112
+ * declares none renders the tree it rendered before this existed, and a page
113
+ * that suspends without a boundary above it still fails the way React says it
114
+ * should rather than silently rendering nothing.
115
+ *
116
+ * The error boundary goes *outside* the fallback at the same depth. A throw
117
+ * while the page is resolving has to reach a boundary that is still mounted,
118
+ * and the `<Suspense>` is part of what the throw came out of.
119
+ *
120
+ * # Where the templates go
121
+ *
122
+ * Inside their own segment's layout and outside everything else at that depth
123
+ * — the error boundary, the fallback and the page — which is what makes a
124
+ * template's remount mean "this segment and what is under it" and a layout's
125
+ * persistence mean "this segment's frame". The two files are the same wrapper
126
+ * with opposite answers to one question, so they are one line apart here, and
127
+ * the whole of the difference is the `key` — see [`insideTemplates`], which is
128
+ * that line's other half.
129
+ *
130
+ * # Where the boundary marks go
131
+ *
132
+ * Inside each boundary and around nothing else, under `uf dev` only. A
133
+ * `<Suspense>` and a class boundary each render no element of their own, so the
134
+ * run of nodes one owns is indistinguishable on the page from the layout's own
135
+ * nodes beside it — the marks are what distinguish it, and this loop is the
136
+ * only place that knows which boundary is which. `./boundaries.js` has the
137
+ * mechanism and the argument; every reference to it here is inside a
138
+ * [`BOUNDARY_MARKS`] branch, so a build has none of it. See
139
+ * ubugeeei-prod/uf#520.
140
+ */
141
+ export function composeRoute(resolved: ResolvedRoute, options: ComposeOptions): React.Node {
142
+ const { module, above } = resolved.errorBoundary;
143
+ const { marks } = options;
144
+ // The innermost element, so a page that suspends — on its loader or on
145
+ // anything else — suspends below every boundary the loop below adds, which is
146
+ // what makes the layouts and the fallback the shell rather than something
147
+ // waiting behind the page.
148
+ let element: React.Node = options.page;
149
+
150
+ for (let depth = resolved.layouts.length; depth >= 0; depth -= 1) {
151
+ // Backwards over a root-first list, so the deepest segment's fallback ends
152
+ // up closest to the page. Two segments land on the same depth whenever the
153
+ // inner one declares no layout of its own, and then this order is the only
154
+ // thing that keeps them nested the way the directories are.
155
+ for (let index = resolved.loading.length - 1; index >= 0; index -= 1) {
156
+ const boundary = resolved.loading[index];
157
+ if (boundary.above !== depth) {
158
+ continue;
159
+ }
160
+ const Fallback = loadingComponent(boundary.module);
161
+ element = (
162
+ <Suspense fallback={<Fallback />}>
163
+ {BOUNDARY_MARKS ? insideBoundary(marks?.get(suspenseId(index)), element) : element}
164
+ </Suspense>
165
+ );
166
+ }
167
+ // Placed on `above` alone, and not on there being a module: a `null` one is
168
+ // the framework's own error page, and where it renders is exactly the
169
+ // question ubugeeei-prod/uf#351 asks. A project that declares no
170
+ // `$error.js` has the record the build synthesises for the router root,
171
+ // whose `above` is the root's layouts — so the framework's page appears
172
+ // inside the masthead rather than in place of the document. A table with no
173
+ // record at all answers 0, which puts this boundary outside every layout,
174
+ // where the outer one below already stood.
175
+ //
176
+ // Not around a route that already resolved to its error page: that page is
177
+ // the boundary's own component, and wrapping it in the same boundary would
178
+ // answer a throw inside it with itself.
179
+ if (depth === above && resolved.error == null) {
180
+ element = (
181
+ <RouteErrorBoundary module={module} resetKey={resolved.pathname}>
182
+ {BOUNDARY_MARKS ? insideBoundary(marks?.get(ROUTE_ERROR_ID), element) : element}
183
+ </RouteErrorBoundary>
184
+ );
185
+ }
186
+ element = insideTemplates(element, resolved, depth);
187
+ if (depth > 0) {
188
+ const Layout = layoutComponent(resolved.layouts[depth - 1]);
189
+ // The slots declared on this layout's own segment, beside `children`.
190
+ // Spread rather than passed as one `slots` object, because a slot is a
191
+ // prop a layout declares by name — `component Dashboard(children, team)`
192
+ // — and a bag would make every layout destructure a map to find out
193
+ // whether the router had anything for it.
194
+ // The spread first and `params` after it, so that a slot named after a
195
+ // prop the layout already has loses rather than wins. `@params` and
196
+ // `@children` are refused by the scan, and this is the second line of
197
+ // that defence for a table written by hand: losing a slot is a hole in
198
+ // the page, and overwriting `params` is every route in the segment
199
+ // rendering against the wrong parameters.
200
+ element = (
201
+ <Layout {...slotsAt(resolved.slots, depth, resolved)} params={resolved.params}>
202
+ {element}
203
+ </Layout>
204
+ );
205
+ }
206
+ }
207
+ if (needsRootStreamFrame(resolved)) {
208
+ element = <RootStreamFrame>{element}</RootStreamFrame>;
209
+ }
210
+ return (
211
+ <>
212
+ <Head metadata={resolved.metadata} />
213
+ <RouteErrorBoundary module={null} resetKey={resolved.pathname}>
214
+ {BOUNDARY_MARKS ? insideBoundary(marks?.get(ROOT_ERROR_ID), element) : element}
215
+ </RouteErrorBoundary>
216
+ </>
217
+ );
218
+ }
219
+
220
+ /**
221
+ * `children`, between the two marks of `boundary`.
222
+ *
223
+ * `children` unchanged when there is no boundary to mark, so a caller never has
224
+ * to ask twice. The marks are the first and last children of a fragment rather
225
+ * than a wrapper's, so the nodes between them are siblings of them, and every
226
+ * position in the fragment is fixed — an edge going from `null` to a hidden
227
+ * mark after mount is an insertion beside `children` and not around it,
228
+ * which is why it costs no remount.
229
+ *
230
+ * Here rather than beside the marks in `./boundaries.js`, because this is a
231
+ * factory and that is a client module: a server composing a tree for React
232
+ * Server Components calls this, and the edges it places stay references to
233
+ * the module that renders them.
234
+ */
235
+ export function insideBoundary(boundary: ?RouteBoundary, children: React.Node): React.Node {
236
+ if (boundary == null) {
237
+ return children;
238
+ }
239
+ return (
240
+ <>
241
+ <BoundaryEdge boundary={boundary} edge="open" />
242
+ {children}
243
+ <BoundaryEdge boundary={boundary} edge="close" />
244
+ </>
245
+ );
246
+ }
247
+
248
+ /**
249
+ * Whether the outermost route fallback needs one host element above it.
250
+ *
251
+ * React can flush a shell whose suspended boundary is inside any host element,
252
+ * but not one whose boundary is a direct child of the render root. A route with
253
+ * no layout and a root `$loading.js` is exactly that second tree: every
254
+ * framework component above it renders no element, so the fallback waits for
255
+ * the page it was meant to stand in for. A root layout is already the element
256
+ * that can carry it, and deeper fallbacks sit inside a layout by construction.
257
+ */
258
+ function needsRootStreamFrame(resolved: ResolvedRoute): boolean {
259
+ return resolved.layouts.length === 0 && resolved.loading.some((boundary) => boundary.above === 0);
260
+ }
261
+
262
+ component RootStreamFrame(children: React.Node) {
263
+ return (
264
+ <div data-uf-stream-root="" style={{ display: "contents" }}>
265
+ {children}
266
+ </div>
267
+ );
268
+ }
269
+
270
+ /**
271
+ * The component a page module renders: its default export, or the named
272
+ * `Page` that `uf create` scaffolds. An MDX page always has a default export.
273
+ */
274
+ export function pageComponent(module: PageModule): React.ComponentType<PageRenderProps> {
275
+ const component = module.default ?? module.Page;
276
+ if (component == null) {
277
+ throw new Error(
278
+ "@uniflowed/router: a page module must export a component as `default` or `Page`",
279
+ );
280
+ }
281
+ return renderable(component);
282
+ }
283
+
284
+ /**
285
+ * The component a loading module renders: `default`, or the named `Loading`.
286
+ *
287
+ * No props, unlike a page or a layout. A fallback is what the router shows
288
+ * when it does not have the route's answer yet, so there is nothing it could
289
+ * be handed that would be true — not `data`, which is the thing being waited
290
+ * for, and not `children`, because it renders instead of them.
291
+ */
292
+ export function loadingComponent(module: LoadingModule): React.ComponentType<{||}> {
293
+ const component = module.default ?? module.Loading;
294
+ if (component == null) {
295
+ throw new Error(
296
+ "@uniflowed/router: a loading module must export a component as `default` or `Loading`",
297
+ );
298
+ }
299
+ return renderable(component);
300
+ }
301
+
302
+ /**
303
+ * `element`, wrapped in every template declared at `depth`.
304
+ *
305
+ * Outside the boundaries at that depth and inside the layout below it, and
306
+ * backwards over a root-first list for the reason the fallbacks are: two
307
+ * segments share a depth whenever the inner one declares no layout, and this
308
+ * order is what keeps them nested the way the directories are.
309
+ *
310
+ * A function beside `RouteView` rather than a third loop inside it, and that
311
+ * is not only for reading: a third nested loop assigning to `element` is what
312
+ * the React Compiler's aliasing inference gave up on, and a component it
313
+ * cannot compile is a component it does not memoise.
314
+ */
315
+ export function insideTemplates(
316
+ element: React.Node,
317
+ resolved: {
318
+ readonly pathname: string,
319
+ readonly params: RouteParams,
320
+ readonly templates: $ReadOnlyArray<ResolvedTemplate>,
321
+ ...
322
+ },
323
+ depth: number,
324
+ ): React.Node {
325
+ let out = element;
326
+ for (let index = resolved.templates.length - 1; index >= 0; index -= 1) {
327
+ const entry = resolved.templates[index];
328
+ if (entry.above !== depth) {
329
+ continue;
330
+ }
331
+ const Template = templateComponent(entry.module);
332
+ // Keyed on the pathname, which is the whole difference between this file
333
+ // and `$layout.js`: React throws the subtree away and builds it again
334
+ // whenever the key changes, and a navigation that changes only the query
335
+ // string leaves it alone.
336
+ out = (
337
+ <Template key={resolved.pathname} params={resolved.params}>
338
+ {out}
339
+ </Template>
340
+ );
341
+ }
342
+ return out;
343
+ }
344
+
345
+ /**
346
+ * The slots declared at `depth`, as the props the layout there receives.
347
+ *
348
+ * One object per layout rather than one lookup per slot, so the common case —
349
+ * a project with no slots at all — allocates nothing and spreads nothing.
350
+ *
351
+ * A slot the URL addressed and that has no `$default.js` is `null` rather
352
+ * than absent: a layout that declares `team` receives `team` on every route,
353
+ * so `{team ?? <Empty />}` is a thing a project can write and rely on.
354
+ */
355
+ export function slotsAt(
356
+ slots: $ReadOnlyArray<ResolvedSlot>,
357
+ depth: number,
358
+ route: SlotRoute,
359
+ ): { readonly [string]: React.Node } {
360
+ if (slots.length === 0) {
361
+ return EMPTY_SLOTS;
362
+ }
363
+ const props: { [string]: React.Node } = {};
364
+ for (const slot of slots) {
365
+ if (slot.above === depth) {
366
+ // `null` rather than an element that renders nothing, and the difference
367
+ // is the whole of what the prop is for: `{team ?? <Empty />}` has to be
368
+ // able to tell "this slot has nothing in it" from "this slot rendered
369
+ // something empty", and an element is never `null`.
370
+ props[slot.name] =
371
+ slot.page == null ? null : (
372
+ <SlotView slot={slot} pathname={route.pathname} searchParams={route.searchParams} />
373
+ );
374
+ }
375
+ }
376
+ return props;
377
+ }
378
+
379
+ /** One object for every layout on a project that declares no slot. */
380
+ const EMPTY_SLOTS: { readonly [string]: React.Node } = Object.freeze({});
381
+
382
+ /**
383
+ * One slot's tree: its page, inside the layouts declared under the slot, with
384
+ * the slots those layouts declare in turn.
385
+ *
386
+ * The same composition [`RouteView`] does and deliberately not the same
387
+ * function. A route's tree carries the things a slot does not have — the error
388
+ * boundary, the `<Suspense>` fallbacks, the templates, the head — and folding
389
+ * a second, simpler case into that loop would be four `if`s asking which of the
390
+ * two this is. What the two share is the *order*, page innermost and layouts
391
+ * backwards over a root-first list, and that is short enough to be right twice.
392
+ *
393
+ * A slot with no page is never rendered through this component at all —
394
+ * [`slotsAt`] hands the layout `null` instead, so the layout can tell an empty
395
+ * slot from one that rendered something empty. The guard below is what makes
396
+ * that a fact about one place rather than a convention two places share.
397
+ */
398
+ component SlotView(slot: ResolvedSlot, pathname: string, searchParams: SearchParams) {
399
+ // The pathname and the search string are the route's, and they are handed
400
+ // down rather than read from the router: a slot matches the path and the
401
+ // query belongs to the URL rather than to either match, and a component that
402
+ // reads no context is one a server can render for React Server Components.
403
+ const page = slot.page;
404
+ if (page == null) {
405
+ return null;
406
+ }
407
+ // Except in an interception, whose URL is not the one the route on screen was
408
+ // resolved for. The page it renders reads the intercepted URL's query, and
409
+ // its templates and error boundary are keyed on the intercepted pathname — so
410
+ // a second photo opened in the modal remounts what the first one mounted, the
411
+ // way a navigation between two pages does.
412
+ const at = slot.intercepted?.pathname ?? pathname;
413
+ const query = slot.intercepted?.searchParams ?? searchParams;
414
+ const Page = pageComponent(page);
415
+ // The slot module's own export, looked up by slot: `pageComponent` hands back
416
+ // its `default` or `Page` as it is, so this is the same component on every
417
+ // render of the same slot. The React Compiler cannot see through the lookup
418
+ // and reports a component created during render.
419
+ // uf-lint-disable-next-line react-compiler/static-components
420
+ let element: React.Node = <Page params={slot.params} searchParams={query} data={undefined} />;
421
+ const templateContext = {
422
+ pathname: at,
423
+ params: slot.params,
424
+ templates: slot.templates,
425
+ };
426
+ for (let depth = slot.layouts.length; depth >= 0; depth -= 1) {
427
+ for (let index = slot.loading.length - 1; index >= 0; index -= 1) {
428
+ const boundary = slot.loading[index];
429
+ if (boundary.above !== depth) {
430
+ continue;
431
+ }
432
+ const Fallback = loadingComponent(boundary.module);
433
+ // The same lookup as `Page` above: `loadingComponent` hands back the
434
+ // boundary module's own export as it is, so this is the same component
435
+ // on every render of that boundary.
436
+ // uf-lint-disable-next-line react-compiler/static-components
437
+ element = <Suspense fallback={<Fallback />}>{element}</Suspense>;
438
+ }
439
+ const errorBoundary = slot.errorBoundary;
440
+ if (errorBoundary != null && errorBoundary.above === depth) {
441
+ element = (
442
+ <RouteErrorBoundary module={errorBoundary.module} resetKey={`${at}:${slot.name}`}>
443
+ {element}
444
+ </RouteErrorBoundary>
445
+ );
446
+ }
447
+ element = insideTemplates(element, templateContext, depth);
448
+ if (depth > 0) {
449
+ const Layout = layoutComponent(slot.layouts[depth - 1]);
450
+ element = (
451
+ // The layout module's own export, looked up by depth, for the same
452
+ // reason as `Page` above.
453
+ // uf-lint-disable-next-line react-compiler/static-components
454
+ <Layout {...slotsAt(slot.slots, depth, { pathname, searchParams })} params={slot.params}>
455
+ {element}
456
+ </Layout>
457
+ );
458
+ }
459
+ }
460
+ return element;
461
+ }
462
+
463
+ /**
464
+ * The component a template module renders: `default`, or the named `Template`.
465
+ *
466
+ * The same props a layout receives, because it is a layout in every way but
467
+ * one: it wraps `children`, it may read the route's parameters, and the only
468
+ * difference is that `RouteView` gives the element a `key` so React builds it
469
+ * again on every navigation.
470
+ */
471
+ function templateComponent(module: TemplateModule): React.ComponentType<LayoutRenderProps> {
472
+ const component = module.default ?? module.Template;
473
+ if (component == null) {
474
+ throw new Error(
475
+ "@uniflowed/router: a template module must export a component as `default` or `Template`",
476
+ );
477
+ }
478
+ return renderable(component);
479
+ }
480
+
481
+ /** The component a layout module renders: `default`, or the named `Layout`. */
482
+ function layoutComponent(module: LayoutModule): React.ComponentType<LayoutRenderProps> {
483
+ const component = module.default ?? module.Layout;
484
+ if (component == null) {
485
+ throw new Error(
486
+ "@uniflowed/router: a layout module must export a component as `default` or `Layout`",
487
+ );
488
+ }
489
+ return renderable(component);
490
+ }