@uniflowed/router 0.0.0-alpha.18 → 0.0.0-alpha.21
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/client.js +187 -5
- package/handler.js +15 -6
- package/index.js +29 -6
- package/internal/action-wire.js +6 -3
- package/internal/boundaries.js +557 -0
- package/internal/devtools.js +2 -2
- package/internal/diagnostics.js +1 -1
- package/internal/hydration.js +232 -39
- package/internal/inspector.js +615 -0
- package/internal/payload-rows.js +258 -0
- package/internal/payload.js +669 -0
- package/internal/runtime.js +754 -50
- package/internal/stream.js +87 -17
- package/middleware.js +3 -3
- package/package.json +5 -4
- package/server.js +72 -17
package/internal/runtime.js
CHANGED
|
@@ -36,10 +36,39 @@ import { flushSync } from "react-dom";
|
|
|
36
36
|
import { RenderProvider } from "@uniflowed/hooks/render";
|
|
37
37
|
|
|
38
38
|
// The id of the script the loader data is embedded in. It moved out of the
|
|
39
|
-
// head and into the tree with ubugeeei-prod/uf#373 — see [`
|
|
39
|
+
// head and into the tree with ubugeeei-prod/uf#373 — see [`payloadElements`]
|
|
40
40
|
// — so the module that renders it is this one rather than `../server.js`.
|
|
41
41
|
import { DATA_ID } from "./document.js";
|
|
42
42
|
|
|
43
|
+
// The payload the loader's answer is written as, and the rows it defers. Row 0
|
|
44
|
+
// is the element `DATA_ID` names and is byte-identical to what this file wrote
|
|
45
|
+
// inline before the payload existed whenever nothing is deferred; a promise
|
|
46
|
+
// anywhere in the data turns into a reference and a row of its own. See
|
|
47
|
+
// `./payload.js` for the format and ubugeeei-prod/uf#519 for the half of it
|
|
48
|
+
// that is still an element payload rather than a data one.
|
|
49
|
+
import {
|
|
50
|
+
type PayloadRowMessage,
|
|
51
|
+
PayloadRowError,
|
|
52
|
+
encodePayload,
|
|
53
|
+
encodeRowValue,
|
|
54
|
+
payloadJson,
|
|
55
|
+
} from "./payload.js";
|
|
56
|
+
|
|
57
|
+
// The development-only half of [`RouteView`]: the marks that say which DOM
|
|
58
|
+
// subtree each boundary owns, and the report that reads them. Every reference
|
|
59
|
+
// to it is inside a `BOUNDARY_MARKS` branch, which is why a static import is
|
|
60
|
+
// safe here where `../client.js` needs a dynamic one — a component cannot be
|
|
61
|
+
// awaited in the middle of a render, and `false` folds the references away
|
|
62
|
+
// before the bundler is asked to keep the module. See [`BOUNDARY_MARKS`].
|
|
63
|
+
import {
|
|
64
|
+
BoundaryReporter,
|
|
65
|
+
ROOT_ERROR_ID,
|
|
66
|
+
ROUTE_ERROR_ID,
|
|
67
|
+
insideBoundary,
|
|
68
|
+
routeBoundaries,
|
|
69
|
+
suspenseId,
|
|
70
|
+
} from "./boundaries.js";
|
|
71
|
+
|
|
43
72
|
/** One parameter a route path captures. */
|
|
44
73
|
export type RouteParamSpec = {| readonly name: string, readonly catchAll: boolean |};
|
|
45
74
|
|
|
@@ -90,11 +119,24 @@ type PageRenderProps = {|
|
|
|
90
119
|
readonly data: mixed,
|
|
91
120
|
|};
|
|
92
121
|
|
|
93
|
-
/**
|
|
94
|
-
|
|
122
|
+
/**
|
|
123
|
+
* The props `RouteView` gives each layout, outermost first.
|
|
124
|
+
*
|
|
125
|
+
* Inexact, and that is the parallel routes reaching the type: a layout on a
|
|
126
|
+
* segment that declares `@team` is handed a `team` prop beside `children`, and
|
|
127
|
+
* the names are the project's rather than this file's. Every extra prop is a
|
|
128
|
+
* `React.Node` — a rendered slot, or `null` when the URL addressed neither the
|
|
129
|
+
* slot's routes nor a `$default.js`.
|
|
130
|
+
*
|
|
131
|
+
* The exactness is not lost so much as moved: what a layout may be *given* is
|
|
132
|
+
* open, and what it *declares* is still its own exact props type, which is
|
|
133
|
+
* where a typo in a slot name shows up.
|
|
134
|
+
*/
|
|
135
|
+
type LayoutRenderProps = {
|
|
95
136
|
readonly params: RouteParams,
|
|
96
137
|
readonly children: React.Node,
|
|
97
|
-
|
|
138
|
+
...
|
|
139
|
+
};
|
|
98
140
|
|
|
99
141
|
/** What a page module may export. The component is `default` or `Page`. */
|
|
100
142
|
export type PageModule = {
|
|
@@ -405,24 +447,99 @@ export type RouteRecord = {|
|
|
|
405
447
|
/**
|
|
406
448
|
* The `<Suspense>` boundaries this route renders inside, root first.
|
|
407
449
|
*
|
|
408
|
-
* Optional because a table written before
|
|
450
|
+
* Optional because a table written before `$loading.js` existed — a
|
|
409
451
|
* hand-written one in a test, a server bundle built by an older `uf` —
|
|
410
452
|
* is still a table this router can render, and a route with no boundary is
|
|
411
453
|
* exactly what it had before.
|
|
412
454
|
*/
|
|
413
455
|
readonly loading?: $ReadOnlyArray<LoadingRecord>,
|
|
414
456
|
/**
|
|
415
|
-
* The
|
|
457
|
+
* The `$template.js` wrappers this route renders inside, root first.
|
|
416
458
|
*
|
|
417
459
|
* Optional for the reason `loading` is: a table written before templates
|
|
418
460
|
* existed is still a table this router can render, and a route with no
|
|
419
461
|
* template renders exactly the tree it did before.
|
|
420
462
|
*/
|
|
421
463
|
readonly templates?: $ReadOnlyArray<TemplateRecord>,
|
|
464
|
+
/**
|
|
465
|
+
* The parallel-route slots in scope on this route, outermost first.
|
|
466
|
+
*
|
|
467
|
+
* Optional for the reason `templates` is. A route with no slot renders
|
|
468
|
+
* exactly the tree it did before slots existed, which is most routes.
|
|
469
|
+
*/
|
|
470
|
+
readonly slots?: $ReadOnlyArray<SlotRecord>,
|
|
422
471
|
|};
|
|
423
472
|
|
|
424
473
|
/**
|
|
425
|
-
* One
|
|
474
|
+
* One parallel-route slot, as the route table carries it.
|
|
475
|
+
*
|
|
476
|
+
* A slot is a second thing a layout renders. `app/dashboard/@team/` gives
|
|
477
|
+
* `app/dashboard/$layout.js` a `team` prop beside `children`, and the slot's
|
|
478
|
+
* pages are matched against the same URL the page is: `/dashboard/members`
|
|
479
|
+
* renders `app/dashboard/members/$page.js` as `children` and
|
|
480
|
+
* `app/dashboard/@team/members/$page.js` as `team`, at once, each inside its
|
|
481
|
+
* own layouts.
|
|
482
|
+
*
|
|
483
|
+
* A slot never adds a URL — the directory contributes no path segment — so
|
|
484
|
+
* `routes` here is a second table matched against paths the main table already
|
|
485
|
+
* defines. That is uf's answer to the question Next.js answers with a
|
|
486
|
+
* `default.js` for `children`: there is no such thing, because `children` is
|
|
487
|
+
* the page the URL matched and a URL that matches no page is a 404.
|
|
488
|
+
*
|
|
489
|
+
* `above` is how many of the route's `layouts` are outside the slot, so
|
|
490
|
+
* `layouts[above - 1]` is the one that receives it — the same number, spelled
|
|
491
|
+
* the same way, as [`TemplateRecord`]'s and [`LoadingRecord`]'s.
|
|
492
|
+
*/
|
|
493
|
+
export type SlotRecord = {|
|
|
494
|
+
readonly name: string,
|
|
495
|
+
readonly above: number,
|
|
496
|
+
/**
|
|
497
|
+
* `$default.js`: what this slot renders when the URL matches none of its
|
|
498
|
+
* routes.
|
|
499
|
+
*
|
|
500
|
+
* `null` for a slot that declares none, and then the slot renders nothing at
|
|
501
|
+
* all. That is what an unaddressed slot does on a soft navigation in Next.js
|
|
502
|
+
* too, and it is the honest answer for a slot that only some URLs have
|
|
503
|
+
* something to put in — a modal, a detail pane.
|
|
504
|
+
*/
|
|
505
|
+
readonly defaultPage: ?() => Promise<PageModule>,
|
|
506
|
+
/** The default's source path, for diagnostics; absent when there is none. */
|
|
507
|
+
readonly defaultFile?: string,
|
|
508
|
+
/** Whether that default is MDX content. */
|
|
509
|
+
readonly defaultMdx?: boolean,
|
|
510
|
+
readonly routes: $ReadOnlyArray<SlotRouteRecord>,
|
|
511
|
+
|};
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* One page inside a slot.
|
|
515
|
+
*
|
|
516
|
+
* A [`RouteRecord`] without the parts a slot does not have. No `loading` and no
|
|
517
|
+
* `templates`: those belong to the segment, and they already wrap the layout
|
|
518
|
+
* the slot renders into. Per-slot boundaries are the part of parallel routes uf
|
|
519
|
+
* has not built, and `@uniflowed/vite`'s scan refuses the files rather than
|
|
520
|
+
* leaving them unopened — see ubugeeei-prod/uf#267.
|
|
521
|
+
*
|
|
522
|
+
* `page` is required, unlike a `RouteRecord`'s: a slot route that ships no
|
|
523
|
+
* client page has no URL of its own to hand the browser, so there would be
|
|
524
|
+
* nothing to do with the entry. The client table drops the whole route when its
|
|
525
|
+
* page is dropped, slots and all.
|
|
526
|
+
*
|
|
527
|
+
* `layouts` are the layouts *inside* the slot, and `slots` are the slots a
|
|
528
|
+
* layout inside this one declares — the recursion is the feature rather than a
|
|
529
|
+
* special case.
|
|
530
|
+
*/
|
|
531
|
+
export type SlotRouteRecord = {|
|
|
532
|
+
readonly path: string,
|
|
533
|
+
readonly params: $ReadOnlyArray<RouteParamSpec>,
|
|
534
|
+
readonly mdx: boolean,
|
|
535
|
+
readonly file: string,
|
|
536
|
+
readonly page: () => Promise<PageModule>,
|
|
537
|
+
readonly layouts: $ReadOnlyArray<() => Promise<LayoutModule>>,
|
|
538
|
+
readonly slots: $ReadOnlyArray<SlotRecord>,
|
|
539
|
+
|};
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* One `$template.js`, as the route table carries it.
|
|
426
543
|
*
|
|
427
544
|
* The same shape as [`LoadingRecord`] and the same `above`, because it answers
|
|
428
545
|
* the same question — where in the stack of layouts this thing sits — and
|
|
@@ -434,7 +551,7 @@ export type TemplateRecord = {|
|
|
|
434
551
|
|};
|
|
435
552
|
|
|
436
553
|
/**
|
|
437
|
-
* One
|
|
554
|
+
* One `$loading.js`, as the route table carries it.
|
|
438
555
|
*
|
|
439
556
|
* `above` is how many of the route's `layouts` are outside the boundary, which
|
|
440
557
|
* is the same number `ResolvedRoute["errorBoundary"].above` means and is
|
|
@@ -450,7 +567,7 @@ export type LoadingRecord = {|
|
|
|
450
567
|
* One not-found boundary: the page for a path under `path` that matched
|
|
451
568
|
* nothing.
|
|
452
569
|
*
|
|
453
|
-
*
|
|
570
|
+
* `$not-found.js` is a segment file, so `path` is the route path of the
|
|
454
571
|
* directory that declares it and `layouts` are the layouts in scope *there* —
|
|
455
572
|
* which is what the boundary renders inside. A project with one at the router
|
|
456
573
|
* root has one of these; a project whose manual answers its own 404 has two.
|
|
@@ -461,7 +578,7 @@ export type NotFoundBoundary = {|
|
|
|
461
578
|
readonly file: string,
|
|
462
579
|
/**
|
|
463
580
|
* The page this boundary renders — `null` for the one the build synthesises
|
|
464
|
-
* at the router root when a project declares no
|
|
581
|
+
* at the router root when a project declares no `$not-found.js` there.
|
|
465
582
|
*
|
|
466
583
|
* A project that had declared none used to get `layouts: []` along with the
|
|
467
584
|
* framework's page: not the nearest-ancestor rule failing, but the fallback
|
|
@@ -516,8 +633,8 @@ export type RouteMatch = {|
|
|
|
516
633
|
* `unauthorized()` are not different *kinds* of file to write; they are
|
|
517
634
|
* different sentences an error page says, and `match` over this is where a
|
|
518
635
|
* page says all three and the checker confirms it covered them. Deciding it
|
|
519
|
-
* the other way —
|
|
520
|
-
*
|
|
636
|
+
* the other way — `$forbidden.js` and `$unauthorized.js` beside
|
|
637
|
+
* `$error.js`, which is what Next.js does — is three files per segment to
|
|
521
638
|
* express one thing, and nothing would check that any of them handled the
|
|
522
639
|
* case it was named for.
|
|
523
640
|
*
|
|
@@ -563,7 +680,7 @@ export type ResolvedRoute = {|
|
|
|
563
680
|
* to a question the router asked, so it says so.
|
|
564
681
|
*
|
|
565
682
|
* Set only by a streaming render of a route that declares a
|
|
566
|
-
*
|
|
683
|
+
* `$loading.js` and generates no metadata from its data — the two
|
|
567
684
|
* conditions under which deferring buys anything and costs nothing that was
|
|
568
685
|
* not already spent. [`resolveRoute`] is where that is decided and argued.
|
|
569
686
|
*/
|
|
@@ -590,7 +707,7 @@ export type ResolvedRoute = {|
|
|
|
590
707
|
* The boundary that would catch a throw while rendering this route.
|
|
591
708
|
*
|
|
592
709
|
* Always present, because every route has an answer for a throw: `module`
|
|
593
|
-
* is `null` when the project declares no
|
|
710
|
+
* is `null` when the project declares no `$error.js` above the path, and
|
|
594
711
|
* the framework's own error page renders instead. `above` is how many of
|
|
595
712
|
* `layouts` are outside the boundary — the ones that stay mounted, which is
|
|
596
713
|
* what "the rest of the document is still interactive" means.
|
|
@@ -605,14 +722,14 @@ export type ResolvedRoute = {|
|
|
|
605
722
|
* Imported rather than lazy: React decides to render a fallback
|
|
606
723
|
* synchronously, during the render that suspended, so a module that is still
|
|
607
724
|
* being fetched is a module that is not there at the only moment it is
|
|
608
|
-
* wanted. Empty for a route with no
|
|
725
|
+
* wanted. Empty for a route with no `$loading.js` above it, which is the
|
|
609
726
|
* ordinary case and renders exactly the tree it did before.
|
|
610
727
|
*/
|
|
611
728
|
readonly loading: $ReadOnlyArray<{| readonly above: number, readonly module: LoadingModule |}>,
|
|
612
729
|
/**
|
|
613
730
|
* The templates around this route, root first, already imported.
|
|
614
731
|
*
|
|
615
|
-
* Empty for a route with no
|
|
732
|
+
* Empty for a route with no `$template.js` above it, which is the
|
|
616
733
|
* ordinary case and renders exactly the tree it did before templates
|
|
617
734
|
* existed. Empty too on a resolution that *is* a boundary — a not-found or
|
|
618
735
|
* an error page — for the reason its `loading` is: those are matched rather
|
|
@@ -623,6 +740,36 @@ export type ResolvedRoute = {|
|
|
|
623
740
|
readonly above: number,
|
|
624
741
|
readonly module: TemplateModule,
|
|
625
742
|
|}>,
|
|
743
|
+
/**
|
|
744
|
+
* The slots this route renders, outermost first, already imported.
|
|
745
|
+
*
|
|
746
|
+
* Empty for a route with no slot above it, and empty on a resolution that
|
|
747
|
+
* *is* a boundary — a not-found or an error page — for the reason its
|
|
748
|
+
* `templates` is: a boundary is matched rather than walked to, and a slot
|
|
749
|
+
* belongs to the segment the walk went through.
|
|
750
|
+
*/
|
|
751
|
+
readonly slots: $ReadOnlyArray<ResolvedSlot>,
|
|
752
|
+
|};
|
|
753
|
+
|
|
754
|
+
/**
|
|
755
|
+
* One slot, matched against the URL and imported.
|
|
756
|
+
*
|
|
757
|
+
* `page` is `null` for a slot the URL addressed and that declares no
|
|
758
|
+
* `$default.js`, and the layout receives `null` rather than nothing at all:
|
|
759
|
+
* a layout that declares a slot always gets that prop, so a project can write
|
|
760
|
+
* `{team ?? <Empty />}` and mean it.
|
|
761
|
+
*
|
|
762
|
+
* `params` are the slot's own. A slot matches the same URL by its own patterns,
|
|
763
|
+
* so `@team/[member]` captures `member` while the page beside it captures
|
|
764
|
+
* nothing — which is the point of matching twice rather than sharing one match.
|
|
765
|
+
*/
|
|
766
|
+
export type ResolvedSlot = {|
|
|
767
|
+
readonly name: string,
|
|
768
|
+
readonly above: number,
|
|
769
|
+
readonly page: ?PageModule,
|
|
770
|
+
readonly params: RouteParams,
|
|
771
|
+
readonly layouts: $ReadOnlyArray<LayoutModule>,
|
|
772
|
+
readonly slots: $ReadOnlyArray<ResolvedSlot>,
|
|
626
773
|
|};
|
|
627
774
|
|
|
628
775
|
/** Thrown by `notFound()`; the renderer answers with the not-found page. */
|
|
@@ -818,8 +965,25 @@ export function hasClientPage(route: RouteRecord): boolean {
|
|
|
818
965
|
* Match a pathname against the table, preferring the most specific route.
|
|
819
966
|
*/
|
|
820
967
|
export function matchRoute(routes: $ReadOnlyArray<RouteRecord>, pathname: string): ?RouteMatch {
|
|
968
|
+
return matchIn(routes, pathname);
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
/**
|
|
972
|
+
* The same match, over anything that has a route path.
|
|
973
|
+
*
|
|
974
|
+
* A slot is a second table matched against the same URL — see [`SlotRecord`] —
|
|
975
|
+
* and it has to be matched by *this* function rather than by one of its own:
|
|
976
|
+
* two matchers would be two answers to "which of these paths does this URL
|
|
977
|
+
* name", and the one that disagreed would show up as a slot holding somebody
|
|
978
|
+
* else's page. The generic is only about the record type; the ranking, the
|
|
979
|
+
* parameters and the tie-break are the route table's.
|
|
980
|
+
*/
|
|
981
|
+
function matchIn<TRecord: { +path: string, ... }>(
|
|
982
|
+
routes: $ReadOnlyArray<TRecord>,
|
|
983
|
+
pathname: string,
|
|
984
|
+
): ?{| readonly route: TRecord, readonly params: RouteParams |} {
|
|
821
985
|
const parts = pathname.split("/").filter((part) => part !== "");
|
|
822
|
-
let best: ?
|
|
986
|
+
let best: ?{| readonly route: TRecord, readonly params: RouteParams |} = null;
|
|
823
987
|
let bestScore = -1;
|
|
824
988
|
for (const route of routes) {
|
|
825
989
|
const segments = compile(route.path);
|
|
@@ -862,7 +1026,7 @@ function covers(segments: $ReadOnlyArray<Segment>, parts: $ReadOnlyArray<string>
|
|
|
862
1026
|
/**
|
|
863
1027
|
* The nearest boundary above `pathname`, or `null` when none covers it.
|
|
864
1028
|
*
|
|
865
|
-
* The one rule both
|
|
1029
|
+
* The one rule both `$not-found.js` and `$error.js` are resolved by, and
|
|
866
1030
|
* the same one layouts already follow: nearest means the longest path that
|
|
867
1031
|
* covers the URL. It is decided here rather than by the table's order — the
|
|
868
1032
|
* table is sorted by path so the generated module is stable, and a resolver
|
|
@@ -1059,6 +1223,11 @@ async function resolveRoute(
|
|
|
1059
1223
|
// depends on nothing the loader produces.
|
|
1060
1224
|
const loading = resolveLoading(matched.route, matched.route.layouts.length);
|
|
1061
1225
|
const templates = resolveTemplates(matched.route, matched.route.layouts.length);
|
|
1226
|
+
// Started alongside and awaited at the end, for the reason the boundaries
|
|
1227
|
+
// are: a slot is matched against the URL and depends on nothing the loader
|
|
1228
|
+
// produces, so the second match and its imports overlap the first page's
|
|
1229
|
+
// loader rather than following it.
|
|
1230
|
+
const slots = resolveSlots(matched.route.slots ?? [], pathname, matched.route.layouts.length);
|
|
1062
1231
|
|
|
1063
1232
|
// The loader, run here and awaited below — or not awaited at all.
|
|
1064
1233
|
//
|
|
@@ -1066,7 +1235,7 @@ async function resolveRoute(
|
|
|
1066
1235
|
// on its loader could not, because this function awaited the loader before it
|
|
1067
1236
|
// returned and by the time React saw the tree the data was already in hand.
|
|
1068
1237
|
// The fallback beside such a page showed for zero milliseconds, which made
|
|
1069
|
-
//
|
|
1238
|
+
// `$loading.js` useful for the one case a page usually is not slow for.
|
|
1070
1239
|
//
|
|
1071
1240
|
// Two things stand in the way of simply not awaiting, and both are about the
|
|
1072
1241
|
// document rather than about the route. Metadata goes in the head and the
|
|
@@ -1120,9 +1289,141 @@ async function resolveRoute(
|
|
|
1120
1289
|
errorBoundary: await boundary,
|
|
1121
1290
|
loading: await loading,
|
|
1122
1291
|
templates: await templates,
|
|
1292
|
+
slots: await slots,
|
|
1293
|
+
};
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
/**
|
|
1297
|
+
* The route's slots, matched against the URL and imported.
|
|
1298
|
+
*
|
|
1299
|
+
* The second matching pass parallel routes are, and it is a pass rather than a
|
|
1300
|
+
* branch of the first: a slot has its own patterns over the same path, so
|
|
1301
|
+
* `/dashboard/members` can be `[member]` to one slot, a static segment to
|
|
1302
|
+
* another and nothing at all to a third, at once.
|
|
1303
|
+
*
|
|
1304
|
+
* A slot that will not load renders nothing rather than taking the page with
|
|
1305
|
+
* it, which is the judgement `resolveTemplates` and `resolveLoading` already
|
|
1306
|
+
* make: a slot is a second thing beside the page, and a broken second thing
|
|
1307
|
+
* must not become a broken route. The entry stays in the list with `page:
|
|
1308
|
+
* null`, so the layout still receives the prop it declares.
|
|
1309
|
+
*/
|
|
1310
|
+
async function resolveSlots(
|
|
1311
|
+
records: $ReadOnlyArray<SlotRecord>,
|
|
1312
|
+
pathname: string,
|
|
1313
|
+
layoutCount: number,
|
|
1314
|
+
): Promise<$ReadOnlyArray<ResolvedSlot>> {
|
|
1315
|
+
if (records.length === 0) {
|
|
1316
|
+
return [];
|
|
1317
|
+
}
|
|
1318
|
+
return Promise.all(records.map((record) => resolveSlot(record, pathname, layoutCount)));
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
async function resolveSlot(
|
|
1322
|
+
record: SlotRecord,
|
|
1323
|
+
pathname: string,
|
|
1324
|
+
layoutCount: number,
|
|
1325
|
+
): Promise<ResolvedSlot> {
|
|
1326
|
+
// Clamped exactly as a template's `above` is, and for the same reason: a
|
|
1327
|
+
// hand-written table, or a `(group)` between the layout and the route, can
|
|
1328
|
+
// leave a route with fewer layouts than the slot was declared above.
|
|
1329
|
+
const above = Math.min(record.above, layoutCount);
|
|
1330
|
+
const empty: ResolvedSlot = {
|
|
1331
|
+
name: record.name,
|
|
1332
|
+
above,
|
|
1333
|
+
page: null,
|
|
1334
|
+
params: {},
|
|
1335
|
+
layouts: [],
|
|
1336
|
+
slots: [],
|
|
1337
|
+
};
|
|
1338
|
+
|
|
1339
|
+
const matched = matchIn(record.routes, pathname);
|
|
1340
|
+
if (matched == null) {
|
|
1341
|
+
// The URL says nothing about this slot. `$default.js` is what it says
|
|
1342
|
+
// instead, and a slot that declares none renders nothing at all.
|
|
1343
|
+
const load = record.defaultPage;
|
|
1344
|
+
if (load == null) {
|
|
1345
|
+
return empty;
|
|
1346
|
+
}
|
|
1347
|
+
const module = await loadOrNull(load);
|
|
1348
|
+
if (module == null) {
|
|
1349
|
+
return empty;
|
|
1350
|
+
}
|
|
1351
|
+
return { ...empty, page: withoutLoader(module, record.defaultFile ?? record.name) };
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
const route = matched.route;
|
|
1355
|
+
// Started together and awaited apart, so the two `await`s are not a
|
|
1356
|
+
// waterfall and each keeps the type its loader had.
|
|
1357
|
+
const pending = loadOrNull(route.page);
|
|
1358
|
+
const pendingLayouts = Promise.all(route.layouts.map((layout) => loadOrNull(layout)));
|
|
1359
|
+
const page = await pending;
|
|
1360
|
+
const layouts = await pendingLayouts;
|
|
1361
|
+
if (page == null) {
|
|
1362
|
+
return empty;
|
|
1363
|
+
}
|
|
1364
|
+
const loaded = layouts.filter(Boolean);
|
|
1365
|
+
if (loaded.length !== layouts.length) {
|
|
1366
|
+
return empty;
|
|
1367
|
+
}
|
|
1368
|
+
return {
|
|
1369
|
+
name: record.name,
|
|
1370
|
+
above,
|
|
1371
|
+
page: withoutLoader(page, route.file),
|
|
1372
|
+
params: matched.params,
|
|
1373
|
+
layouts: loaded,
|
|
1374
|
+
// The slot's own layouts are what a nested slot is measured against, so
|
|
1375
|
+
// the count handed down is this slot's rather than the route's.
|
|
1376
|
+
slots: await resolveSlots(route.slots, pathname, loaded.length),
|
|
1123
1377
|
};
|
|
1124
1378
|
}
|
|
1125
1379
|
|
|
1380
|
+
/**
|
|
1381
|
+
* A module, or `null` when it would not import.
|
|
1382
|
+
*
|
|
1383
|
+
* The judgement [`resolveTemplates`] and [`resolveLoading`] already make, at
|
|
1384
|
+
* the granularity a slot needs it: a slot is a second thing beside the page, so
|
|
1385
|
+
* a slot whose module is missing renders nothing rather than taking the route
|
|
1386
|
+
* down with it — and the import error surfaces where it belongs, the next time
|
|
1387
|
+
* the module is asked for.
|
|
1388
|
+
*/
|
|
1389
|
+
async function loadOrNull<TModule>(load: () => Promise<TModule>): Promise<?TModule> {
|
|
1390
|
+
try {
|
|
1391
|
+
return await loadOnce(load);
|
|
1392
|
+
} catch {
|
|
1393
|
+
return null;
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
/**
|
|
1398
|
+
* The same page module, having said out loud that a slot's loader does not run.
|
|
1399
|
+
*
|
|
1400
|
+
* A slot page is a component. It is *not* handed data, and this throws rather
|
|
1401
|
+
* than passing `undefined` to a page that asked for some, because a slot whose
|
|
1402
|
+
* loader is quietly skipped is exactly the failure ubugeeei-prod/uf#267 is
|
|
1403
|
+
* about — a file written to a convention, and nothing that reads it.
|
|
1404
|
+
*
|
|
1405
|
+
* Why not run it. A page's loader answer is embedded in the document for the
|
|
1406
|
+
* browser to hydrate from, once, under one id; a slot's would have nowhere to
|
|
1407
|
+
* go, so it would run on the server and again in the browser on the way in.
|
|
1408
|
+
* That is not merely two fetches: a loader that reads `cookies()` succeeds on
|
|
1409
|
+
* the server and throws in the browser, and the slot would render on one side
|
|
1410
|
+
* and not the other — a hydration mismatch produced by the router. So the rule
|
|
1411
|
+
* is the narrow one, and lifting it means embedding per-slot data, which is
|
|
1412
|
+
* named in the issue as what is left.
|
|
1413
|
+
*/
|
|
1414
|
+
function withoutLoader(module: PageModule, file: string): PageModule {
|
|
1415
|
+
if (typeof module.loader === "function") {
|
|
1416
|
+
throw new Error(
|
|
1417
|
+
`@uniflowed/router: ${file} is inside a \`@slot\` and exports a \`loader\`, which the ` +
|
|
1418
|
+
"router does not run — a slot's data has nowhere to be embedded for hydration, so it " +
|
|
1419
|
+
"would be fetched again in the browser and a server-only loader would render one tree " +
|
|
1420
|
+
"on the server and another in the page. Fetch inside the component, or move the data to " +
|
|
1421
|
+
"the page the URL names. https://github.com/ubugeeei-prod/uf/issues/267",
|
|
1422
|
+
);
|
|
1423
|
+
}
|
|
1424
|
+
return module;
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1126
1427
|
/**
|
|
1127
1428
|
* The route's templates, imported.
|
|
1128
1429
|
*
|
|
@@ -1352,6 +1653,11 @@ async function resolveError(
|
|
|
1352
1653
|
// show, which is worse than none.
|
|
1353
1654
|
loading: [],
|
|
1354
1655
|
templates: [],
|
|
1656
|
+
// And slots for the third time: a slot belongs to the segment the walk went
|
|
1657
|
+
// through, and an error page is matched rather than walked to. A layout
|
|
1658
|
+
// that declares one is still mounted above the boundary, holding the slot
|
|
1659
|
+
// it was rendered with — the boundary replaces what is under it.
|
|
1660
|
+
slots: [],
|
|
1355
1661
|
};
|
|
1356
1662
|
}
|
|
1357
1663
|
|
|
@@ -1363,13 +1669,13 @@ async function resolveError(
|
|
|
1363
1669
|
* Taking the matched route's layouts was the other candidate and it is wrong
|
|
1364
1670
|
* in both directions: for an unmatched URL there is no matched route to take
|
|
1365
1671
|
* them from, and for `notFound()` thrown from a page they would keep the
|
|
1366
|
-
* layouts *below* the boundary — so `app/guide/[slug]
|
|
1367
|
-
* wrap a 404 that `app/guide
|
|
1672
|
+
* layouts *below* the boundary — so `app/guide/[slug]/$layout.js` would
|
|
1673
|
+
* wrap a 404 that `app/guide/$not-found.js` answered, which is the layout
|
|
1368
1674
|
* of the page that just said it does not exist.
|
|
1369
1675
|
*
|
|
1370
1676
|
* # The record with no page
|
|
1371
1677
|
*
|
|
1372
|
-
* A project that declares no
|
|
1678
|
+
* A project that declares no `$not-found.js` anywhere still has a record —
|
|
1373
1679
|
* the one the build synthesises for the router root — and it names the root's
|
|
1374
1680
|
* layouts and no module. Before that record existed this function answered
|
|
1375
1681
|
* with `layouts: []`, so a site whose root layout owns the masthead, the
|
|
@@ -1423,6 +1729,8 @@ async function resolveNotFound(
|
|
|
1423
1729
|
loading: [],
|
|
1424
1730
|
// Templates are accumulated on that same walk, and for the same reason.
|
|
1425
1731
|
templates: [],
|
|
1732
|
+
// Slots too: a URL that matched no route addressed no slot either.
|
|
1733
|
+
slots: [],
|
|
1426
1734
|
};
|
|
1427
1735
|
}
|
|
1428
1736
|
|
|
@@ -1520,7 +1828,7 @@ function errorTitle(error: RouteError): string {
|
|
|
1520
1828
|
}
|
|
1521
1829
|
|
|
1522
1830
|
/**
|
|
1523
|
-
* The framework's error page, for a project that declares no
|
|
1831
|
+
* The framework's error page, for a project that declares no `$error.js`.
|
|
1524
1832
|
*
|
|
1525
1833
|
* It says which of the three happened and offers the reset, and it does *not*
|
|
1526
1834
|
* print the thrown error: on the server that message is written for whoever
|
|
@@ -1687,7 +1995,7 @@ class RouteErrorBoundary extends React.Component<RouteErrorBoundaryProps, RouteE
|
|
|
1687
1995
|
//
|
|
1688
1996
|
// The cost is real and worth stating rather than discovering. Inside a
|
|
1689
1997
|
// transition the commit is synchronous, so a route that suspends *while
|
|
1690
|
-
// rendering* shows its
|
|
1998
|
+
// rendering* shows its `$loading.js` fallback instead of leaving the
|
|
1691
1999
|
// previous page up until it resolves. Its modules and its loader are already
|
|
1692
2000
|
// finished by this point — `resolveMatch` awaited both — so what is left is a
|
|
1693
2001
|
// component suspending on something else, and it degrades to the fallback the
|
|
@@ -1854,10 +2162,19 @@ export type RouteInfo = {|
|
|
|
1854
2162
|
readonly pending: boolean,
|
|
1855
2163
|
|};
|
|
1856
2164
|
|
|
2165
|
+
/**
|
|
2166
|
+
* What this application does when a visitor follows a link.
|
|
2167
|
+
*
|
|
2168
|
+
* `app.rendering.navigation` in `uf.config.js`, and the same two words: the
|
|
2169
|
+
* client router takes the link over, or the browser does.
|
|
2170
|
+
*/
|
|
2171
|
+
export type Navigation = "client" | "document";
|
|
2172
|
+
|
|
1857
2173
|
type RouterState = {|
|
|
1858
2174
|
readonly resolved: ResolvedRoute,
|
|
1859
2175
|
readonly router: Router,
|
|
1860
2176
|
readonly pending: boolean,
|
|
2177
|
+
readonly navigation: Navigation,
|
|
1861
2178
|
|};
|
|
1862
2179
|
|
|
1863
2180
|
const RouterContext: React.Context<?RouterState> = createContext(null);
|
|
@@ -1865,6 +2182,38 @@ const RouterContext: React.Context<?RouterState> = createContext(null);
|
|
|
1865
2182
|
/** The route table the application was started with. */
|
|
1866
2183
|
let installedTable: ?RouteTable = null;
|
|
1867
2184
|
|
|
2185
|
+
/**
|
|
2186
|
+
* How the application navigates, installed by the entry that started it.
|
|
2187
|
+
*
|
|
2188
|
+
* Module state beside `installedTable`, and for the same reason: the entry is
|
|
2189
|
+
* the only thing that knows, and every component that needs the answer is
|
|
2190
|
+
* somewhere under a `RouterProvider` it did not construct. `routerView` builds
|
|
2191
|
+
* that provider from two props the server handed it, and threading a third one
|
|
2192
|
+
* from the entry through the application root would have made every
|
|
2193
|
+
* hand-written `<App>` in a test a place the default lives.
|
|
2194
|
+
*
|
|
2195
|
+
* `"client"` until something says otherwise, which is what every uf
|
|
2196
|
+
* application did before `app.rendering.navigation` existed and what a test
|
|
2197
|
+
* that renders `routerView` directly still gets.
|
|
2198
|
+
*/
|
|
2199
|
+
let installedNavigation: Navigation = "client";
|
|
2200
|
+
|
|
2201
|
+
/**
|
|
2202
|
+
* Say how this application navigates. Called once, by the client entry.
|
|
2203
|
+
*
|
|
2204
|
+
* `@uniflowed/vite` generates the call into `virtual:uf/client` from
|
|
2205
|
+
* `app.rendering.navigation`; nothing else should call it, and calling it after
|
|
2206
|
+
* the first render is a change no rendered `Link` will notice.
|
|
2207
|
+
*/
|
|
2208
|
+
export function installNavigation(navigation: Navigation): void {
|
|
2209
|
+
installedNavigation = navigation;
|
|
2210
|
+
}
|
|
2211
|
+
|
|
2212
|
+
/** How this application navigates. */
|
|
2213
|
+
export function navigationMode(): Navigation {
|
|
2214
|
+
return installedNavigation;
|
|
2215
|
+
}
|
|
2216
|
+
|
|
1868
2217
|
/** Register the generated route table. Called once by the client and server entries. */
|
|
1869
2218
|
export function installRoutes(table: RouteTable): void {
|
|
1870
2219
|
installedTable = table;
|
|
@@ -1911,10 +2260,31 @@ function isBrowser(): boolean {
|
|
|
1911
2260
|
* provider listens to history and to `Link` clicks; a navigation resolves the
|
|
1912
2261
|
* next route (loading its chunks and running its loader) *before* committing,
|
|
1913
2262
|
* inside a transition, so the previous page stays interactive meanwhile.
|
|
2263
|
+
*
|
|
2264
|
+
* # Unless the application asked the browser to do it
|
|
2265
|
+
*
|
|
2266
|
+
* Under `app.rendering.navigation: "document"` every one of those sentences
|
|
2267
|
+
* stops being true, and the provider is still here: the tree below it still
|
|
2268
|
+
* reads `useRoute`, still renders `<RouteView>`, and still hydrates whatever
|
|
2269
|
+
* `"use client"` boundary made the document interactive. What it does not do is
|
|
2270
|
+
* take the link over. `navigate` hands the URL to the browser, no `popstate`
|
|
2271
|
+
* listener is installed, and `prefetch` — which exists to load the chunks of a
|
|
2272
|
+
* route this page will render — has no page to load them for.
|
|
2273
|
+
*
|
|
2274
|
+
* That is one branch rather than a second provider because the two differ in
|
|
2275
|
+
* what happens on a click and in nothing else. A second implementation would
|
|
2276
|
+
* have had to keep `resolved`, `pending`, the context and every hook that
|
|
2277
|
+
* reads it in step with this one, which is four things to keep in step for one
|
|
2278
|
+
* that actually differs.
|
|
1914
2279
|
*/
|
|
1915
2280
|
export component RouterProvider(url: string, initial: ResolvedRoute, children: React.Node) {
|
|
1916
2281
|
const [resolved, setResolved] = useState<ResolvedRoute>(initial);
|
|
1917
2282
|
const [pending, setPending] = useState<boolean>(false);
|
|
2283
|
+
// Read once per render rather than per navigation: it is installed by the
|
|
2284
|
+
// entry before the first render and never changes after it, and a `Link`
|
|
2285
|
+
// that asked at click time would be asking a question whose answer decided
|
|
2286
|
+
// what it rendered.
|
|
2287
|
+
const navigation = navigationMode();
|
|
1918
2288
|
|
|
1919
2289
|
const navigate = async (to: string, options?: NavigateOptions): Promise<void> => {
|
|
1920
2290
|
if (!isBrowser()) {
|
|
@@ -1922,6 +2292,19 @@ export component RouterProvider(url: string, initial: ResolvedRoute, children: R
|
|
|
1922
2292
|
}
|
|
1923
2293
|
const target = new URL(to, window.location.href);
|
|
1924
2294
|
const next = target.pathname + target.search;
|
|
2295
|
+
// The browser's job in this application. `assign` and `replace` rather
|
|
2296
|
+
// than the history API, because the point is a document request: the
|
|
2297
|
+
// history entry, the scroll position, the `Referer` and the unload
|
|
2298
|
+
// handlers are then the browser's, done the way they are done for a link
|
|
2299
|
+
// in a page with no JavaScript on it at all.
|
|
2300
|
+
if (navigation === "document") {
|
|
2301
|
+
if (options?.replace === true) {
|
|
2302
|
+
window.location.replace(target.href);
|
|
2303
|
+
} else {
|
|
2304
|
+
window.location.assign(target.href);
|
|
2305
|
+
}
|
|
2306
|
+
return;
|
|
2307
|
+
}
|
|
1925
2308
|
// The half of the split that is not about bytes. A route whose page is not
|
|
1926
2309
|
// in this bundle is not a route this router can render, and pretending
|
|
1927
2310
|
// otherwise is the silent break: the navigation would resolve to nothing
|
|
@@ -1971,6 +2354,15 @@ export component RouterProvider(url: string, initial: ResolvedRoute, children: R
|
|
|
1971
2354
|
if (!isBrowser()) {
|
|
1972
2355
|
return undefined;
|
|
1973
2356
|
}
|
|
2357
|
+
// Nothing pushed a history entry, so there is nothing to pop back into: a
|
|
2358
|
+
// document-navigating application left this page when the link was
|
|
2359
|
+
// followed, and the back button asks the browser for the previous document
|
|
2360
|
+
// rather than asking this listener to rebuild it. Installing one anyway
|
|
2361
|
+
// would put a `resolveMatch` on the back button of a page that is about to
|
|
2362
|
+
// be replaced by the one the browser already has.
|
|
2363
|
+
if (navigation === "document") {
|
|
2364
|
+
return undefined;
|
|
2365
|
+
}
|
|
1974
2366
|
const onPopState = () => {
|
|
1975
2367
|
const next = window.location.pathname + window.location.search;
|
|
1976
2368
|
// Back into a route this bundle has no page for. The history entry is
|
|
@@ -2000,7 +2392,12 @@ export component RouterProvider(url: string, initial: ResolvedRoute, children: R
|
|
|
2000
2392
|
push: (to, options) => navigate(to, options),
|
|
2001
2393
|
replace: (to) => navigate(to, { replace: true }),
|
|
2002
2394
|
prefetch: async (to) => {
|
|
2003
|
-
|
|
2395
|
+
// A prefetch loads the modules the *next render* will need, and under
|
|
2396
|
+
// document navigation there is no next render in this page: the browser
|
|
2397
|
+
// fetches a document and throws this one away. Loading the chunks would
|
|
2398
|
+
// be bytes spent on a page that is leaving, so this declines rather than
|
|
2399
|
+
// warming a cache nothing reads.
|
|
2400
|
+
if (!isBrowser() || navigation === "document") {
|
|
2004
2401
|
return;
|
|
2005
2402
|
}
|
|
2006
2403
|
const target = new URL(to, window.location.href);
|
|
@@ -2018,6 +2415,14 @@ export component RouterProvider(url: string, initial: ResolvedRoute, children: R
|
|
|
2018
2415
|
if (!isBrowser()) {
|
|
2019
2416
|
return;
|
|
2020
2417
|
}
|
|
2418
|
+
// The same URL, rendered again — which under document navigation is what
|
|
2419
|
+
// the browser calls a reload. Resolving it in the page instead would
|
|
2420
|
+
// re-run the loader and commit a tree whose links this application has
|
|
2421
|
+
// already said it does not drive.
|
|
2422
|
+
if (navigation === "document") {
|
|
2423
|
+
window.location.reload();
|
|
2424
|
+
return;
|
|
2425
|
+
}
|
|
2021
2426
|
const nextResolved = await resolveMatch(
|
|
2022
2427
|
routeTable(),
|
|
2023
2428
|
window.location.pathname + window.location.search,
|
|
@@ -2042,7 +2447,7 @@ export component RouterProvider(url: string, initial: ResolvedRoute, children: R
|
|
|
2042
2447
|
},
|
|
2043
2448
|
};
|
|
2044
2449
|
|
|
2045
|
-
const value: RouterState = { resolved, router, pending };
|
|
2450
|
+
const value: RouterState = { resolved, router, pending, navigation };
|
|
2046
2451
|
return <RouterContext.Provider value={value}>{children}</RouterContext.Provider>;
|
|
2047
2452
|
}
|
|
2048
2453
|
|
|
@@ -2119,6 +2524,25 @@ export hook useLoaderData(): mixed {
|
|
|
2119
2524
|
return useResolvedData(useRouterState().resolved);
|
|
2120
2525
|
}
|
|
2121
2526
|
|
|
2527
|
+
/**
|
|
2528
|
+
* Whether this bundle marks the boundaries it renders.
|
|
2529
|
+
*
|
|
2530
|
+
* `import.meta.hot` is the same gate `../client.js` uses for the hydration
|
|
2531
|
+
* report and the DevTools check, chosen there for the reason it is chosen here:
|
|
2532
|
+
* Vite defines it while serving and replaces it with `undefined` in a build, so
|
|
2533
|
+
* every branch below is statically dead in a production bundle and the module
|
|
2534
|
+
* behind it — `@uniflowed/router` is `sideEffects: false` — is dropped rather
|
|
2535
|
+
* than shipped unused. Node leaves it undefined, so a host that imports this
|
|
2536
|
+
* file without a bundler gets the production path, and so does the test suite.
|
|
2537
|
+
*
|
|
2538
|
+
* It is a module constant rather than a per-render question because the branch
|
|
2539
|
+
* has to be foldable, and it may answer differently in the browser and on the
|
|
2540
|
+
* server without costing anything: a mark renders nothing until it has mounted,
|
|
2541
|
+
* so neither the server's markup nor the tree React hydrates against it can
|
|
2542
|
+
* contain one. See `./boundaries.js`, which has the argument.
|
|
2543
|
+
*/
|
|
2544
|
+
const BOUNDARY_MARKS: boolean = import.meta.hot != null;
|
|
2545
|
+
|
|
2122
2546
|
/**
|
|
2123
2547
|
* Renders the matched page inside its layouts, innermost last, with the
|
|
2124
2548
|
* document metadata as hoistable head elements.
|
|
@@ -2135,7 +2559,7 @@ export hook useLoaderData(): mixed {
|
|
|
2135
2559
|
* # Where the error boundaries go
|
|
2136
2560
|
*
|
|
2137
2561
|
* Two, and they are not the same thing twice. The inner one is the project's
|
|
2138
|
-
*
|
|
2562
|
+
* `$error.js`, placed at the depth the file sits at, so the layouts above
|
|
2139
2563
|
* it stay mounted and interactive while the subtree below is replaced — that
|
|
2140
2564
|
* placement *is* the feature. The outer one has no module and so renders the
|
|
2141
2565
|
* framework's page; it is what stands between a throw in a root layout, or in
|
|
@@ -2149,7 +2573,7 @@ export hook useLoaderData(): mixed {
|
|
|
2149
2573
|
* everything under it, which is what makes the shell arrive first: a renderer
|
|
2150
2574
|
* streaming this tree can send every layout down to the boundary, and the
|
|
2151
2575
|
* fallback, before whatever the page is waiting for has resolved. A segment
|
|
2152
|
-
* with no
|
|
2576
|
+
* with no `$loading.js` contributes no boundary at all — it is not wrapped
|
|
2153
2577
|
* in a `<Suspense fallback={null}>` on the way past — so a project that
|
|
2154
2578
|
* declares none renders the tree it rendered before this existed, and a page
|
|
2155
2579
|
* that suspends without a boundary above it still fails the way React says it
|
|
@@ -2168,11 +2592,33 @@ export hook useLoaderData(): mixed {
|
|
|
2168
2592
|
* with opposite answers to one question, so they are one line apart here, and
|
|
2169
2593
|
* the whole of the difference is the `key` — see [`insideTemplates`], which is
|
|
2170
2594
|
* that line's other half.
|
|
2595
|
+
*
|
|
2596
|
+
* # Where the boundary marks go
|
|
2597
|
+
*
|
|
2598
|
+
* Inside each boundary and around nothing else, under `uf dev` only. A
|
|
2599
|
+
* `<Suspense>` and a class boundary each render no element of their own, so the
|
|
2600
|
+
* run of nodes one owns is indistinguishable on the page from the layout's own
|
|
2601
|
+
* nodes beside it — the marks are what distinguish it, and this loop is the
|
|
2602
|
+
* only place that knows which boundary is which. `./boundaries.js` has the
|
|
2603
|
+
* mechanism and the argument; every reference to it here is inside a
|
|
2604
|
+
* [`BOUNDARY_MARKS`] branch, so a build has none of it. See
|
|
2605
|
+
* ubugeeei-prod/uf#520.
|
|
2171
2606
|
*/
|
|
2172
2607
|
export component RouteView() {
|
|
2173
2608
|
const { resolved } = useRouterState();
|
|
2174
2609
|
const { module, above } = resolved.errorBoundary;
|
|
2175
2610
|
const loader = resolved.deferred;
|
|
2611
|
+
// The route's boundaries, named once and read by both the marks below and the
|
|
2612
|
+
// report that watches them. `installedTable` rather than [`routeTable`],
|
|
2613
|
+
// which throws: a test may render this view without an entry having installed
|
|
2614
|
+
// a table, and an error boundary named by its depth alone is worth less than
|
|
2615
|
+
// one named by its file rather than wrong.
|
|
2616
|
+
const marks = BOUNDARY_MARKS
|
|
2617
|
+
? routeBoundaries(
|
|
2618
|
+
resolved,
|
|
2619
|
+
nearestBoundary(installedTable?.errors ?? [], resolved.pathname)?.file,
|
|
2620
|
+
)
|
|
2621
|
+
: null;
|
|
2176
2622
|
// The innermost element, so the `use` inside `AwaitedPage` suspends below
|
|
2177
2623
|
// every boundary the loop below adds — which is what makes the layouts and
|
|
2178
2624
|
// the fallback the shell rather than something waiting behind the loader.
|
|
@@ -2190,12 +2636,16 @@ export component RouteView() {
|
|
|
2190
2636
|
continue;
|
|
2191
2637
|
}
|
|
2192
2638
|
const Fallback = loadingComponent(boundary.module);
|
|
2193
|
-
element =
|
|
2639
|
+
element = (
|
|
2640
|
+
<Suspense fallback={<Fallback />}>
|
|
2641
|
+
{BOUNDARY_MARKS ? insideBoundary(marks?.get(suspenseId(index)), element) : element}
|
|
2642
|
+
</Suspense>
|
|
2643
|
+
);
|
|
2194
2644
|
}
|
|
2195
2645
|
// Placed on `above` alone, and not on there being a module: a `null` one is
|
|
2196
2646
|
// the framework's own error page, and where it renders is exactly the
|
|
2197
2647
|
// question ubugeeei-prod/uf#351 asks. A project that declares no
|
|
2198
|
-
//
|
|
2648
|
+
// `$error.js` has the record the build synthesises for the router root,
|
|
2199
2649
|
// whose `above` is the root's layouts — so the framework's page appears
|
|
2200
2650
|
// inside the masthead rather than in place of the document. A table with no
|
|
2201
2651
|
// record at all answers 0, which puts this boundary outside every layout,
|
|
@@ -2207,22 +2657,42 @@ export component RouteView() {
|
|
|
2207
2657
|
if (depth === above && resolved.error == null) {
|
|
2208
2658
|
element = (
|
|
2209
2659
|
<RouteErrorBoundary module={module} resetKey={resolved.pathname}>
|
|
2210
|
-
{element}
|
|
2660
|
+
{BOUNDARY_MARKS ? insideBoundary(marks?.get(ROUTE_ERROR_ID), element) : element}
|
|
2211
2661
|
</RouteErrorBoundary>
|
|
2212
2662
|
);
|
|
2213
2663
|
}
|
|
2214
2664
|
element = insideTemplates(element, resolved, depth);
|
|
2215
2665
|
if (depth > 0) {
|
|
2216
2666
|
const Layout = layoutComponent(resolved.layouts[depth - 1]);
|
|
2217
|
-
|
|
2667
|
+
// The slots declared on this layout's own segment, beside `children`.
|
|
2668
|
+
// Spread rather than passed as one `slots` object, because a slot is a
|
|
2669
|
+
// prop a layout declares by name — `component Dashboard(children, team)`
|
|
2670
|
+
// — and a bag would make every layout destructure a map to find out
|
|
2671
|
+
// whether the router had anything for it.
|
|
2672
|
+
// The spread first and `params` after it, so that a slot named after a
|
|
2673
|
+
// prop the layout already has loses rather than wins. `@params` and
|
|
2674
|
+
// `@children` are refused by the scan, and this is the second line of
|
|
2675
|
+
// that defence for a table written by hand: losing a slot is a hole in
|
|
2676
|
+
// the page, and overwriting `params` is every route in the segment
|
|
2677
|
+
// rendering against the wrong parameters.
|
|
2678
|
+
element = (
|
|
2679
|
+
<Layout {...slotsAt(resolved.slots, depth)} params={resolved.params}>
|
|
2680
|
+
{element}
|
|
2681
|
+
</Layout>
|
|
2682
|
+
);
|
|
2218
2683
|
}
|
|
2219
2684
|
}
|
|
2220
2685
|
return (
|
|
2221
2686
|
<>
|
|
2222
2687
|
<Head metadata={resolved.metadata} />
|
|
2223
2688
|
<RouteErrorBoundary module={null} resetKey={resolved.pathname}>
|
|
2224
|
-
{element}
|
|
2689
|
+
{BOUNDARY_MARKS ? insideBoundary(marks?.get(ROOT_ERROR_ID), element) : element}
|
|
2225
2690
|
</RouteErrorBoundary>
|
|
2691
|
+
{/* After the tree rather than before it, so its effect runs once every
|
|
2692
|
+
mark below has had its own — which is the commit the marks are in. */}
|
|
2693
|
+
{BOUNDARY_MARKS && marks != null ? (
|
|
2694
|
+
<BoundaryReporter path={resolved.path} boundaries={marks} />
|
|
2695
|
+
) : null}
|
|
2226
2696
|
</>
|
|
2227
2697
|
);
|
|
2228
2698
|
}
|
|
@@ -2238,6 +2708,12 @@ export component RouteView() {
|
|
|
2238
2708
|
* innermost `<Suspense>` when the route deferred its loader on the server, and
|
|
2239
2709
|
* exactly there again on the client, where the data is already in hand and
|
|
2240
2710
|
* nothing suspends at all.
|
|
2711
|
+
*
|
|
2712
|
+
* "The loader's answer" is now a payload rather than a value, so what
|
|
2713
|
+
* [`payloadElements`] renders is that script plus one boundary per value the
|
|
2714
|
+
* answer deferred. The same argument covers all of them: the browser's copy of
|
|
2715
|
+
* this component renders the same rows in the same places, from the values it
|
|
2716
|
+
* read out of those very elements.
|
|
2241
2717
|
*/
|
|
2242
2718
|
component RenderedPage(data: mixed) {
|
|
2243
2719
|
const { resolved } = useRouterState();
|
|
@@ -2245,7 +2721,7 @@ component RenderedPage(data: mixed) {
|
|
|
2245
2721
|
return (
|
|
2246
2722
|
<>
|
|
2247
2723
|
<Page params={resolved.params} searchParams={resolved.searchParams} data={data} />
|
|
2248
|
-
{
|
|
2724
|
+
{payloadElements(data)}
|
|
2249
2725
|
</>
|
|
2250
2726
|
);
|
|
2251
2727
|
}
|
|
@@ -2284,25 +2760,159 @@ component AwaitedPage(loader: Promise<mixed>) {
|
|
|
2284
2760
|
*
|
|
2285
2761
|
* `<` is escaped inside the JSON so a string holding `</script>` cannot end the
|
|
2286
2762
|
* element early, and U+2028 and U+2029 because a JSON document is not
|
|
2287
|
-
* JavaScript source but is sometimes read as if it were
|
|
2763
|
+
* JavaScript source but is sometimes read as if it were — the escape moved to
|
|
2764
|
+
* `./payload.js` when the model stopped being the only thing written that way.
|
|
2288
2765
|
* `dangerouslySetInnerHTML` rather than a text child because React escapes a
|
|
2289
2766
|
* text child and `"` is not JSON any more. `security/no-dangerously-set-
|
|
2290
2767
|
* inner-html` is about markup that came from somewhere and has to be sanitized
|
|
2291
2768
|
* before a browser parses it as HTML; this is `JSON.stringify`'s output with
|
|
2292
2769
|
* `<` escaped, in an element the browser never parses as HTML and never runs.
|
|
2293
|
-
* `docs/app
|
|
2770
|
+
* `docs/app/$layout.js` carries the same suppression for the same reason.
|
|
2771
|
+
*
|
|
2772
|
+
* # And the rows the model deferred
|
|
2773
|
+
*
|
|
2774
|
+
* A promise anywhere in the loader's answer used to be `JSON.stringify`'d to
|
|
2775
|
+
* `{}`. It is now a `"$P<n>"` reference in the element above and a `<script
|
|
2776
|
+
* data-uf-row="n">` of its own, inside a `<Suspense fallback={null}>` — which
|
|
2777
|
+
* is what makes React stream it at the moment the promise settles rather than
|
|
2778
|
+
* holding the document for it. Each row is its own boundary, so two deferred
|
|
2779
|
+
* values arrive in the order they resolved in and not in the order they were
|
|
2780
|
+
* written. `./payload.js` is the format; `./payload-rows.js` is the browser
|
|
2781
|
+
* reading them back.
|
|
2782
|
+
*
|
|
2783
|
+
* The boundaries sit after the page rather than before it, where the data
|
|
2784
|
+
* element already was. A page that suspends with no `$loading.js` above it
|
|
2785
|
+
* holds the whole shell — that is React's rule and uf does not work around it
|
|
2786
|
+
* — so the position buys nothing either way, and "the scripts are where the
|
|
2787
|
+
* script was" is worth more than a rearrangement that is not.
|
|
2788
|
+
*
|
|
2789
|
+
* # Why the rows are inside an element
|
|
2790
|
+
*
|
|
2791
|
+
* Because a `<Suspense>` that is a direct child of the *render root* stops the
|
|
2792
|
+
* shell being flushed at all. React's renderer can only write a segment once
|
|
2793
|
+
* the segment is complete, and the root segment holds an unresolved boundary
|
|
2794
|
+
* open: measured against React 19.2.8, a tree of `[<div>, <Suspense>]` writes
|
|
2795
|
+
* its first byte when the boundary resolves, and the same tree with the
|
|
2796
|
+
* boundary inside any host element writes it immediately. Every component
|
|
2797
|
+
* between the root and here — `RenderProvider`, `RouterProvider`, `RouteView`,
|
|
2798
|
+
* `RouteErrorBoundary` — renders no element of its own, so without this
|
|
2799
|
+
* `<span>` the rows would be exactly that first shape and a payload would have
|
|
2800
|
+
* streamed nothing.
|
|
2801
|
+
*
|
|
2802
|
+
* `hidden` because it holds no content a reader is meant to see: `<script
|
|
2803
|
+
* type="application/json">` renders nothing either way, and the attribute is
|
|
2804
|
+
* what says so to anything that inspects the document. One element for all the
|
|
2805
|
+
* rows rather than one each — the boundaries inside it still resolve
|
|
2806
|
+
* independently, since each is its own.
|
|
2807
|
+
*
|
|
2808
|
+
* The same rule catches a route whose `$loading.js` sits above no layout:
|
|
2809
|
+
* `RouteView` puts that boundary in the same position, and it does not stream
|
|
2810
|
+
* either. That is a bug this file did not introduce and does not fix; it is
|
|
2811
|
+
* written down in ubugeeei-prod/uf#519 rather than left to be rediscovered.
|
|
2294
2812
|
*/
|
|
2295
|
-
function
|
|
2813
|
+
function payloadElements(data: mixed): React.Node {
|
|
2296
2814
|
if (data === undefined) {
|
|
2297
2815
|
return null;
|
|
2298
2816
|
}
|
|
2299
|
-
const
|
|
2300
|
-
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
const
|
|
2817
|
+
const { model, rows } = encodePayload(data, "the route's loader data");
|
|
2818
|
+
// Before anything renders, so a promise that has already rejected is one
|
|
2819
|
+
// somebody is listening to. `settledRow` is memoized, so the components
|
|
2820
|
+
// below get these same promises rather than a second set.
|
|
2821
|
+
for (const row of rows) {
|
|
2822
|
+
settledRow(row.value);
|
|
2823
|
+
}
|
|
2824
|
+
const html = { __html: payloadJson(model) };
|
|
2304
2825
|
// uf-lint-disable-next-line security/no-dangerously-set-inner-html
|
|
2305
|
-
|
|
2826
|
+
const row0 = <script id={DATA_ID} type="application/json" dangerouslySetInnerHTML={html} />;
|
|
2827
|
+
return (
|
|
2828
|
+
<>
|
|
2829
|
+
{row0}
|
|
2830
|
+
{rows.length === 0 ? null : (
|
|
2831
|
+
<span hidden>
|
|
2832
|
+
{rows.map((row) => (
|
|
2833
|
+
<Suspense key={row.id} fallback={null}>
|
|
2834
|
+
<PayloadRow id={row.id} value={row.value} />
|
|
2835
|
+
</Suspense>
|
|
2836
|
+
))}
|
|
2837
|
+
</span>
|
|
2838
|
+
)}
|
|
2839
|
+
</>
|
|
2840
|
+
);
|
|
2841
|
+
}
|
|
2842
|
+
|
|
2843
|
+
/**
|
|
2844
|
+
* One deferred value, written when it settles.
|
|
2845
|
+
*
|
|
2846
|
+
* Rendered on both sides, which is the thing to keep in mind about it. On the
|
|
2847
|
+
* server `value` is the loader's own promise; in the browser it is the promise
|
|
2848
|
+
* `./payload-rows.js` created for this row and resolved out of this very
|
|
2849
|
+
* element. Both then write the element from the settled result through the
|
|
2850
|
+
* same [`payloadJson`], so the bytes agree and hydration has nothing to
|
|
2851
|
+
* report. `encodeRowValue` is what re-applies the reference escape to a value
|
|
2852
|
+
* the browser has already had it removed from.
|
|
2853
|
+
*
|
|
2854
|
+
* It never rejects. `use` on a rejected promise throws, and a throw here would
|
|
2855
|
+
* put the *row's* boundary into the error boundary above it — which is the
|
|
2856
|
+
* page, for a value the page may not even be reading. The failure travels as a
|
|
2857
|
+
* row instead, and the page's own `use` of the same promise is what reaches
|
|
2858
|
+
* the page's boundary, exactly as it would have without a payload.
|
|
2859
|
+
*/
|
|
2860
|
+
component PayloadRow(id: number, value: Promise<mixed>) {
|
|
2861
|
+
const message = use(settledRow(value));
|
|
2862
|
+
const html = { __html: payloadJson(message) };
|
|
2863
|
+
return (
|
|
2864
|
+
// uf-lint-disable-next-line security/no-dangerously-set-inner-html
|
|
2865
|
+
<script type="application/json" data-uf-row={String(id)} dangerouslySetInnerHTML={html} />
|
|
2866
|
+
);
|
|
2867
|
+
}
|
|
2868
|
+
|
|
2869
|
+
/**
|
|
2870
|
+
* The message a row will carry, as a promise that always fulfils.
|
|
2871
|
+
*
|
|
2872
|
+
* Keyed by the promise rather than recomputed, because `use` wants the same
|
|
2873
|
+
* promise every render and a render is repeated: React renders a component
|
|
2874
|
+
* again after it suspends, and Strict Mode renders it twice more. A `WeakMap`
|
|
2875
|
+
* so a route that has navigated away takes its rows with it.
|
|
2876
|
+
*/
|
|
2877
|
+
const settledRows: WeakMap<Promise<mixed>, Promise<PayloadRowMessage>> = new WeakMap();
|
|
2878
|
+
|
|
2879
|
+
function settledRow(value: Promise<mixed>): Promise<PayloadRowMessage> {
|
|
2880
|
+
const existing = settledRows.get(value);
|
|
2881
|
+
if (existing != null) {
|
|
2882
|
+
return existing;
|
|
2883
|
+
}
|
|
2884
|
+
const settled = value.then(
|
|
2885
|
+
(resolved) => ({ value: encodeRowValue(resolved, "a deferred value") }),
|
|
2886
|
+
(error) => ({ error: rowFailure(error) }),
|
|
2887
|
+
);
|
|
2888
|
+
settledRows.set(value, settled);
|
|
2889
|
+
return settled;
|
|
2890
|
+
}
|
|
2891
|
+
|
|
2892
|
+
/**
|
|
2893
|
+
* What a row says when the value failed.
|
|
2894
|
+
*
|
|
2895
|
+
* A fixed sentence in a build, and the error's own words where
|
|
2896
|
+
* `import.meta.hot` says a developer is reading them — the same gate
|
|
2897
|
+
* [`BOUNDARY_MARKS`] uses, and the same argument: a message that came out of a
|
|
2898
|
+
* loader can name a table, a query or a file path, and a browser is not where
|
|
2899
|
+
* any of those belong.
|
|
2900
|
+
*
|
|
2901
|
+
* A `PayloadRowError` short-circuits both, and has to. That error is what the
|
|
2902
|
+
* browser's reader rejects with, carrying the row's own text, so echoing it is
|
|
2903
|
+
* what makes the element the browser renders equal the one the server sent
|
|
2904
|
+
* whichever of the two builds was the development one.
|
|
2905
|
+
*/
|
|
2906
|
+
const ROW_FAILURE = "@uniflowed/router: a deferred value failed on the server.";
|
|
2907
|
+
|
|
2908
|
+
function rowFailure(error: mixed): string {
|
|
2909
|
+
if (error instanceof PayloadRowError) {
|
|
2910
|
+
return error.wire;
|
|
2911
|
+
}
|
|
2912
|
+
if (!BOUNDARY_MARKS) {
|
|
2913
|
+
return ROW_FAILURE;
|
|
2914
|
+
}
|
|
2915
|
+
return error instanceof Error ? `${ROW_FAILURE} ${error.message}` : ROW_FAILURE;
|
|
2306
2916
|
}
|
|
2307
2917
|
|
|
2308
2918
|
/**
|
|
@@ -2359,7 +2969,7 @@ function insideTemplates(element: React.Node, resolved: ResolvedRoute, depth: nu
|
|
|
2359
2969
|
}
|
|
2360
2970
|
const Template = templateComponent(entry.module);
|
|
2361
2971
|
// Keyed on the pathname, which is the whole difference between this file
|
|
2362
|
-
// and
|
|
2972
|
+
// and `$layout.js`: React throws the subtree away and builds it again
|
|
2363
2973
|
// whenever the key changes, and a navigation that changes only the query
|
|
2364
2974
|
// string leaves it alone.
|
|
2365
2975
|
out = (
|
|
@@ -2371,6 +2981,79 @@ function insideTemplates(element: React.Node, resolved: ResolvedRoute, depth: nu
|
|
|
2371
2981
|
return out;
|
|
2372
2982
|
}
|
|
2373
2983
|
|
|
2984
|
+
/**
|
|
2985
|
+
* The slots declared at `depth`, as the props the layout there receives.
|
|
2986
|
+
*
|
|
2987
|
+
* One object per layout rather than one lookup per slot, so the common case —
|
|
2988
|
+
* a project with no slots at all — allocates nothing and spreads nothing.
|
|
2989
|
+
*
|
|
2990
|
+
* A slot the URL addressed and that has no `$default.js` is `null` rather
|
|
2991
|
+
* than absent: a layout that declares `team` receives `team` on every route,
|
|
2992
|
+
* so `{team ?? <Empty />}` is a thing a project can write and rely on.
|
|
2993
|
+
*/
|
|
2994
|
+
function slotsAt(
|
|
2995
|
+
slots: $ReadOnlyArray<ResolvedSlot>,
|
|
2996
|
+
depth: number,
|
|
2997
|
+
): { readonly [string]: React.Node } {
|
|
2998
|
+
if (slots.length === 0) {
|
|
2999
|
+
return EMPTY_SLOTS;
|
|
3000
|
+
}
|
|
3001
|
+
const props: { [string]: React.Node } = {};
|
|
3002
|
+
for (const slot of slots) {
|
|
3003
|
+
if (slot.above === depth) {
|
|
3004
|
+
// `null` rather than an element that renders nothing, and the difference
|
|
3005
|
+
// is the whole of what the prop is for: `{team ?? <Empty />}` has to be
|
|
3006
|
+
// able to tell "this slot has nothing in it" from "this slot rendered
|
|
3007
|
+
// something empty", and an element is never `null`.
|
|
3008
|
+
props[slot.name] = slot.page == null ? null : <SlotView slot={slot} />;
|
|
3009
|
+
}
|
|
3010
|
+
}
|
|
3011
|
+
return props;
|
|
3012
|
+
}
|
|
3013
|
+
|
|
3014
|
+
/** One object for every layout on a project that declares no slot. */
|
|
3015
|
+
const EMPTY_SLOTS: { readonly [string]: React.Node } = Object.freeze({});
|
|
3016
|
+
|
|
3017
|
+
/**
|
|
3018
|
+
* One slot's tree: its page, inside the layouts declared under the slot, with
|
|
3019
|
+
* the slots those layouts declare in turn.
|
|
3020
|
+
*
|
|
3021
|
+
* The same composition [`RouteView`] does and deliberately not the same
|
|
3022
|
+
* function. A route's tree carries the things a slot does not have — the error
|
|
3023
|
+
* boundary, the `<Suspense>` fallbacks, the templates, the head — and folding
|
|
3024
|
+
* a second, simpler case into that loop would be four `if`s asking which of the
|
|
3025
|
+
* two this is. What the two share is the *order*, page innermost and layouts
|
|
3026
|
+
* backwards over a root-first list, and that is short enough to be right twice.
|
|
3027
|
+
*
|
|
3028
|
+
* A slot with no page is never rendered through this component at all —
|
|
3029
|
+
* [`slotsAt`] hands the layout `null` instead, so the layout can tell an empty
|
|
3030
|
+
* slot from one that rendered something empty. The guard below is what makes
|
|
3031
|
+
* that a fact about one place rather than a convention two places share.
|
|
3032
|
+
*/
|
|
3033
|
+
component SlotView(slot: ResolvedSlot) {
|
|
3034
|
+
// Before the early return, because a hook after one is a hook that runs on
|
|
3035
|
+
// some renders and not others. The search string is the route's — a slot
|
|
3036
|
+
// matches the path and the query belongs to the URL, not to either match.
|
|
3037
|
+
const { resolved } = useRouterState();
|
|
3038
|
+
const page = slot.page;
|
|
3039
|
+
if (page == null) {
|
|
3040
|
+
return null;
|
|
3041
|
+
}
|
|
3042
|
+
const Page = pageComponent(page);
|
|
3043
|
+
let element: React.Node = (
|
|
3044
|
+
<Page params={slot.params} searchParams={resolved.searchParams} data={undefined} />
|
|
3045
|
+
);
|
|
3046
|
+
for (let depth = slot.layouts.length; depth > 0; depth -= 1) {
|
|
3047
|
+
const Layout = layoutComponent(slot.layouts[depth - 1]);
|
|
3048
|
+
element = (
|
|
3049
|
+
<Layout {...slotsAt(slot.slots, depth)} params={slot.params}>
|
|
3050
|
+
{element}
|
|
3051
|
+
</Layout>
|
|
3052
|
+
);
|
|
3053
|
+
}
|
|
3054
|
+
return element;
|
|
3055
|
+
}
|
|
3056
|
+
|
|
2374
3057
|
/**
|
|
2375
3058
|
* The component a template module renders: `default`, or the named `Template`.
|
|
2376
3059
|
*
|
|
@@ -2501,7 +3184,7 @@ function jsonLdText(entry: JsonLd): string {
|
|
|
2501
3184
|
* One JSON-LD object, as the element that carries it.
|
|
2502
3185
|
*
|
|
2503
3186
|
* A function rather than an element written inline, because the suppression
|
|
2504
|
-
* needs a line of its own; `docs/app
|
|
3187
|
+
* needs a line of its own; `docs/app/$layout.js` has the same shape for the
|
|
2505
3188
|
* same reason. `security/no-dangerously-set-inner-html` is about markup that
|
|
2506
3189
|
* came from somewhere and has to be sanitized before a browser parses it as
|
|
2507
3190
|
* HTML, and its escape hatch is a `@uniflowed/markdown` sanitizer — the right
|
|
@@ -2682,6 +3365,22 @@ export type LinkPrefetch = "off" | "intent" | "render";
|
|
|
2682
3365
|
* default) loads the destination's chunks on hover or focus, and
|
|
2683
3366
|
* `transition={false}` makes this one navigation a cut — most navigations are
|
|
2684
3367
|
* a link, so the opt-out in [`NavigateOptions`] has to be reachable from one.
|
|
3368
|
+
*
|
|
3369
|
+
* # Under `app.rendering.navigation: "document"` it is only the anchor
|
|
3370
|
+
*
|
|
3371
|
+
* No click handler of uf's, no `preventDefault`, no prefetch listeners: the
|
|
3372
|
+
* element the browser gets is the one it would have got from `<a href>` in the
|
|
3373
|
+
* source. That is the whole of what changing the mode does to a component,
|
|
3374
|
+
* which is the point — a project moving between the two rewrites its
|
|
3375
|
+
* `uf.config.js` and none of its pages, and a component library built on
|
|
3376
|
+
* `Link` works in both without knowing which it is in.
|
|
3377
|
+
*
|
|
3378
|
+
* It matters that the handler is *absent* rather than a handler that calls
|
|
3379
|
+
* `location.assign`. The two look the same for a left click and are not the
|
|
3380
|
+
* same link: `preventDefault` and a scripted navigation lose `download`, lose
|
|
3381
|
+
* a `target`, and change what the browser does with a middle click and with a
|
|
3382
|
+
* gesture uf has not heard of. An ordinary link is not an approximation of an
|
|
3383
|
+
* ordinary link.
|
|
2685
3384
|
*/
|
|
2686
3385
|
export component Link(
|
|
2687
3386
|
to: string,
|
|
@@ -2693,11 +3392,12 @@ export component Link(
|
|
|
2693
3392
|
onClick?: (event: SyntheticMouseEvent<HTMLAnchorElement>) => mixed,
|
|
2694
3393
|
...rest: { readonly [string]: mixed }
|
|
2695
3394
|
) {
|
|
2696
|
-
const router =
|
|
3395
|
+
const { router, navigation } = useRouterState();
|
|
2697
3396
|
const prefetched = React.useRef(false);
|
|
3397
|
+
const drives = navigation === "client";
|
|
2698
3398
|
|
|
2699
3399
|
const doPrefetch = () => {
|
|
2700
|
-
if (prefetch === "off" || prefetched.current || isExternal(to)) {
|
|
3400
|
+
if (!drives || prefetch === "off" || prefetched.current || isExternal(to)) {
|
|
2701
3401
|
return;
|
|
2702
3402
|
}
|
|
2703
3403
|
prefetched.current = true;
|
|
@@ -2733,14 +3433,18 @@ export component Link(
|
|
|
2733
3433
|
});
|
|
2734
3434
|
};
|
|
2735
3435
|
|
|
3436
|
+
// The caller's own `onClick` still runs under document navigation — it is
|
|
3437
|
+
// theirs, and an application that closes a menu when a link is clicked is
|
|
3438
|
+
// not asking uf to take the navigation over — so it is passed through rather
|
|
3439
|
+
// than dropped with the rest of the behaviour.
|
|
2736
3440
|
return (
|
|
2737
3441
|
<a
|
|
2738
3442
|
{...rest}
|
|
2739
3443
|
href={to}
|
|
2740
3444
|
className={className}
|
|
2741
|
-
onClick={handleClick}
|
|
2742
|
-
onMouseEnter={prefetch === "intent" ? doPrefetch : undefined}
|
|
2743
|
-
onFocus={prefetch === "intent" ? doPrefetch : undefined}
|
|
3445
|
+
onClick={drives ? handleClick : onClick}
|
|
3446
|
+
onMouseEnter={drives && prefetch === "intent" ? doPrefetch : undefined}
|
|
3447
|
+
onFocus={drives && prefetch === "intent" ? doPrefetch : undefined}
|
|
2744
3448
|
>
|
|
2745
3449
|
{children}
|
|
2746
3450
|
</a>
|