@uniflowed/vite 0.0.0-alpha.32 → 0.0.0-alpha.34
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/driver.js +3 -3
- package/index.js +10 -0
- package/internal/routes.js +459 -90
- package/internal/rsc.js +21 -3
- package/internal/serve.js +14 -14
- package/package.json +5 -5
package/driver.js
CHANGED
|
@@ -1356,9 +1356,9 @@ async function deploy() {
|
|
|
1356
1356
|
* out — `server.js` below does exactly that through
|
|
1357
1357
|
* `@uniflowed/server/node`, and a worker hands `settle` to `ctx.waitUntil`.
|
|
1358
1358
|
* It comes from the bundle rather than from the host's own
|
|
1359
|
-
* `@uniflowed/server`, because the request
|
|
1360
|
-
*
|
|
1361
|
-
*
|
|
1359
|
+
* `@uniflowed/server`, because the request store is shared only by copies of
|
|
1360
|
+
* one release of that package and the release the application reads is the one
|
|
1361
|
+
* inlined here. See ubugeeei-prod/uf#389.
|
|
1362
1362
|
*
|
|
1363
1363
|
* The document's script and stylesheet URLs are baked in here because they
|
|
1364
1364
|
* come from the client manifest, which exists at this moment and not in the
|
package/index.js
CHANGED
|
@@ -291,6 +291,12 @@ function flowPlugin({
|
|
|
291
291
|
const forgetActions = () => {
|
|
292
292
|
actionMemo = null;
|
|
293
293
|
};
|
|
294
|
+
const invalidateActionModules = (moduleGraph, modules) => {
|
|
295
|
+
for (const id of modules.keys()) {
|
|
296
|
+
const module = moduleGraph.getModuleById(id);
|
|
297
|
+
if (module) moduleGraph.invalidateModule(module);
|
|
298
|
+
}
|
|
299
|
+
};
|
|
294
300
|
const actionTables = () => {
|
|
295
301
|
const file = process.env[RSC_MANIFEST_ENV];
|
|
296
302
|
const key = rscManifestKey(file);
|
|
@@ -581,11 +587,15 @@ function flowPlugin({
|
|
|
581
587
|
devServer.watcher.add(manifestPath);
|
|
582
588
|
const onManifest = (file) => {
|
|
583
589
|
if (path.resolve(file) !== manifestPath) return;
|
|
590
|
+
const previousActions = actionTables().modules;
|
|
584
591
|
// The action tables are read from the same file and are memoised on
|
|
585
592
|
// its size and modification time, which is a pair two writes inside
|
|
586
593
|
// one millisecond can share. This is the answer that does not
|
|
587
594
|
// depend on a clock.
|
|
588
595
|
forgetActions();
|
|
596
|
+
const nextActions = actionTables().modules;
|
|
597
|
+
invalidateActionModules(devServer.moduleGraph, previousActions);
|
|
598
|
+
invalidateActionModules(devServer.moduleGraph, nextActions);
|
|
589
599
|
const routes = devServer.moduleGraph.getModuleById(resolved(VIRTUAL.routes));
|
|
590
600
|
if (routes) devServer.moduleGraph.invalidateModule(routes);
|
|
591
601
|
const actions = devServer.moduleGraph.getModuleById(resolved(VIRTUAL.actions));
|
package/internal/routes.js
CHANGED
|
@@ -39,23 +39,44 @@ export const RESERVED = Object.freeze({
|
|
|
39
39
|
* spelling one router refuses and the other serves as a URL is exactly the
|
|
40
40
|
* disagreement that made this necessary.
|
|
41
41
|
*
|
|
42
|
-
* `(.)photo`
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
* `RoutePath` union contained them, so `route("/@team", …)` type checked. A
|
|
42
|
+
* Until #267 neither `@team` nor `(.)photo` meant anything to this scan, so
|
|
43
|
+
* both fell through to "a literal URL segment": `@team` became `/@team`,
|
|
44
|
+
* `(.)photo` became `/(.)photo` — the test for a `(group)` is that the segment
|
|
45
|
+
* *ends* in `)` — and the generated `RoutePath` union contained them. A
|
|
47
46
|
* convention served as nonsense is worse than one that is refused, because the
|
|
48
47
|
* project looks like it works.
|
|
49
48
|
*
|
|
50
|
-
*
|
|
51
|
-
* interception
|
|
52
|
-
*
|
|
49
|
+
* Both are routes this router serves now: a slot everywhere, and an
|
|
50
|
+
* interception inside a slot; see {@link INTERCEPTION_SEGMENTS}. What is left
|
|
51
|
+
* here is what is spelled like an interception and cannot be one — a marker
|
|
52
|
+
* that climbs nowhere, or a marker with no URL segment after it.
|
|
53
53
|
*/
|
|
54
54
|
export const UNSUPPORTED_SEGMENTS = Object.freeze([
|
|
55
|
+
"(.)(.)photo",
|
|
56
|
+
"(.)(..)photo",
|
|
57
|
+
"(...)(..)photo",
|
|
58
|
+
"(....)photo",
|
|
59
|
+
"(.)(gallery)",
|
|
60
|
+
"(.)@photo",
|
|
61
|
+
]);
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Directory names this router serves inside a `@slot` and refuses outside one:
|
|
65
|
+
* intercepting routes.
|
|
66
|
+
*
|
|
67
|
+
* The same list as `uf_router::RouteSegment::SLOT_ONLY_EXAMPLES`, held to it by
|
|
68
|
+
* `crates/uf_router/tests/reserved_names.rs`. A second list rather than more
|
|
69
|
+
* entries on {@link UNSUPPORTED_SEGMENTS}, because the refusal is a different
|
|
70
|
+
* sentence: these are spelled correctly and are in the wrong place, and telling
|
|
71
|
+
* somebody to rename a correct directory is the worse of the two mistakes.
|
|
72
|
+
*/
|
|
73
|
+
export const INTERCEPTION_SEGMENTS = Object.freeze([
|
|
55
74
|
"(.)photo",
|
|
56
75
|
"(..)photo",
|
|
57
76
|
"(...)photo",
|
|
58
77
|
"(..)(..)photo",
|
|
78
|
+
"(..)(..)(..)photo",
|
|
79
|
+
"(.)[id]",
|
|
59
80
|
]);
|
|
60
81
|
|
|
61
82
|
/**
|
|
@@ -77,6 +98,16 @@ export const LAYOUT_PROP_NAMES = Object.freeze(["children", "params"]);
|
|
|
77
98
|
/** Template spellings that look conventional elsewhere but uf will not open. */
|
|
78
99
|
const UNSUPPORTED_TEMPLATE_FILES = Object.freeze(["template.js", "_uf.template.js"]);
|
|
79
100
|
|
|
101
|
+
/** Boundary spellings a slot might look for, but uf does not open. */
|
|
102
|
+
const UNSUPPORTED_SLOT_BOUNDARY_FILES = Object.freeze({
|
|
103
|
+
"error.js": "error",
|
|
104
|
+
"loading.js": "loading",
|
|
105
|
+
"not-found.js": "not-found",
|
|
106
|
+
"_uf.error.js": "error",
|
|
107
|
+
"_uf.loading.js": "loading",
|
|
108
|
+
"_uf.not-found.js": "not-found",
|
|
109
|
+
});
|
|
110
|
+
|
|
80
111
|
/** Extensions a page or layout may use; `.mdx` is a page written as content. */
|
|
81
112
|
const PAGE_EXTENSIONS = [".js", ".jsx", ".mdx"];
|
|
82
113
|
const MODULE_EXTENSIONS = [".js", ".jsx"];
|
|
@@ -172,23 +203,34 @@ const MAX_DEPTH = 32;
|
|
|
172
203
|
* renders nothing, which is what an unaddressed slot on a soft navigation does
|
|
173
204
|
* in Next.js too.
|
|
174
205
|
*
|
|
206
|
+
* `intercepts` is what the slot renders for a client navigation that starts on
|
|
207
|
+
* a page it is on and reaches the URL each entry names — the pages under an
|
|
208
|
+
* interception directory such as `@modal/(.)photo/[id]/`, each at the URL it
|
|
209
|
+
* stands in for. A list of its own, because nothing that matches `routes`
|
|
210
|
+
* may reach one: the server renders the ordinary page for that URL, always.
|
|
211
|
+
*
|
|
175
212
|
* @typedef {object} Slot
|
|
176
213
|
* @property {string} name the slot's name, without the `@`
|
|
177
214
|
* @property {number} above how many of the route's layouts are outside it
|
|
178
215
|
* @property {?string} defaultPage absolute path of `$default.*`, or `null`
|
|
179
216
|
* @property {boolean} defaultMdx whether that default is MDX content
|
|
217
|
+
* @property {?{above: number, module: string}} defaultErrorBoundary the
|
|
218
|
+
* `$error.js` boundary that catches the default page in the browser
|
|
180
219
|
* @property {ReadonlyArray<SlotRoute>} routes what the slot may render, by URL
|
|
220
|
+
* @property {ReadonlyArray<SlotRoute>} intercepts what the slot renders when a
|
|
221
|
+
* client navigation is intercepted, by the URL it stands in for
|
|
181
222
|
*/
|
|
182
223
|
|
|
183
224
|
/**
|
|
184
225
|
* One page inside a slot.
|
|
185
226
|
*
|
|
186
|
-
* A `Route` without the parts a slot does not have: no
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
*
|
|
227
|
+
* A `Route` without the request-level parts a slot does not have: no handler
|
|
228
|
+
* and no not-found boundary of its own. Loading boundaries, templates and
|
|
229
|
+
* browser render error boundaries are the pieces that compose like layouts, so
|
|
230
|
+
* they are carried below. Per-slot not-found boundaries are the part of
|
|
231
|
+
* parallel routes uf has not built — see
|
|
190
232
|
* https://github.com/ubugeeei-prod/uf/issues/267 — and {@link scanRoutes}
|
|
191
|
-
* refuses
|
|
233
|
+
* refuses those files rather than leaving them unopened.
|
|
192
234
|
*
|
|
193
235
|
* `layouts` are the layouts *inside* the slot, root first; the ones above it
|
|
194
236
|
* are already rendering, since the slot renders into one of them.
|
|
@@ -198,6 +240,12 @@ const MAX_DEPTH = 32;
|
|
|
198
240
|
* @property {ReadonlyArray<{name: string, catchAll: boolean}>} params
|
|
199
241
|
* @property {string} page absolute path of the page module
|
|
200
242
|
* @property {ReadonlyArray<string>} layouts absolute paths, slot root first
|
|
243
|
+
* @property {ReadonlyArray<{above: number, module: string}>} loading the
|
|
244
|
+
* `$loading.js` fallbacks inside the slot, root first
|
|
245
|
+
* @property {ReadonlyArray<{above: number, module: string}>} templates the
|
|
246
|
+
* `$template.js` wrappers inside the slot, root first
|
|
247
|
+
* @property {?{above: number, module: string}} errorBoundary the `$error.js`
|
|
248
|
+
* boundary inside the slot, if one is in scope
|
|
201
249
|
* @property {ReadonlyArray<Slot>} slots slots declared inside this slot
|
|
202
250
|
* @property {boolean} mdx whether the page is MDX content
|
|
203
251
|
*/
|
|
@@ -291,16 +339,17 @@ const MAX_DEPTH = 32;
|
|
|
291
339
|
* Directories that do not exist yield an empty table rather than an error: a
|
|
292
340
|
* library project has no router root, and that is not a mistake.
|
|
293
341
|
*
|
|
294
|
-
* Throws for a directory named the way an intercepting route is spelled: uf
|
|
295
|
-
* does not have interception, and the spelling used to become a literal URL
|
|
296
|
-
* segment. See {@link UNSUPPORTED_SEGMENTS}.
|
|
297
|
-
*
|
|
298
342
|
* A `@slot` directory is a parallel route and is scanned; see {@link Slot}. It
|
|
299
|
-
* throws for the
|
|
300
|
-
*
|
|
301
|
-
*
|
|
302
|
-
*
|
|
303
|
-
* about.
|
|
343
|
+
* throws for the ways one can be written without being renderable: a slot on a
|
|
344
|
+
* segment with no layout of its own, a `$default.js` that is not directly
|
|
345
|
+
* inside a slot, a not-found boundary or handler inside a slot, and
|
|
346
|
+
* boundary-like files with names uf does not open. Each is a file the router
|
|
347
|
+
* would otherwise never open, which is the failure #267 is about.
|
|
348
|
+
*
|
|
349
|
+
* An interception directory — `(.)photo` — is scanned inside a slot, into that
|
|
350
|
+
* slot's `intercepts`, and throws everywhere it cannot be one: outside a slot,
|
|
351
|
+
* spelled so nothing reads it ({@link UNSUPPORTED_SEGMENTS}), climbing past the
|
|
352
|
+
* router root, or standing in for a URL no page serves.
|
|
304
353
|
*
|
|
305
354
|
* @param {string} appRoot absolute path of the router root (`app/`)
|
|
306
355
|
* @param {{target?: "web" | "native" | "ios" | "android"}} [options]
|
|
@@ -464,8 +513,10 @@ export function scanRoutes(appRoot, options = {}) {
|
|
|
464
513
|
if (classifyRouteSegment(entry.name).kind === "slot") continue;
|
|
465
514
|
// Checked before descending, and after the private-directory test for
|
|
466
515
|
// the same reason `uf_router` prunes them: `app/_drafts/(.)photo/` is not
|
|
467
|
-
// a route uf would have served, so it is not one to refuse.
|
|
468
|
-
|
|
516
|
+
// a route uf would have served, so it is not one to refuse. This walk is
|
|
517
|
+
// everywhere a slot is not, so a correctly spelled interception is
|
|
518
|
+
// refused here too — for where it is rather than how it is written.
|
|
519
|
+
const refused = unsupportedSegmentReason(entry.name) ?? outsideSlotReason(entry.name);
|
|
469
520
|
if (refused != null) {
|
|
470
521
|
throw new Error(`${path.join(directory, entry.name)}: ${refused}`);
|
|
471
522
|
}
|
|
@@ -526,9 +577,88 @@ export function scanRoutes(appRoot, options = {}) {
|
|
|
526
577
|
// parallel-route trees uf does not have yet; see ubugeeei-prod/uf#267.
|
|
527
578
|
notFound.sort(byPath);
|
|
528
579
|
errors.sort(byPath);
|
|
580
|
+
// Last, because it is about the table rather than a directory: an
|
|
581
|
+
// intercepting page is only as good as the ordinary page that serves its URL
|
|
582
|
+
// to everybody the interception does not.
|
|
583
|
+
refuseInterceptionsWithoutPages(appRoot, routes);
|
|
529
584
|
return { routes, handlers, middleware, notFound, errors };
|
|
530
585
|
}
|
|
531
586
|
|
|
587
|
+
/**
|
|
588
|
+
* Refuse an intercepting route whose URL no page serves.
|
|
589
|
+
*
|
|
590
|
+
* An interception renders in its slot only for a client navigation that starts
|
|
591
|
+
* on a page the slot is on. Everybody else who arrives at the URL — a reload, a
|
|
592
|
+
* shared link, a crawler, the prerender — is given the page the URL names, and
|
|
593
|
+
* with none the photo a reader opened in a modal is a 404 the moment they reload
|
|
594
|
+
* it or send it to somebody. Mirrors `uf_router`'s `check_interceptions`.
|
|
595
|
+
*
|
|
596
|
+
* Each slot record is visited once, because a record is shared by every route
|
|
597
|
+
* under the segment that declares it.
|
|
598
|
+
*/
|
|
599
|
+
function refuseInterceptionsWithoutPages(appRoot, routes) {
|
|
600
|
+
const seen = new Set();
|
|
601
|
+
const visit = (slots) => {
|
|
602
|
+
for (const slot of slots) {
|
|
603
|
+
if (seen.has(slot)) continue;
|
|
604
|
+
seen.add(slot);
|
|
605
|
+
for (const intercepting of slot.intercepts ?? []) {
|
|
606
|
+
if (!routes.some((route) => servesEveryUrlOf(route.path, intercepting.path))) {
|
|
607
|
+
const directories = intercepting.path
|
|
608
|
+
.split("/")
|
|
609
|
+
.filter((part) => part !== "")
|
|
610
|
+
.map((part) =>
|
|
611
|
+
part.startsWith(":") && part.endsWith("*")
|
|
612
|
+
? `[...${part.slice(1, -1)}]`
|
|
613
|
+
: part.startsWith(":")
|
|
614
|
+
? `[${part.slice(1)}]`
|
|
615
|
+
: part,
|
|
616
|
+
);
|
|
617
|
+
const ordinary = path.join(appRoot, ...directories, `${RESERVED.page}.js`);
|
|
618
|
+
throw new Error(
|
|
619
|
+
`${intercepting.page}: this intercepting route stands in for \`${intercepting.path}\` ` +
|
|
620
|
+
"when a client navigation reaches it, and no page serves " +
|
|
621
|
+
`\`${intercepting.path}\`, so a reload of that URL, a link to it and the prerender ` +
|
|
622
|
+
`would all be a 404. Add \`${ordinary}\`, the page everybody who does not arrive by ` +
|
|
623
|
+
"that navigation gets, or remove the interception.",
|
|
624
|
+
);
|
|
625
|
+
}
|
|
626
|
+
visit(intercepting.slots);
|
|
627
|
+
}
|
|
628
|
+
for (const route of slot.routes) {
|
|
629
|
+
visit(route.slots);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
};
|
|
633
|
+
for (const route of routes) {
|
|
634
|
+
visit(route.slots ?? []);
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* Whether every URL the route path `intercepted` matches is one `ordinary`
|
|
640
|
+
* serves: segment by segment, the way the runtime's matcher reads both. A
|
|
641
|
+
* static segment serves only itself, a parameter any one segment but not a
|
|
642
|
+
* catch-all's many, and a catch-all whatever is left as long as something is.
|
|
643
|
+
* Mirrors `uf_router`'s `serves_every_url_of`.
|
|
644
|
+
*/
|
|
645
|
+
function servesEveryUrlOf(ordinary, intercepted) {
|
|
646
|
+
const theirs = ordinary.split("/").filter((part) => part !== "");
|
|
647
|
+
const ours = intercepted.split("/").filter((part) => part !== "");
|
|
648
|
+
for (let index = 0; index < theirs.length; index += 1) {
|
|
649
|
+
const segment = theirs[index];
|
|
650
|
+
if (segment.startsWith(":") && segment.endsWith("*")) return ours.length > index;
|
|
651
|
+
const other = ours[index];
|
|
652
|
+
if (other === undefined) return false;
|
|
653
|
+
const otherIsCatchAll = other.startsWith(":") && other.endsWith("*");
|
|
654
|
+
const serves = segment.startsWith(":")
|
|
655
|
+
? !otherIsCatchAll
|
|
656
|
+
: !other.startsWith(":") && other === segment;
|
|
657
|
+
if (!serves) return false;
|
|
658
|
+
}
|
|
659
|
+
return ours.length === theirs.length;
|
|
660
|
+
}
|
|
661
|
+
|
|
532
662
|
/**
|
|
533
663
|
* One `@slot` directory, scanned into a {@link Slot}.
|
|
534
664
|
*
|
|
@@ -580,14 +710,35 @@ function scanSlot(parent, directoryName, name, segments, ownLayout, above, targe
|
|
|
580
710
|
}
|
|
581
711
|
|
|
582
712
|
const routes = [];
|
|
713
|
+
// What the slot renders *instead of* the page a client navigation reaches:
|
|
714
|
+
// the pages under an interception directory. A list of its own rather than
|
|
715
|
+
// more `routes`, because `routes` is matched against every URL the segment
|
|
716
|
+
// renders — by the server as much as the browser — and nothing but a
|
|
717
|
+
// navigation that starts on a page this slot is on may render one of these.
|
|
718
|
+
const intercepts = [];
|
|
583
719
|
const defaultPage = findModule(directory, RESERVED.default, PAGE_EXTENSIONS, target);
|
|
584
|
-
|
|
585
|
-
|
|
720
|
+
const defaultError = findModule(directory, RESERVED.error, MODULE_EXTENSIONS, target);
|
|
721
|
+
|
|
722
|
+
const walkSlot = (
|
|
723
|
+
current,
|
|
724
|
+
currentSegments,
|
|
725
|
+
layouts,
|
|
726
|
+
loading,
|
|
727
|
+
templates,
|
|
728
|
+
errorBoundary,
|
|
729
|
+
atSlotRoot,
|
|
730
|
+
intercepting,
|
|
731
|
+
currentDepth,
|
|
732
|
+
) => {
|
|
586
733
|
if (currentDepth > MAX_DEPTH) return;
|
|
734
|
+
const entries = readdirSync(current, { withFileTypes: true }).sort((a, b) =>
|
|
735
|
+
a.name < b.name ? -1 : a.name > b.name ? 1 : 0,
|
|
736
|
+
);
|
|
737
|
+
|
|
587
738
|
// What a slot does not have, said where somebody writing the file will
|
|
588
|
-
// read it rather than by never opening it.
|
|
589
|
-
//
|
|
590
|
-
for (const role of [RESERVED.notFound
|
|
739
|
+
// read it rather than by never opening it. Loading and error boundaries
|
|
740
|
+
// compose like layouts, so they are carried below instead of refused here.
|
|
741
|
+
for (const role of [RESERVED.notFound]) {
|
|
591
742
|
const found =
|
|
592
743
|
findModule(current, role, MODULE_EXTENSIONS, target) ??
|
|
593
744
|
findModule(current, role, PAGE_EXTENSIONS, target);
|
|
@@ -595,12 +746,24 @@ function scanSlot(parent, directoryName, name, segments, ownLayout, above, targe
|
|
|
595
746
|
throw new Error(
|
|
596
747
|
`${found}: a \`@slot\` renders a page and the layouts inside the slot, and has no ` +
|
|
597
748
|
`\`${role.slice("$".length)}\` of its own — uf's parallel routes do not carry ` +
|
|
598
|
-
"per-slot boundaries yet, so this file would never be opened. Put it outside " +
|
|
749
|
+
"per-slot not-found boundaries yet, so this file would never be opened. Put it outside " +
|
|
599
750
|
`\`${directoryName}\`, where it covers the whole segment. ` +
|
|
600
751
|
"https://github.com/ubugeeei-prod/uf/issues/267",
|
|
601
752
|
);
|
|
602
753
|
}
|
|
603
754
|
}
|
|
755
|
+
for (const entry of entries) {
|
|
756
|
+
if (!entry.isFile()) continue;
|
|
757
|
+
const role = unsupportedSlotBoundaryRole(entry.name);
|
|
758
|
+
if (role == null) continue;
|
|
759
|
+
const file = path.join(current, entry.name);
|
|
760
|
+
throw new Error(
|
|
761
|
+
`${file}: \`${entry.name}\` looks like a \`${role}\` boundary for a \`@slot\`, but it ` +
|
|
762
|
+
"is not a uf route file there. Use `$loading.js` for slot loading and `$error.js` " +
|
|
763
|
+
"for slot errors; per-slot not-found boundaries are still not implemented. " +
|
|
764
|
+
"https://github.com/ubugeeei-prod/uf/issues/267",
|
|
765
|
+
);
|
|
766
|
+
}
|
|
604
767
|
for (const role of [RESERVED.route, RESERVED.middleware]) {
|
|
605
768
|
const found = findModule(current, role, MODULE_EXTENSIONS, target);
|
|
606
769
|
if (found != null) {
|
|
@@ -626,10 +789,18 @@ function scanSlot(parent, directoryName, name, segments, ownLayout, above, targe
|
|
|
626
789
|
|
|
627
790
|
const layoutHere = findModule(current, RESERVED.layout, MODULE_EXTENSIONS, target);
|
|
628
791
|
const nextLayouts = layoutHere ? [...layouts, layoutHere] : layouts;
|
|
629
|
-
|
|
630
|
-
const
|
|
631
|
-
|
|
632
|
-
|
|
792
|
+
const loadingHere = findModule(current, RESERVED.loading, MODULE_EXTENSIONS, target);
|
|
793
|
+
const nextLoading = loadingHere
|
|
794
|
+
? [...loading, { above: nextLayouts.length, module: loadingHere }]
|
|
795
|
+
: loading;
|
|
796
|
+
const templateHere = findModule(current, RESERVED.template, MODULE_EXTENSIONS, target);
|
|
797
|
+
const nextTemplates = templateHere
|
|
798
|
+
? [...templates, { above: nextLayouts.length, module: templateHere }]
|
|
799
|
+
: templates;
|
|
800
|
+
const errorHere = findModule(current, RESERVED.error, MODULE_EXTENSIONS, target);
|
|
801
|
+
const nextErrorBoundary = errorHere
|
|
802
|
+
? { above: nextLayouts.length, module: errorHere }
|
|
803
|
+
: errorBoundary;
|
|
633
804
|
|
|
634
805
|
refuseUnsupportedTemplateFiles(current, entries);
|
|
635
806
|
|
|
@@ -656,12 +827,18 @@ function scanSlot(parent, directoryName, name, segments, ownLayout, above, targe
|
|
|
656
827
|
|
|
657
828
|
const page = findModule(current, RESERVED.page, PAGE_EXTENSIONS, target);
|
|
658
829
|
if (page) {
|
|
830
|
+
// Under an interception directory the path is the URL the page stands in
|
|
831
|
+
// for — `routeFromSegments` applies the climb — and the page goes in the
|
|
832
|
+
// slot's other list.
|
|
659
833
|
const { path: routePath, params } = routeFromSegments(currentSegments);
|
|
660
|
-
routes.push({
|
|
834
|
+
(intercepting ? intercepts : routes).push({
|
|
661
835
|
path: routePath,
|
|
662
836
|
params,
|
|
663
837
|
page,
|
|
664
838
|
layouts: nextLayouts,
|
|
839
|
+
loading: nextLoading,
|
|
840
|
+
templates: nextTemplates,
|
|
841
|
+
errorBoundary: nextErrorBoundary,
|
|
665
842
|
slots: nestedSlots,
|
|
666
843
|
mdx: page.endsWith(".mdx"),
|
|
667
844
|
});
|
|
@@ -672,30 +849,59 @@ function scanSlot(parent, directoryName, name, segments, ownLayout, above, targe
|
|
|
672
849
|
if (entry.name.startsWith(".") || entry.name.startsWith("_")) continue;
|
|
673
850
|
const classified = classifyRouteSegment(entry.name);
|
|
674
851
|
if (classified.kind === "slot") continue;
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
852
|
+
if (classified.kind === "interception") {
|
|
853
|
+
// Inside a slot, so the place is right. What is left to refuse is a
|
|
854
|
+
// spelling nothing reads and a climb past the router root, and the
|
|
855
|
+
// depth is what the directories above it really contribute, climbs
|
|
856
|
+
// applied.
|
|
857
|
+
const depthHere = routeFromSegments(currentSegments)
|
|
858
|
+
.path.split("/")
|
|
859
|
+
.filter((part) => part !== "").length;
|
|
860
|
+
const refused = unsupportedSegmentReason(entry.name) ?? climbReason(entry.name, depthHere);
|
|
861
|
+
if (refused != null) {
|
|
862
|
+
throw new Error(`${path.join(current, entry.name)}: ${refused}`);
|
|
863
|
+
}
|
|
678
864
|
}
|
|
679
865
|
walkSlot(
|
|
680
866
|
path.join(current, entry.name),
|
|
681
867
|
[...currentSegments, entry.name],
|
|
682
868
|
nextLayouts,
|
|
869
|
+
nextLoading,
|
|
870
|
+
nextTemplates,
|
|
871
|
+
nextErrorBoundary,
|
|
683
872
|
false,
|
|
873
|
+
intercepting || classified.kind === "interception",
|
|
684
874
|
currentDepth + 1,
|
|
685
875
|
);
|
|
686
876
|
}
|
|
687
877
|
};
|
|
688
878
|
|
|
689
|
-
|
|
879
|
+
// The slot's own directory is in the segments from here down. It adds nothing
|
|
880
|
+
// to a path, and it is how `routeFromSegments` knows that an interception
|
|
881
|
+
// below it is inside a slot.
|
|
882
|
+
walkSlot(
|
|
883
|
+
directory,
|
|
884
|
+
[...segments, directoryName],
|
|
885
|
+
[],
|
|
886
|
+
[],
|
|
887
|
+
[],
|
|
888
|
+
defaultError == null ? null : { above: 0, module: defaultError },
|
|
889
|
+
true,
|
|
890
|
+
false,
|
|
891
|
+
depth + 1,
|
|
892
|
+
);
|
|
690
893
|
|
|
691
894
|
const byPath = (a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
|
|
692
895
|
routes.sort(byPath);
|
|
896
|
+
intercepts.sort(byPath);
|
|
693
897
|
return {
|
|
694
898
|
name,
|
|
695
899
|
above,
|
|
696
900
|
defaultPage,
|
|
697
901
|
defaultMdx: defaultPage != null && defaultPage.endsWith(".mdx"),
|
|
902
|
+
defaultErrorBoundary: defaultError == null ? null : { above: 0, module: defaultError },
|
|
698
903
|
routes,
|
|
904
|
+
intercepts,
|
|
699
905
|
};
|
|
700
906
|
}
|
|
701
907
|
|
|
@@ -740,6 +946,10 @@ function unsupportedTemplateFileReason(fileName) {
|
|
|
740
946
|
);
|
|
741
947
|
}
|
|
742
948
|
|
|
949
|
+
function unsupportedSlotBoundaryRole(fileName) {
|
|
950
|
+
return UNSUPPORTED_SLOT_BOUNDARY_FILES[fileName] ?? null;
|
|
951
|
+
}
|
|
952
|
+
|
|
743
953
|
/**
|
|
744
954
|
* What one directory name means to the route path.
|
|
745
955
|
*
|
|
@@ -774,10 +984,12 @@ export function classifyRouteSegment(segment) {
|
|
|
774
984
|
/**
|
|
775
985
|
* The `(.)`-style prefix of `segment` and the route after it, or `null`.
|
|
776
986
|
*
|
|
777
|
-
* One or more
|
|
778
|
-
*
|
|
779
|
-
*
|
|
780
|
-
*
|
|
987
|
+
* One or more parenthesised runs of dots, followed by something for them to
|
|
988
|
+
* intercept. Which runs *mean* anything is {@link interceptionClimb}'s question
|
|
989
|
+
* and deliberately not this one: `(....)photo` is the shape of an interception
|
|
990
|
+
* written by somebody who miscounted, and reading it as a literal URL segment is
|
|
991
|
+
* how the miscount becomes a page at `/(....)photo`. A marker with nothing after
|
|
992
|
+
* it names no route and is the `(group)` it has always been.
|
|
781
993
|
*/
|
|
782
994
|
function interceptionMarker(segment) {
|
|
783
995
|
let consumed = 0;
|
|
@@ -785,7 +997,7 @@ function interceptionMarker(segment) {
|
|
|
785
997
|
const close = segment.indexOf(")", consumed);
|
|
786
998
|
if (close === -1) break;
|
|
787
999
|
const inner = segment.slice(consumed + 1, close);
|
|
788
|
-
if (inner.length === 0 ||
|
|
1000
|
+
if (inner.length === 0 || /[^.]/.test(inner)) break;
|
|
789
1001
|
consumed = close + 1;
|
|
790
1002
|
}
|
|
791
1003
|
if (consumed === 0 || consumed === segment.length) return null;
|
|
@@ -793,28 +1005,124 @@ function interceptionMarker(segment) {
|
|
|
793
1005
|
}
|
|
794
1006
|
|
|
795
1007
|
/**
|
|
796
|
-
*
|
|
1008
|
+
* How far a marker climbs, in URL segments: a number for `(.)` and `(..)`
|
|
1009
|
+
* repeated, `"root"` for `(...)`, and `null` for a marker uf does not read.
|
|
1010
|
+
*
|
|
1011
|
+
* Mirrors `uf_router::interception_climb`. `(.)` and `(...)` only as the whole
|
|
1012
|
+
* marker, because each already says where the climb ends; `(..)` as many times
|
|
1013
|
+
* as there are levels to climb.
|
|
1014
|
+
*
|
|
1015
|
+
* @param {string} marker
|
|
1016
|
+
* @returns {number | "root" | null}
|
|
1017
|
+
*/
|
|
1018
|
+
export function interceptionClimb(marker) {
|
|
1019
|
+
const runs = marker.match(/\(\.+\)/g) ?? [];
|
|
1020
|
+
if (runs.length === 0 || runs.join("") !== marker) return null;
|
|
1021
|
+
if (runs.length === 1 && runs[0] === "(.)") return 0;
|
|
1022
|
+
if (runs.length === 1 && runs[0] === "(...)") return "root";
|
|
1023
|
+
return runs.every((run) => run === "(..)") ? runs.length : null;
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
/**
|
|
1027
|
+
* The interception a classified directory name is, when it is one uf reads: a
|
|
1028
|
+
* marker that climbs, with a URL segment after it to stand in for.
|
|
1029
|
+
*
|
|
1030
|
+
* @returns {{climb: number | "root", route: {kind: string, name: string}} | null}
|
|
1031
|
+
*/
|
|
1032
|
+
function readInterception(classified) {
|
|
1033
|
+
if (classified.kind !== "interception") return null;
|
|
1034
|
+
const climb = interceptionClimb(classified.marker);
|
|
1035
|
+
const route = classifyRouteSegment(classified.route);
|
|
1036
|
+
if (climb == null) return null;
|
|
1037
|
+
if (route.kind !== "literal" && route.kind !== "param" && route.kind !== "catchAll") {
|
|
1038
|
+
return null;
|
|
1039
|
+
}
|
|
1040
|
+
return { climb, route };
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
/**
|
|
1044
|
+
* Why uf refuses a directory named `segment` wherever it is, or `null` when it
|
|
1045
|
+
* serves it somewhere.
|
|
797
1046
|
*
|
|
798
1047
|
* The message is this router's own rather than `uf_router`'s, because the two
|
|
799
1048
|
* are reached differently: the Rust one fails `uf build` and `uf dev` through
|
|
800
1049
|
* the route manifest, and this one fails a project driving Vite itself. Both
|
|
801
|
-
* say the same
|
|
802
|
-
*
|
|
1050
|
+
* say the same things — what is wrong with the spelling, and that it is refused
|
|
1051
|
+
* rather than served as a URL.
|
|
1052
|
+
*
|
|
1053
|
+
* A correctly spelled interception has two more refusals, about where it is
|
|
1054
|
+
* rather than how it is written: {@link outsideSlotReason} and `climbReason`.
|
|
803
1055
|
*/
|
|
804
1056
|
export function unsupportedSegmentReason(segment) {
|
|
805
1057
|
const classified = classifyRouteSegment(segment);
|
|
806
|
-
if (classified.kind
|
|
1058
|
+
if (classified.kind !== "interception") return null;
|
|
1059
|
+
const { marker, route } = classified;
|
|
1060
|
+
if (interceptionClimb(marker) == null) {
|
|
1061
|
+
return (
|
|
1062
|
+
`\`${segment}\` is spelled like an intercepting route and \`${marker}\` is not a marker uf ` +
|
|
1063
|
+
"reads. The markers are `(.)` for the level the directory is at, `(..)` for one above it — " +
|
|
1064
|
+
"repeated for each further level — and `(...)` for the router root. It is refused rather " +
|
|
1065
|
+
`than served as the URL segment \`/${segment}\`, which is what it used to become. Spell the ` +
|
|
1066
|
+
"marker as one of those and put the directory inside a `@slot`, or rename it to the literal " +
|
|
1067
|
+
`segment \`${route}\`. https://github.com/ubugeeei-prod/uf/issues/267`
|
|
1068
|
+
);
|
|
1069
|
+
}
|
|
1070
|
+
if (readInterception(classified) == null) {
|
|
807
1071
|
return (
|
|
808
|
-
`\`${segment}\` is an intercepting route, and
|
|
809
|
-
"
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
"
|
|
1072
|
+
`\`${segment}\` is spelled like an intercepting route, and \`${route}\` after the marker is ` +
|
|
1073
|
+
"not a URL segment, so there is no path for it to intercept: an interception names the " +
|
|
1074
|
+
`segment it stands in for, the way \`${marker}photo\` and \`${marker}[id]\` do. It is ` +
|
|
1075
|
+
`refused rather than served as the URL segment \`/${segment}\`, which is what it used to ` +
|
|
1076
|
+
"become. Put a segment name after the marker, or rename the directory. " +
|
|
1077
|
+
"https://github.com/ubugeeei-prod/uf/issues/267"
|
|
813
1078
|
);
|
|
814
1079
|
}
|
|
815
1080
|
return null;
|
|
816
1081
|
}
|
|
817
1082
|
|
|
1083
|
+
/**
|
|
1084
|
+
* Why uf refuses the intercepting route `segment` outside a `@slot`, where it
|
|
1085
|
+
* would serve it inside one; `null` for any other directory name.
|
|
1086
|
+
*
|
|
1087
|
+
* A sentence of its own rather than another case of
|
|
1088
|
+
* {@link unsupportedSegmentReason}, because this one is spelled correctly and
|
|
1089
|
+
* placed wrongly, and telling its author to rename it would be wrong.
|
|
1090
|
+
*/
|
|
1091
|
+
export function outsideSlotReason(segment) {
|
|
1092
|
+
const classified = classifyRouteSegment(segment);
|
|
1093
|
+
if (readInterception(classified) == null) return null;
|
|
1094
|
+
return (
|
|
1095
|
+
`\`${segment}\` is an intercepting route, and an intercepting route renders into a \`@slot\`: ` +
|
|
1096
|
+
"it is what a client navigation shows in a named place instead of the page its URL names, " +
|
|
1097
|
+
"and outside a slot there is no named place for it to show in. It is refused rather than " +
|
|
1098
|
+
`served as the URL segment \`/${segment}\`, which is what it used to become. Move it inside a ` +
|
|
1099
|
+
"slot directory beside the layout that renders the slot, or rename the directory to the " +
|
|
1100
|
+
`literal segment \`${classified.route}\`. https://github.com/ubugeeei-prod/uf/issues/267`
|
|
1101
|
+
);
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
/**
|
|
1105
|
+
* Why an interception `depth` URL segments below the router root climbs past
|
|
1106
|
+
* it, or `null` when it does not. Mirrors `RouteSegment::climb_reason`.
|
|
1107
|
+
*/
|
|
1108
|
+
function climbReason(segment, depth) {
|
|
1109
|
+
const read = readInterception(classifyRouteSegment(segment));
|
|
1110
|
+
if (read == null || read.climb === "root" || read.climb <= depth) return null;
|
|
1111
|
+
const climbs = read.climb === 1 ? "one level" : `${read.climb} levels`;
|
|
1112
|
+
const sits =
|
|
1113
|
+
depth === 0
|
|
1114
|
+
? "at the router root"
|
|
1115
|
+
: depth === 1
|
|
1116
|
+
? "one level below it"
|
|
1117
|
+
: `${depth} levels below it`;
|
|
1118
|
+
return (
|
|
1119
|
+
`\`${segment}\` climbs ${climbs} from the directory it is in, which is ${sits}, so the URL it ` +
|
|
1120
|
+
"intercepts would be above the router root, and there is no such URL. It is refused rather " +
|
|
1121
|
+
"than read as a climb to the root. Remove a `(..)`, or write `(...)` to intercept from the " +
|
|
1122
|
+
"router root. https://github.com/ubugeeei-prod/uf/issues/267"
|
|
1123
|
+
);
|
|
1124
|
+
}
|
|
1125
|
+
|
|
818
1126
|
/**
|
|
819
1127
|
* Turn directory segments into a route path and its parameters.
|
|
820
1128
|
*
|
|
@@ -822,29 +1130,48 @@ export function unsupportedSegmentReason(segment) {
|
|
|
822
1130
|
* captures one segment, and `[...name]` captures the rest of the path. A
|
|
823
1131
|
* `@slot` contributes nothing either — it is a named place a route renders
|
|
824
1132
|
* into, matched against the URL of the segment that declares it — so a slot's
|
|
825
|
-
* pages are matched against ordinary paths and add none of their own.
|
|
826
|
-
*
|
|
827
|
-
*
|
|
1133
|
+
* pages are matched against ordinary paths and add none of their own.
|
|
1134
|
+
*
|
|
1135
|
+
* An intercepting route is where "one directory, one segment" stops holding.
|
|
1136
|
+
* Inside a slot, `(..)photo` takes a segment *away* before it adds its own, so
|
|
1137
|
+
* `["feed", "@modal", "(..)photo", "[id]"]` is `/photo/:id`: the URL the
|
|
1138
|
+
* interception stands in for. That is `uf_router`'s `path_segments`, spelled
|
|
1139
|
+
* again. Wherever an interception cannot be — outside a slot, climbing past the
|
|
1140
|
+
* root, written so nothing reads it — this throws the refusal
|
|
1141
|
+
* {@link scanRoutes} would give the directory rather than build a URL from it.
|
|
828
1142
|
*/
|
|
829
1143
|
export function routeFromSegments(segments) {
|
|
830
|
-
|
|
831
|
-
|
|
1144
|
+
let out = [];
|
|
1145
|
+
let insideSlot = false;
|
|
832
1146
|
for (const segment of segments) {
|
|
833
1147
|
const classified = classifyRouteSegment(segment);
|
|
834
|
-
if (classified.kind === "group"
|
|
835
|
-
if (classified.kind === "
|
|
836
|
-
|
|
837
|
-
out.push(`:${classified.name}*`);
|
|
1148
|
+
if (classified.kind === "group") continue;
|
|
1149
|
+
if (classified.kind === "slot") {
|
|
1150
|
+
insideSlot = true;
|
|
838
1151
|
continue;
|
|
839
1152
|
}
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
1153
|
+
let named = classified;
|
|
1154
|
+
if (classified.kind === "interception") {
|
|
1155
|
+
const refused =
|
|
1156
|
+
unsupportedSegmentReason(segment) ??
|
|
1157
|
+
(insideSlot ? climbReason(segment, out.length) : outsideSlotReason(segment));
|
|
1158
|
+
if (refused != null) {
|
|
1159
|
+
throw new Error(refused);
|
|
1160
|
+
}
|
|
1161
|
+
const read = readInterception(classified);
|
|
1162
|
+
out = read.climb === "root" ? [] : out.slice(0, out.length - read.climb);
|
|
1163
|
+
named = read.route;
|
|
1164
|
+
}
|
|
1165
|
+
if (named.kind === "catchAll") {
|
|
1166
|
+
out.push({ spelling: `:${named.name}*`, param: { name: named.name, catchAll: true } });
|
|
1167
|
+
} else if (named.kind === "param") {
|
|
1168
|
+
out.push({ spelling: `:${named.name}`, param: { name: named.name, catchAll: false } });
|
|
1169
|
+
} else {
|
|
1170
|
+
out.push({ spelling: named.name, param: null });
|
|
844
1171
|
}
|
|
845
|
-
out.push(segment);
|
|
846
1172
|
}
|
|
847
|
-
const routePath = out.length === 0 ? "/" : `/${out.join("/")}`;
|
|
1173
|
+
const routePath = out.length === 0 ? "/" : `/${out.map((entry) => entry.spelling).join("/")}`;
|
|
1174
|
+
const params = out.flatMap((entry) => (entry.param == null ? [] : [entry.param]));
|
|
848
1175
|
return { path: routePath, pattern: routePath.replace(/:(\w+)\*/g, "*$1"), params };
|
|
849
1176
|
}
|
|
850
1177
|
|
|
@@ -1020,44 +1347,75 @@ export function routesModuleSource(table, options = {}) {
|
|
|
1020
1347
|
const slotIds = new Map();
|
|
1021
1348
|
const slotDefinitions = [];
|
|
1022
1349
|
const slotFiles = new Set();
|
|
1023
|
-
const
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1350
|
+
const slotErrorBoundary = (boundary) =>
|
|
1351
|
+
boundary == null
|
|
1352
|
+
? "null"
|
|
1353
|
+
: `{ above: ${boundary.above}, module: () => import(${JSON.stringify(boundary.module)}) }`;
|
|
1354
|
+
// One route a slot may render, as source. The same shape for a slot's own
|
|
1355
|
+
// routes and for its interceptions, because an intercepting page is composed
|
|
1356
|
+
// exactly the way every other page in the slot is.
|
|
1357
|
+
const slotRoute = (route) => {
|
|
1358
|
+
slotFiles.add(route.page);
|
|
1359
|
+
for (const file of route.layouts) slotFiles.add(file);
|
|
1360
|
+
for (const entry of route.loading ?? []) slotFiles.add(entry.module);
|
|
1361
|
+
const nested = route.slots.map(slotId);
|
|
1362
|
+
if (route.errorBoundary != null) slotFiles.add(route.errorBoundary.module);
|
|
1363
|
+
return ` {
|
|
1033
1364
|
path: ${JSON.stringify(route.path)},
|
|
1034
1365
|
params: ${JSON.stringify(route.params)},
|
|
1035
1366
|
mdx: ${route.mdx},
|
|
1036
1367
|
file: ${JSON.stringify(displayFile(route.page))},
|
|
1037
1368
|
page: () => import(${JSON.stringify(route.page)}),
|
|
1038
1369
|
layouts: [${route.layouts.map(layoutId).join(", ")}],
|
|
1370
|
+
loading: [${(route.loading ?? [])
|
|
1371
|
+
.map((boundary) => `{ above: ${boundary.above}, module: ${loadingId(boundary.module)} }`)
|
|
1372
|
+
.join(", ")}],
|
|
1373
|
+
templates: [${(route.templates ?? [])
|
|
1374
|
+
.map((entry) => `{ above: ${entry.above}, module: ${templateId(entry.module)} }`)
|
|
1375
|
+
.join(", ")}],
|
|
1376
|
+
errorBoundary: ${slotErrorBoundary(route.errorBoundary ?? null)},
|
|
1039
1377
|
slots: [${nested.join(", ")}],
|
|
1040
1378
|
}`;
|
|
1041
|
-
|
|
1379
|
+
};
|
|
1380
|
+
const slotId = (slot) => {
|
|
1381
|
+
let id = slotIds.get(slot);
|
|
1382
|
+
if (id !== undefined) {
|
|
1383
|
+
return id;
|
|
1384
|
+
}
|
|
1385
|
+
const routes = slot.routes.map(slotRoute);
|
|
1386
|
+
const intercepts = (slot.intercepts ?? []).map(slotRoute);
|
|
1042
1387
|
// After the routes, so a nested slot's `const` is already emitted.
|
|
1043
1388
|
id = `slot${slotIds.size}`;
|
|
1044
1389
|
slotIds.set(slot, id);
|
|
1045
1390
|
if (slot.defaultPage != null) {
|
|
1046
1391
|
slotFiles.add(slot.defaultPage);
|
|
1047
1392
|
}
|
|
1393
|
+
if (slot.defaultErrorBoundary != null) {
|
|
1394
|
+
slotFiles.add(slot.defaultErrorBoundary.module);
|
|
1395
|
+
}
|
|
1048
1396
|
const fallback =
|
|
1049
1397
|
slot.defaultPage == null
|
|
1050
1398
|
? " defaultPage: null,"
|
|
1051
1399
|
: ` defaultPage: () => import(${JSON.stringify(slot.defaultPage)}),
|
|
1052
1400
|
defaultFile: ${JSON.stringify(displayFile(slot.defaultPage))},`;
|
|
1401
|
+
// Only when there is one, so a slot that intercepts nothing is emitted byte
|
|
1402
|
+
// for byte the way it was before interception existed.
|
|
1403
|
+
const intercepting =
|
|
1404
|
+
intercepts.length === 0
|
|
1405
|
+
? ""
|
|
1406
|
+
: `
|
|
1407
|
+
intercepts: [
|
|
1408
|
+
${intercepts.join(",\n")}
|
|
1409
|
+
],`;
|
|
1053
1410
|
slotDefinitions.push(`const ${id} = {
|
|
1054
1411
|
name: ${JSON.stringify(slot.name)},
|
|
1055
1412
|
above: ${slot.above},
|
|
1056
1413
|
${fallback}
|
|
1057
1414
|
defaultMdx: ${slot.defaultMdx},
|
|
1415
|
+
defaultErrorBoundary: ${slotErrorBoundary(slot.defaultErrorBoundary ?? null)},
|
|
1058
1416
|
routes: [
|
|
1059
1417
|
${routes.join(",\n")}
|
|
1060
|
-
]
|
|
1418
|
+
],${intercepting}
|
|
1061
1419
|
};`);
|
|
1062
1420
|
return id;
|
|
1063
1421
|
};
|
|
@@ -1221,8 +1579,18 @@ function slotModuleFiles(slots) {
|
|
|
1221
1579
|
const files = [];
|
|
1222
1580
|
for (const slot of slots) {
|
|
1223
1581
|
if (slot.defaultPage != null) files.push(slot.defaultPage);
|
|
1224
|
-
|
|
1225
|
-
|
|
1582
|
+
if (slot.defaultErrorBoundary != null) files.push(slot.defaultErrorBoundary.module);
|
|
1583
|
+
// An interception's modules with the slot's own: its stylesheet is part of
|
|
1584
|
+
// what the page looks like when a navigation opens it over this route.
|
|
1585
|
+
for (const route of [...slot.routes, ...(slot.intercepts ?? [])]) {
|
|
1586
|
+
files.push(
|
|
1587
|
+
route.page,
|
|
1588
|
+
...route.layouts,
|
|
1589
|
+
...(route.loading ?? []).map((it) => it.module),
|
|
1590
|
+
...(route.templates ?? []).map((it) => it.module),
|
|
1591
|
+
...(route.errorBoundary == null ? [] : [route.errorBoundary.module]),
|
|
1592
|
+
...slotModuleFiles(route.slots),
|
|
1593
|
+
);
|
|
1226
1594
|
}
|
|
1227
1595
|
}
|
|
1228
1596
|
return files;
|
|
@@ -1330,14 +1698,15 @@ ${mount}({ App, routes, notFound, errors${strictMode}${navigation} });
|
|
|
1330
1698
|
* has nothing to do with a document that is no route's.
|
|
1331
1699
|
*
|
|
1332
1700
|
* `beginRequest` is the fourth, and it is re-exported rather than imported by
|
|
1333
|
-
* the host for a reason that is easy to get wrong: `@uniflowed/server`
|
|
1334
|
-
*
|
|
1335
|
-
* application has its own copy
|
|
1336
|
-
*
|
|
1337
|
-
*
|
|
1338
|
-
*
|
|
1339
|
-
* the
|
|
1340
|
-
* take it from here; see
|
|
1701
|
+
* the host for a reason that is easy to get wrong: `@uniflowed/server` shares
|
|
1702
|
+
* its request store between copies of one *release* of itself, and a bundled
|
|
1703
|
+
* application has its own copy inlined. A host that imported `beginRequest`
|
|
1704
|
+
* from its own `node_modules` could be holding another release, would
|
|
1705
|
+
* establish a request in a store the application never reads, and every
|
|
1706
|
+
* `cookies()` in the application would still be outside one. So the bundle
|
|
1707
|
+
* hands the host the entry point that belongs to the bundle. `uf preview`,
|
|
1708
|
+
* `uf start`, `uf dev` and the compiled binary all take it from here; see
|
|
1709
|
+
* ubugeeei-prod/uf#389.
|
|
1341
1710
|
*
|
|
1342
1711
|
* Through `@uniflowed/router/server` rather than `@uniflowed/server/host`,
|
|
1343
1712
|
* because this source is resolved from the *project's* directory and a project
|
package/internal/rsc.js
CHANGED
|
@@ -187,7 +187,13 @@ export function clientRouteFilter(manifest, root, boundaries = {}) {
|
|
|
187
187
|
*
|
|
188
188
|
* @param {ReadonlyArray<{
|
|
189
189
|
* defaultPage: ?string,
|
|
190
|
-
* routes: ReadonlyArray<{
|
|
190
|
+
* routes: ReadonlyArray<{
|
|
191
|
+
* page: string,
|
|
192
|
+
* layouts: ReadonlyArray<string>,
|
|
193
|
+
* loading?: ReadonlyArray<{module: string}>,
|
|
194
|
+
* templates?: ReadonlyArray<{module: string}>,
|
|
195
|
+
* slots: ReadonlyArray<*>,
|
|
196
|
+
* }>,
|
|
191
197
|
* @param {(file: ?string) => boolean} needed
|
|
192
198
|
* @returns {(slot: *) => boolean}
|
|
193
199
|
*/
|
|
@@ -196,13 +202,25 @@ function slotPredicate(needed) {
|
|
|
196
202
|
const needsSlot = (slot) => {
|
|
197
203
|
const cached = cache.get(slot);
|
|
198
204
|
if (cached !== undefined) return cached;
|
|
199
|
-
|
|
205
|
+
// A slot that intercepts anything needs the client router on every page it
|
|
206
|
+
// is rendered on, whatever its modules are. An intercepting route renders
|
|
207
|
+
// only for a client navigation that starts on such a page, and a page
|
|
208
|
+
// served as a document with no router hydrated on it is a page no
|
|
209
|
+
// navigation starts from — so dropping it would leave the interception a
|
|
210
|
+
// file nothing ever renders, which is the silence #267 is about.
|
|
211
|
+
let answer = (slot.intercepts ?? []).length > 0;
|
|
200
212
|
if (slot.defaultPage != null && needed(slot.defaultPage)) {
|
|
201
213
|
answer = true;
|
|
202
214
|
}
|
|
203
215
|
for (const route of slot.routes) {
|
|
204
216
|
if (answer) break;
|
|
205
|
-
if (
|
|
217
|
+
if (
|
|
218
|
+
needed(route.page) ||
|
|
219
|
+
route.layouts.some(needed) ||
|
|
220
|
+
(route.loading ?? []).some((entry) => needed(entry.module)) ||
|
|
221
|
+
(route.templates ?? []).some((entry) => needed(entry.module)) ||
|
|
222
|
+
route.slots.some(needsSlot)
|
|
223
|
+
) {
|
|
206
224
|
answer = true;
|
|
207
225
|
}
|
|
208
226
|
}
|
package/internal/serve.js
CHANGED
|
@@ -64,12 +64,12 @@
|
|
|
64
64
|
// settles it on the line after the last byte.
|
|
65
65
|
//
|
|
66
66
|
// It has to be the *entry's* `beginRequest` rather than one imported here: the
|
|
67
|
-
// request
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
// application cannot see, and nothing would fail loudly — the
|
|
71
|
-
// the page would render, and every `cookies()` in it would
|
|
72
|
-
// host had run at all. See ubugeeei-prod/uf#389.
|
|
67
|
+
// request store is shared by every copy of one release of `@uniflowed/server`,
|
|
68
|
+
// and the release that matters is the one inside the application bundle. A
|
|
69
|
+
// host that resolved its own could be holding another release, would begin a
|
|
70
|
+
// request the application cannot see, and nothing would fail loudly — the
|
|
71
|
+
// guard would run, the page would render, and every `cookies()` in it would
|
|
72
|
+
// throw as though no host had run at all. See ubugeeei-prod/uf#389.
|
|
73
73
|
|
|
74
74
|
import { readFile, stat } from "node:fs/promises";
|
|
75
75
|
import path from "node:path";
|
|
@@ -348,10 +348,10 @@ export function assetsFromManifest(manifest) {
|
|
|
348
348
|
* its callback whether the render threw or not, and `drainDeferred` already
|
|
349
349
|
* reports a failing task rather than propagating it.
|
|
350
350
|
*
|
|
351
|
-
* `entry.beginRequest` and not an import: the request
|
|
352
|
-
*
|
|
353
|
-
*
|
|
354
|
-
*
|
|
351
|
+
* `entry.beginRequest` and not an import: the request store is shared by every
|
|
352
|
+
* copy of one release of `@uniflowed/server`, and the release that matters is
|
|
353
|
+
* the one inside the application bundle. See `serverModuleSource` in
|
|
354
|
+
* `./routes.js`.
|
|
355
355
|
*
|
|
356
356
|
* The one case this cannot be exact about is a request uf hands back rather
|
|
357
357
|
* than answers: a caller whose `catch` is `next(error)` gives the response to
|
|
@@ -385,10 +385,10 @@ export async function withRequest(entry, request, body) {
|
|
|
385
385
|
* `finally` here. A caller that always answers should use [`withRequest`] and
|
|
386
386
|
* not think about it.
|
|
387
387
|
*
|
|
388
|
-
* `entry.beginRequest` and not an import: the request
|
|
389
|
-
*
|
|
390
|
-
*
|
|
391
|
-
*
|
|
388
|
+
* `entry.beginRequest` and not an import: the request store is shared by every
|
|
389
|
+
* copy of one release of `@uniflowed/server`, and the release that matters is
|
|
390
|
+
* the one inside the application bundle. See `serverModuleSource` in
|
|
391
|
+
* `./routes.js`.
|
|
392
392
|
*
|
|
393
393
|
* What this host can do is put on the request the way `createFetchHandler`
|
|
394
394
|
* puts it on the one it owns. `uf dev` and `uf build --compile` reach a route
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniflowed/vite",
|
|
3
|
-
"version": "0.0.0-alpha.
|
|
3
|
+
"version": "0.0.0-alpha.34",
|
|
4
4
|
"description": "Vite, driven by uf.config.js: every Flow module through `uf transform`, MDX, the file-system router and static rendering as Vite plugins.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -34,10 +34,10 @@
|
|
|
34
34
|
"dependencies": {
|
|
35
35
|
"@mdx-js/rollup": "^3.1.1",
|
|
36
36
|
"@shikijs/rehype": "^3.23.0",
|
|
37
|
-
"@uniflowed/host": "0.0.0-alpha.
|
|
38
|
-
"@uniflowed/router": "0.0.0-alpha.
|
|
39
|
-
"@uniflowed/server": "0.0.0-alpha.
|
|
40
|
-
"@uniflowed/validator": "0.0.0-alpha.
|
|
37
|
+
"@uniflowed/host": "0.0.0-alpha.34",
|
|
38
|
+
"@uniflowed/router": "0.0.0-alpha.34",
|
|
39
|
+
"@uniflowed/server": "0.0.0-alpha.34",
|
|
40
|
+
"@uniflowed/validator": "0.0.0-alpha.34",
|
|
41
41
|
"rehype-slug": "^6.0.0",
|
|
42
42
|
"remark-frontmatter": "^5.0.0",
|
|
43
43
|
"remark-gfm": "^4.0.1",
|