@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.
@@ -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 [`loaderDataScript`]
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
- /** The props `RouteView` gives each layout, outermost first. */
94
- type LayoutRenderProps = {|
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 `_uf.loading.js` existed — a
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 `_uf.template.js` wrappers this route renders inside, root first.
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 `_uf.template.js`, as the route table carries it.
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 `_uf.loading.js`, as the route table carries it.
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
- * `_uf.not-found.js` is a segment file, so `path` is the route path of the
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 `_uf.not-found.js` there.
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 — `_uf.forbidden.js` and `_uf.unauthorized.js` beside
520
- * `_uf.error.js`, which is what Next.js does — is three files per segment to
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
- * `_uf.loading.js` and generates no metadata from its data — the two
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 `_uf.error.js` above the path, and
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 `_uf.loading.js` above it, which is the
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 `_uf.template.js` above it, which is the
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: ?RouteMatch = null;
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 `_uf.not-found.js` and `_uf.error.js` are resolved by, and
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
- // `_uf.loading.js` useful for the one case a page usually is not slow for.
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]/_uf.layout.js` would
1367
- * wrap a 404 that `app/guide/_uf.not-found.js` answered, which is the layout
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 `_uf.not-found.js` anywhere still has a record —
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 `_uf.error.js`.
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 `_uf.loading.js` fallback instead of leaving the
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
- if (!isBrowser()) {
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
- * `_uf.error.js`, placed at the depth the file sits at, so the layouts above
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 `_uf.loading.js` contributes no boundary at all — it is not wrapped
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 = <Suspense fallback={<Fallback />}>{element}</Suspense>;
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
- // `_uf.error.js` has the record the build synthesises for the router root,
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
- element = <Layout params={resolved.params}>{element}</Layout>;
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
- {loaderDataScript(data)}
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 `&quot;` 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/_uf.layout.js` carries the same suppression for the same reason.
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 loaderDataScript(data: mixed): React.Node {
2813
+ function payloadElements(data: mixed): React.Node {
2296
2814
  if (data === undefined) {
2297
2815
  return null;
2298
2816
  }
2299
- const json = JSON.stringify(data)
2300
- .replace(/</g, "\\u003c")
2301
- .replace(/\u2028/g, "\\u2028")
2302
- .replace(/\u2029/g, "\\u2029");
2303
- const html = { __html: json };
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
- return <script id={DATA_ID} type="application/json" dangerouslySetInnerHTML={html} />;
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 `_uf.layout.js`: React throws the subtree away and builds it again
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/_uf.layout.js` has the same shape for the
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 = useRouter();
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>