@troyio/vite-file-router 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -48,14 +48,15 @@ plugins: [
48
48
  loader: "load",
49
49
  action: "mutate",
50
50
  middleware: "guard",
51
+ hydrateFallback: "booting",
51
52
  },
52
53
  }),
53
54
  ];
54
55
  ```
55
56
 
56
57
  Omitted convention values keep their defaults: `page`, `layout`, `loading`,
57
- `not-found`, `error`, `loader`, `action`, and `middleware`. Each convention
58
- must have a unique filename stem.
58
+ `not-found`, `error`, `loader`, `action`, `middleware`, and
59
+ `hydrate-fallback`. Each convention must have a unique filename stem.
59
60
 
60
61
  The router scans the pages directory recursively. Each directory becomes a
61
62
  nested route, and the following files are recognized:
@@ -70,6 +71,7 @@ nested route, and the following files are recognized:
70
71
  | `loader.js` / `loader.ts` | A React Router loader for the directory's route and its descendants. |
71
72
  | `action.js` / `action.ts` | A React Router action selected when that directory route is the submission target. |
72
73
  | `middleware.js` / `middleware.ts` | React Router middleware that runs before every loader and action at or below the directory. |
74
+ | `hydrate-fallback.jsx` / `hydrate-fallback.tsx` | What renders while the first page load's loaders run. |
73
75
 
74
76
  Component files must default-export a React component. Loader and action
75
77
  modules must named-export their handler as `loader` or `action`; if both
@@ -144,6 +146,32 @@ export default function Loading() {
144
146
 
145
147
  A nested loading file creates a more specific boundary for that directory.
146
148
 
149
+ Loading files cover lazy page code, not loader time. On later navigations,
150
+ React Router keeps the current page on screen while loaders run, so show
151
+ pending UI with `useNavigation()` in a layout.
152
+
153
+ ### Hydrate fallbacks
154
+
155
+ On the first page load, nothing renders until the matched routes' loaders and
156
+ lazy modules are ready. Add a `hydrate-fallback.jsx` or `hydrate-fallback.tsx`
157
+ to show something in the meantime:
158
+
159
+ ```tsx
160
+ export default function HydrateFallback() {
161
+ return <p>Loading the app...</p>;
162
+ }
163
+ ```
164
+
165
+ The file becomes the directory route's React Router `HydrateFallback`.
166
+ React Router renders the nearest one at or above the first route that is
167
+ still loading, below the layouts of the routes above it. A layout in the same
168
+ directory as the fallback doesn't wrap it. It only shows on the first load;
169
+ later navigations keep the current page on screen instead.
170
+
171
+ Hydrate fallbacks are imported up front rather than lazily, because they
172
+ render before any route module has loaded. Keep them small: they're part of
173
+ your app's initial bundle.
174
+
147
175
  ### Not-found pages
148
176
 
149
177
  Add a `not-found.jsx` or `not-found.tsx` file to handle unmatched URLs. The plugin generates
@@ -386,11 +414,15 @@ export default defineConfig({
386
414
  ```
387
415
 
388
416
  Open the **Routes** dock to use the panel. It's development-only and needs
389
- Vite DevTools enabled. In the panel you can:
417
+ Vite DevTools enabled. The panel has three pages, listed in a sidebar (or a
418
+ top bar when the dock is narrow): **Routes**, **Timeline**, and **Generated
419
+ code**. The page you're on is kept in the URL hash, so reloading the panel
420
+ stays on it. In the panel you can:
390
421
 
391
422
  - **Browse the route tree.** Each route shows its URL segment, full path, and
392
423
  badges for its files: page, layout, loading, error, not-found (`404`),
393
- loader, and action. You can filter by path, param, or file, collapse
424
+ loader, action, middleware (`mw`), and hydrate fallback (`hydrate`). You
425
+ can filter by path, param, or file, collapse
394
426
  branches, and move between routes with the arrow keys.
395
427
  - **See route details.** Select a route to see its params, files, and
396
428
  warnings. For a route with a loader or action, the **Data calls** section
@@ -445,6 +477,14 @@ Vite DevTools enabled. In the panel you can:
445
477
  you can see when a loader was skipped. You can filter by call type, text,
446
478
  or route. The dev server keeps the last 200 calls, so a panel opened later
447
479
  still sees recent history.
480
+ - **Read the generated code.** The **Generated code** page shows
481
+ `virtual:routes`, `virtual:route-manifest`, `virtual:route-helpers`, and
482
+ the route types declaration file exactly as the plugin generates them, with
483
+ line numbers, size, simple highlighting, and a copy button. For
484
+ `virtual:routes`, switch between the dev server version, which includes the
485
+ DevTools client, and the build version. The page refreshes when route files
486
+ change, and a module that can't be generated (for example during a route
487
+ conflict) shows its error.
448
488
  - **Inspect a call.** Expand a timeline entry to see its route params, the
449
489
  request, and for actions the submitted form fields or body. Returned data
450
490
  appears as a collapsible JSON tree with a copy button. Responses show their
package/devtools/app.js CHANGED
@@ -3,6 +3,7 @@ const EVENTS_URL = "/__vite-file-router-events";
3
3
  const MATCH_URL = "/__vite-file-router-match";
4
4
  const OPEN_URL = "/__open-in-editor";
5
5
  const NAVIGATE_URL = "/__vite-file-router-navigate";
6
+ const CODE_URL = "/__vite-file-router-code";
6
7
  const RECENT_PARAMS_KEY = "vite-file-router:recent-params";
7
8
  const TESTER_STORAGE_KEY = "vite-file-router:tester-url";
8
9
 
@@ -24,11 +25,11 @@ const detailsElement = $("#details");
24
25
  const currentElement = $("#current");
25
26
  const currentSubtitle = $("#current-subtitle");
26
27
  const tabPicker = $("#tab-picker");
27
- const inspectTab = $("#tab-inspect");
28
- const timelineTab = $("#tab-timeline");
29
- const inspectView = $("#view-inspect");
30
- const timelineView = $("#view-timeline");
28
+ const pageNav = $("#page-nav");
31
29
  const timelineCount = $("#timeline-count");
30
+ const warningCount = $("#warning-count");
31
+ const codeModulesElement = $("#code-modules");
32
+ const codeViewElement = $("#code-view");
32
33
  const timelineElement = $("#timeline");
33
34
  const timelineFilter = $("#timeline-filter");
34
35
  const timelineTypes = $("#timeline-types");
@@ -47,6 +48,7 @@ const FILE_KINDS = [
47
48
  ["loader", "Loader", "loader"],
48
49
  ["action", "Action", "action"],
49
50
  ["middleware", "Middleware", "mw"],
51
+ ["hydrateFallback", "Hydrate fallback", "hydrate"],
50
52
  ];
51
53
 
52
54
  const state = {
@@ -69,6 +71,8 @@ const state = {
69
71
  // Exact route ID the timeline is narrowed to, from a route's details.
70
72
  timelineRoute: undefined,
71
73
  openCalls: new Set(),
74
+ // Generated modules for the Generated code page, fetched when it opens.
75
+ code: { modules: undefined, selected: "virtual:routes", variant: "dev", stale: false, failure: undefined },
72
76
  // Signature of the slow-loader warnings last rendered.
73
77
  slowSignature: "",
74
78
  };
@@ -406,7 +410,7 @@ function slowLoaderWarnings() {
406
410
  severity: "info",
407
411
  key: found.key,
408
412
  fullPath: found.node.fullPath,
409
- message: `This loader took up to ${formatDuration(duration)}. Loading files don't show while loaders run, so show pending UI with useNavigation() in a layout.`,
413
+ message: `This loader took up to ${formatDuration(duration)}. Loading files don't show while loaders run. Add a hydrate-fallback file for the first page load, and show pending UI with useNavigation() in a layout for later navigations.`,
410
414
  files: found.node.files.loader ? [found.node.files.loader] : [],
411
415
  },
412
416
  ];
@@ -467,6 +471,10 @@ function revealRoute(key) {
467
471
 
468
472
  function renderWarnings() {
469
473
  const warnings = allWarnings();
474
+ const severeCount = warnings.filter((warning) => warning.severity === "warning").length;
475
+ warningCount.hidden = severeCount === 0;
476
+ warningCount.textContent = String(severeCount);
477
+ warningCount.title = `${severeCount} route warning${severeCount === 1 ? "" : "s"}`;
470
478
  warningsElement.hidden = warnings.length === 0;
471
479
  if (!warnings.length) return;
472
480
  const severe = warnings.filter((warning) => warning.severity === "warning").length;
@@ -694,6 +702,7 @@ function renderMatch() {
694
702
  result.middleware?.length ? fact("Middleware, outermost first", result.middleware.map(fileLink)) : undefined,
695
703
  result.loaders.length ? fact("Loaders", result.loaders.map(fileLink)) : undefined,
696
704
  result.loading ? fact("Loading fallback", fileLink(result.loading)) : undefined,
705
+ result.hydrateFallback ? fact("Hydrate fallback (first load)", fileLink(result.hydrateFallback)) : undefined,
697
706
  result.error ? fact("Error boundary", fileLink(result.error)) : undefined,
698
707
  ),
699
708
  );
@@ -990,6 +999,201 @@ function rowOpenButton(node, key) {
990
999
  );
991
1000
  }
992
1001
 
1002
+ // ---------------------------------------------------------------------------
1003
+ // Pages
1004
+
1005
+ const PAGES = ["routes", "timeline", "code"];
1006
+
1007
+ function currentPage() {
1008
+ const name = window.location.hash.slice(1);
1009
+ return PAGES.includes(name) ? name : "routes";
1010
+ }
1011
+
1012
+ /** Shows a page. `inspect` is the Routes page's older name. */
1013
+ function showView(name) {
1014
+ const page = name === "inspect" ? "routes" : name;
1015
+ if (window.location.hash !== `#${page}`) {
1016
+ history.replaceState(null, "", `#${page}`);
1017
+ }
1018
+ applyPage();
1019
+ }
1020
+
1021
+ function applyPage() {
1022
+ const page = currentPage();
1023
+ for (const name of PAGES) $(`#page-${name}`).hidden = name !== page;
1024
+ for (const link of pageNav.querySelectorAll("a")) {
1025
+ if (link.dataset.page === page) link.setAttribute("aria-current", "page");
1026
+ else link.removeAttribute("aria-current");
1027
+ }
1028
+ if (page === "timeline") state.unseen = 0;
1029
+ renderTimeline();
1030
+ if (page === "code" && (!state.code.modules || state.code.stale)) loadCode();
1031
+ }
1032
+
1033
+ // ---------------------------------------------------------------------------
1034
+ // Generated code
1035
+
1036
+ const CODE_MODULES = [
1037
+ ["virtual:routes", "Route objects passed to React Router, with lazy imports and preloading."],
1038
+ ["virtual:route-manifest", "The route tree as data: paths, params, and files."],
1039
+ ["virtual:route-helpers", "routePath(), useRouteParams(), and useRouteData()."],
1040
+ ["route-types", "Types for route patterns, params, and loader data."],
1041
+ ];
1042
+
1043
+ const KEYWORDS = new Set(
1044
+ "import export from const let var function return async await if else for of in new typeof type interface declare module extends default throw try catch null undefined true false class keyof infer as".split(
1045
+ " ",
1046
+ ),
1047
+ );
1048
+ const TOKEN_PATTERN =
1049
+ /(\/\/[^\n]*|\/\*[\s\S]*?\*\/)|("(?:[^"\\\n]|\\.)*"|'(?:[^'\\\n]|\\.)*'|`(?:[^`\\]|\\.)*`)|\b([A-Za-z_$][\w$]*)\b|\b(\d+(?:\.\d+)?)\b/g;
1050
+
1051
+ /** A small highlighter for the generated JavaScript and TypeScript. */
1052
+ function highlight(code) {
1053
+ const pieces = [];
1054
+ let last = 0;
1055
+ for (const match of code.matchAll(TOKEN_PATTERN)) {
1056
+ const [text, comment, string, word, number] = match;
1057
+ const kind = comment
1058
+ ? "comment"
1059
+ : string
1060
+ ? "string"
1061
+ : number
1062
+ ? "number"
1063
+ : word && KEYWORDS.has(word)
1064
+ ? "keyword"
1065
+ : undefined;
1066
+ if (!kind) continue;
1067
+ if (match.index > last) pieces.push([undefined, code.slice(last, match.index)]);
1068
+ pieces.push([kind, text]);
1069
+ last = match.index + text.length;
1070
+ }
1071
+ if (last < code.length) pieces.push([undefined, code.slice(last)]);
1072
+
1073
+ // Split the pieces into lines, so each line can carry its number.
1074
+ const lines = [[]];
1075
+ for (const [kind, text] of pieces) {
1076
+ text.split("\n").forEach((part, index) => {
1077
+ if (index > 0) lines.push([]);
1078
+ if (part) lines.at(-1).push(kind ? el("span", { class: `tok-${kind}` }, part) : document.createTextNode(part));
1079
+ });
1080
+ }
1081
+ if (lines.length > 1 && lines.at(-1).length === 0) lines.pop();
1082
+ return lines.map((parts) => el("span", { class: "code-line" }, parts.length ? parts : "​"));
1083
+ }
1084
+
1085
+ function formatBytes(bytes) {
1086
+ return bytes < 1024 ? `${bytes} B` : `${(bytes / 1024).toFixed(1)} KB`;
1087
+ }
1088
+
1089
+ function selectedModule() {
1090
+ const { modules, selected, variant } = state.code;
1091
+ return modules?.find(
1092
+ (module) => module.id === selected && (module.variant === undefined || module.variant === variant),
1093
+ );
1094
+ }
1095
+
1096
+ function renderCode() {
1097
+ const { modules, selected, variant, failure } = state.code;
1098
+ codeModulesElement.replaceChildren(
1099
+ ...CODE_MODULES.map(([id, description]) =>
1100
+ el(
1101
+ "button",
1102
+ {
1103
+ class: "code-module",
1104
+ type: "button",
1105
+ role: "tab",
1106
+ "aria-selected": String(id === selected),
1107
+ onclick: () => {
1108
+ state.code.selected = id;
1109
+ renderCode();
1110
+ },
1111
+ },
1112
+ el("span", { class: "code-module-name" }, id === "route-types" ? "Route types (.d.ts)" : id),
1113
+ el("span", { class: "code-module-description" }, description),
1114
+ ),
1115
+ ),
1116
+ );
1117
+
1118
+ if (failure) {
1119
+ codeViewElement.replaceChildren(el("p", { class: "empty" }, failure));
1120
+ return;
1121
+ }
1122
+ if (!modules) {
1123
+ codeViewElement.replaceChildren(el("p", { class: "empty" }, "Loading generated code…"));
1124
+ return;
1125
+ }
1126
+ const module = selectedModule();
1127
+ const code = module?.code ?? "";
1128
+ const notes = [];
1129
+ if (module?.id === "virtual:routes") {
1130
+ notes.push(
1131
+ variant === "dev"
1132
+ ? "As the dev server serves it, with the DevTools client and call reporting."
1133
+ : "As vite build generates it, without any DevTools code.",
1134
+ );
1135
+ }
1136
+ if (module?.id === "route-types") {
1137
+ notes.push(
1138
+ module.written
1139
+ ? `Written to ${module.file}.`
1140
+ : `Not written: the dts option is false, or the project has no tsconfig.json or jsconfig.json. With dts set, it goes to ${module.file}.`,
1141
+ );
1142
+ }
1143
+
1144
+ codeViewElement.replaceChildren(
1145
+ el(
1146
+ "div",
1147
+ { class: "code-toolbar" },
1148
+ module?.id === "virtual:routes"
1149
+ ? el(
1150
+ "div",
1151
+ { class: "segmented code-variant", role: "group", "aria-label": "Variant" },
1152
+ ["dev", "build"].map((name) =>
1153
+ el(
1154
+ "button",
1155
+ {
1156
+ type: "button",
1157
+ "aria-pressed": String(name === variant),
1158
+ onclick: () => {
1159
+ state.code.variant = name;
1160
+ renderCode();
1161
+ },
1162
+ },
1163
+ name === "dev" ? "Dev server" : "Build",
1164
+ ),
1165
+ ),
1166
+ )
1167
+ : undefined,
1168
+ el(
1169
+ "span",
1170
+ { class: "code-size muted" },
1171
+ module?.code === undefined
1172
+ ? ""
1173
+ : `${code.split("\n").length - (code.endsWith("\n") ? 1 : 0)} lines · ${formatBytes(new TextEncoder().encode(code).length)}`,
1174
+ ),
1175
+ module?.code !== undefined ? copyButton(code, "Copy") : undefined,
1176
+ ),
1177
+ notes.length ? el("p", { class: "code-note muted" }, notes.join(" ")) : undefined,
1178
+ module?.error
1179
+ ? el("p", { class: "code-error" }, module.error)
1180
+ : el("pre", { class: "code", tabindex: "0" }, el("code", {}, highlight(code))),
1181
+ );
1182
+ }
1183
+
1184
+ async function loadCode() {
1185
+ state.code.stale = false;
1186
+ try {
1187
+ const response = await fetch(CODE_URL, { cache: "no-store" });
1188
+ if (!response.ok) throw new Error(`Request failed (${response.status})`);
1189
+ state.code.modules = (await response.json()).modules;
1190
+ state.code.failure = undefined;
1191
+ } catch (error) {
1192
+ state.code.failure = `Couldn't load the generated code: ${error.message}`;
1193
+ }
1194
+ renderCode();
1195
+ }
1196
+
993
1197
  // ---------------------------------------------------------------------------
994
1198
  // Loader and action timeline
995
1199
 
@@ -1415,7 +1619,7 @@ function addActivity(calls) {
1415
1619
  if (state.activity.length > MAX_TIMELINE) {
1416
1620
  state.activity.splice(0, state.activity.length - MAX_TIMELINE);
1417
1621
  }
1418
- if (timelineView.hidden) state.unseen += calls.length;
1622
+ if (currentPage() !== "timeline") state.unseen += calls.length;
1419
1623
  renderTimeline();
1420
1624
  refreshSlowWarnings();
1421
1625
  // Keep the selected route's call stats current.
@@ -1424,15 +1628,6 @@ function addActivity(calls) {
1424
1628
  if (routeId && calls.some((call) => call.routeId === routeId)) renderDetails();
1425
1629
  }
1426
1630
 
1427
- function showView(name) {
1428
- const timeline = name === "timeline";
1429
- inspectView.hidden = timeline;
1430
- timelineView.hidden = !timeline;
1431
- inspectTab.setAttribute("aria-selected", String(!timeline));
1432
- timelineTab.setAttribute("aria-selected", String(timeline));
1433
- if (timeline) state.unseen = 0;
1434
- renderTimeline();
1435
- }
1436
1631
 
1437
1632
  function applyData(data) {
1438
1633
  const snapshot = JSON.stringify(data);
@@ -1453,6 +1648,9 @@ function applyData(data) {
1453
1648
  renderCurrent();
1454
1649
  renderTimeline();
1455
1650
  if (testerInput.value.trim()) runMatch();
1651
+ // Generated code follows the route files; refetch it now if it's showing.
1652
+ state.code.stale = true;
1653
+ if (currentPage() === "code" && state.code.modules) loadCode();
1456
1654
  }
1457
1655
 
1458
1656
  async function refresh() {
@@ -1506,8 +1704,7 @@ function connect() {
1506
1704
  }
1507
1705
 
1508
1706
  filterElement.addEventListener("input", renderTree);
1509
- inspectTab.addEventListener("click", () => showView("inspect"));
1510
- timelineTab.addEventListener("click", () => showView("timeline"));
1707
+ window.addEventListener("hashchange", applyPage);
1511
1708
  timelineFilter.addEventListener("input", renderTimeline);
1512
1709
  timelineTypes.addEventListener("click", (event) => {
1513
1710
  const type = event.target.closest("button")?.dataset.type;
@@ -1541,6 +1738,6 @@ testerForm.addEventListener("submit", (event) => {
1541
1738
  testerInput.value = readStorage(TESTER_STORAGE_KEY);
1542
1739
  renderTree();
1543
1740
  renderCurrent();
1544
- renderTimeline();
1741
+ applyPage();
1545
1742
  refresh();
1546
1743
  connect();
@@ -24,6 +24,15 @@
24
24
  <symbol id="icon-go" viewBox="0 0 16 16">
25
25
  <path d="M3 8h9M8.5 4.5L12 8l-3.5 3.5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
26
26
  </symbol>
27
+ <symbol id="icon-timeline" viewBox="0 0 16 16">
28
+ <path d="M3 4h10M3 8h6M3 12h8" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
29
+ </symbol>
30
+ <symbol id="icon-code" viewBox="0 0 16 16">
31
+ <path d="M6 4.5L2.5 8 6 11.5M10 4.5L13.5 8 10 11.5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
32
+ </symbol>
33
+ <symbol id="icon-copy" viewBox="0 0 16 16">
34
+ <path d="M5.5 5.5h7v7h-7zM3.5 10.5v-7h7" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round" />
35
+ </symbol>
27
36
  <symbol id="icon-route" viewBox="0 0 16 16">
28
37
  <circle cx="4" cy="4" r="1.8" fill="none" stroke="currentColor" stroke-width="1.4" />
29
38
  <circle cx="12" cy="12" r="1.8" fill="none" stroke="currentColor" stroke-width="1.4" />
@@ -58,96 +67,124 @@
58
67
  </header>
59
68
 
60
69
  <p class="alert" id="error" role="alert" hidden></p>
61
- <section class="conflicts" id="conflicts" aria-label="Route conflicts" hidden></section>
62
- <section class="warnings" id="warnings" aria-label="Route warnings" hidden></section>
63
70
 
64
- <main class="layout">
65
- <section class="panel tree-panel" aria-label="Route tree">
66
- <div class="panel-toolbar">
67
- <label class="field">
68
- <svg class="field-icon" aria-hidden="true"><use href="#icon-search" /></svg>
69
- <span class="visually-hidden">Filter routes</span>
70
- <input id="filter" type="search" placeholder="Filter by path, param, or file" autocomplete="off" spellcheck="false" />
71
- </label>
72
- <button class="icon-button" id="toggle" type="button" aria-label="Collapse all" title="Collapse all">
73
- <svg><use href="#icon-collapse" /></svg>
74
- </button>
75
- </div>
76
- <div class="tree" id="tree" role="tree" aria-label="Routes"></div>
77
- </section>
71
+ <div class="app">
72
+ <nav class="page-nav" id="page-nav" aria-label="Panel pages">
73
+ <a href="#routes" data-page="routes" aria-current="page">
74
+ <svg aria-hidden="true"><use href="#icon-route" /></svg>
75
+ <span>Routes</span>
76
+ <span class="nav-count" id="warning-count" hidden></span>
77
+ </a>
78
+ <a href="#timeline" data-page="timeline">
79
+ <svg aria-hidden="true"><use href="#icon-timeline" /></svg>
80
+ <span>Timeline</span>
81
+ <span class="nav-count" id="timeline-count" hidden></span>
82
+ </a>
83
+ <a href="#code" data-page="code">
84
+ <svg aria-hidden="true"><use href="#icon-code" /></svg>
85
+ <span>Generated code</span>
86
+ </a>
87
+ </nav>
78
88
 
79
- <aside class="side">
80
- <section class="panel current-panel" aria-labelledby="current-title">
81
- <div class="panel-header panel-header-row">
82
- <div>
83
- <h2 id="current-title">Current page</h2>
84
- <p id="current-subtitle">Follows the route your app is showing.</p>
89
+ <div class="page" id="page-routes">
90
+ <section class="conflicts" id="conflicts" aria-label="Route conflicts" hidden></section>
91
+ <section class="warnings" id="warnings" aria-label="Route warnings" hidden></section>
92
+
93
+ <main class="layout">
94
+ <section class="panel tree-panel" aria-label="Route tree">
95
+ <div class="panel-toolbar">
96
+ <label class="field">
97
+ <svg class="field-icon" aria-hidden="true"><use href="#icon-search" /></svg>
98
+ <span class="visually-hidden">Filter routes</span>
99
+ <input id="filter" type="search" placeholder="Filter by path, param, or file" autocomplete="off" spellcheck="false" />
100
+ </label>
101
+ <button class="icon-button" id="toggle" type="button" aria-label="Collapse all" title="Collapse all">
102
+ <svg><use href="#icon-collapse" /></svg>
103
+ </button>
85
104
  </div>
86
- <div class="tab-picker" id="tab-picker" role="group" aria-label="App tabs" hidden></div>
87
- </div>
88
- <div id="current" aria-live="polite"></div>
89
- </section>
105
+ <div class="tree" id="tree" role="tree" aria-label="Routes"></div>
106
+ </section>
90
107
 
91
- <div class="tabs" role="tablist" aria-label="Panel sections">
92
- <button class="tab" id="tab-inspect" type="button" role="tab" aria-selected="true" aria-controls="view-inspect">Inspect</button>
93
- <button class="tab" id="tab-timeline" type="button" role="tab" aria-selected="false" aria-controls="view-timeline">
94
- Timeline <span class="tab-count" id="timeline-count" hidden></span>
95
- </button>
96
- </div>
108
+ <aside class="side">
109
+ <section class="panel current-panel" aria-labelledby="current-title">
110
+ <div class="panel-header panel-header-row">
111
+ <div>
112
+ <h2 id="current-title">Current page</h2>
113
+ <p id="current-subtitle">Follows the route your app is showing.</p>
114
+ </div>
115
+ <div class="tab-picker" id="tab-picker" role="group" aria-label="App tabs" hidden></div>
116
+ </div>
117
+ <div id="current" aria-live="polite"></div>
118
+ </section>
119
+
120
+ <section class="panel" aria-labelledby="tester-title">
121
+ <div class="panel-header">
122
+ <h2 id="tester-title">Test a URL</h2>
123
+ <p>Shows which route files handle a URL.</p>
124
+ </div>
125
+ <form class="tester" id="tester">
126
+ <label class="field field-mono">
127
+ <span class="visually-hidden">URL to match</span>
128
+ <input id="tester-input" type="text" placeholder="/users/42?tab=posts" autocomplete="off" spellcheck="false" />
129
+ </label>
130
+ <button class="button" type="submit">Match</button>
131
+ </form>
132
+ <div id="tester-result" aria-live="polite"></div>
133
+ </section>
97
134
 
98
- <div class="view" id="view-inspect" role="tabpanel" aria-labelledby="tab-inspect">
99
- <section class="panel" aria-labelledby="tester-title">
100
- <div class="panel-header">
101
- <h2 id="tester-title">Test a URL</h2>
102
- <p>Shows which route files handle a URL.</p>
135
+ <section class="panel" aria-labelledby="details-title">
136
+ <div class="panel-header">
137
+ <h2 id="details-title">Route details</h2>
138
+ </div>
139
+ <div id="details"></div>
140
+ </section>
141
+ </aside>
142
+ </main>
143
+ </div>
144
+
145
+ <div class="page" id="page-timeline" hidden>
146
+ <section class="panel" aria-labelledby="timeline-title">
147
+ <div class="panel-header panel-header-row">
148
+ <div>
149
+ <h2 id="timeline-title">Loader and action timeline</h2>
150
+ <p>Calls made by your app, newest first.</p>
151
+ </div>
152
+ <button class="icon-button text-button" id="timeline-clear" type="button">Clear</button>
103
153
  </div>
104
- <form class="tester" id="tester">
105
- <label class="field field-mono">
106
- <span class="visually-hidden">URL to match</span>
107
- <input id="tester-input" type="text" placeholder="/users/42?tab=posts" autocomplete="off" spellcheck="false" />
154
+ <div class="timeline-toolbar">
155
+ <div class="segmented" id="timeline-types" role="group" aria-label="Filter by call type">
156
+ <button type="button" data-type="all" aria-pressed="true">All</button>
157
+ <button type="button" data-type="loader" aria-pressed="false">Loaders</button>
158
+ <button type="button" data-type="action" aria-pressed="false">Actions</button>
159
+ <button type="button" data-type="middleware" aria-pressed="false">Middleware</button>
160
+ <button type="button" data-type="revalidate" aria-pressed="false">Revalidation</button>
161
+ </div>
162
+ <span class="route-pill" id="timeline-route" hidden></span>
163
+ <label class="field">
164
+ <svg class="field-icon" aria-hidden="true"><use href="#icon-search" /></svg>
165
+ <span class="visually-hidden">Filter calls</span>
166
+ <input id="timeline-filter" type="search" placeholder="Filter by route, URL, or outcome" autocomplete="off" spellcheck="false" />
108
167
  </label>
109
- <button class="button" type="submit">Match</button>
110
- </form>
111
- <div id="tester-result" aria-live="polite"></div>
112
- </section>
113
-
114
- <section class="panel" aria-labelledby="details-title">
115
- <div class="panel-header">
116
- <h2 id="details-title">Route details</h2>
117
168
  </div>
118
- <div id="details"></div>
169
+ <ol class="timeline" id="timeline" aria-label="Calls"></ol>
119
170
  </section>
120
- </div>
171
+ </div>
121
172
 
122
- <div class="view" id="view-timeline" role="tabpanel" aria-labelledby="tab-timeline" hidden>
123
- <section class="panel" aria-labelledby="timeline-title">
124
- <div class="panel-header panel-header-row">
125
- <div>
126
- <h2 id="timeline-title">Loader and action timeline</h2>
127
- <p>Calls made by your app, newest first.</p>
128
- </div>
129
- <button class="icon-button text-button" id="timeline-clear" type="button">Clear</button>
130
- </div>
131
- <div class="timeline-toolbar">
132
- <div class="segmented" id="timeline-types" role="group" aria-label="Filter by call type">
133
- <button type="button" data-type="all" aria-pressed="true">All</button>
134
- <button type="button" data-type="loader" aria-pressed="false">Loaders</button>
135
- <button type="button" data-type="action" aria-pressed="false">Actions</button>
136
- <button type="button" data-type="middleware" aria-pressed="false">Middleware</button>
137
- <button type="button" data-type="revalidate" aria-pressed="false">Revalidation</button>
138
- </div>
139
- <span class="route-pill" id="timeline-route" hidden></span>
140
- <label class="field">
141
- <svg class="field-icon" aria-hidden="true"><use href="#icon-search" /></svg>
142
- <span class="visually-hidden">Filter calls</span>
143
- <input id="timeline-filter" type="search" placeholder="Filter by route, URL, or outcome" autocomplete="off" spellcheck="false" />
144
- </label>
173
+ <div class="page" id="page-code" hidden>
174
+ <section class="panel code-panel" aria-labelledby="code-title">
175
+ <div class="panel-header panel-header-row">
176
+ <div>
177
+ <h2 id="code-title">Generated code</h2>
178
+ <p>The modules the plugin generates from your route files.</p>
145
179
  </div>
146
- <ol class="timeline" id="timeline" aria-label="Calls"></ol>
147
- </section>
148
- </div>
149
- </aside>
150
- </main>
180
+ </div>
181
+ <div class="code-layout">
182
+ <div class="code-modules" id="code-modules" role="tablist" aria-label="Generated modules"></div>
183
+ <div class="code-view" id="code-view" role="tabpanel" aria-live="polite"></div>
184
+ </div>
185
+ </section>
186
+ </div>
187
+ </div>
151
188
  <script type="module" src="/__vite-file-router/app.js"></script>
152
189
  </body>
153
190
  </html>
@@ -350,7 +350,7 @@ h1 {
350
350
 
351
351
  .alert,
352
352
  .conflicts {
353
- margin: 16px 20px 0;
353
+ margin: 0;
354
354
  padding: 12px 14px;
355
355
  border: 1px solid var(--border);
356
356
  border-radius: 0;
@@ -389,7 +389,7 @@ h1 {
389
389
  grid-template-columns: minmax(0, 1fr) minmax(300px, 380px);
390
390
  gap: 16px;
391
391
  align-items: start;
392
- padding: 16px 20px 24px;
392
+ padding: 0;
393
393
  }
394
394
 
395
395
  @media (max-width: 860px) {
@@ -918,62 +918,6 @@ mark {
918
918
  }
919
919
 
920
920
 
921
- /* Tabs */
922
-
923
- .tabs {
924
- display: flex;
925
- gap: 4px;
926
- padding: 4px;
927
- border: 1px solid var(--border);
928
- border-radius: 0;
929
- background: var(--surface);
930
- }
931
-
932
- .tab {
933
- display: inline-flex;
934
- flex: 1;
935
- align-items: center;
936
- justify-content: center;
937
- gap: 6px;
938
- height: 30px;
939
- border: 0;
940
- border-radius: 0;
941
- background: none;
942
- color: var(--text-2);
943
- font-weight: 600;
944
- cursor: pointer;
945
- }
946
-
947
- .tab:hover {
948
- color: var(--text);
949
- }
950
-
951
- .tab[aria-selected="true"] {
952
- background: var(--accent-soft);
953
- color: var(--accent-text);
954
- }
955
-
956
- .tab-count {
957
- min-width: 18px;
958
- height: 18px;
959
- padding: 0 5px;
960
- border-radius: 0;
961
- background: var(--accent);
962
- color: var(--accent-contrast);
963
- font-size: 10.5px;
964
- line-height: 18px;
965
- font-variant-numeric: tabular-nums;
966
- }
967
-
968
- .view {
969
- display: grid;
970
- gap: 16px;
971
- }
972
-
973
- .view[hidden] {
974
- display: none;
975
- }
976
-
977
921
  .text-button {
978
922
  width: auto;
979
923
  padding: 0 10px;
@@ -1137,7 +1081,7 @@ mark {
1137
1081
  /* Missing-file warnings */
1138
1082
 
1139
1083
  .warnings {
1140
- margin: 16px 20px 0;
1084
+ margin: 0;
1141
1085
  border: 1px solid var(--border);
1142
1086
  background: var(--surface);
1143
1087
  }
@@ -1488,3 +1432,279 @@ mark {
1488
1432
  .result-open {
1489
1433
  margin-left: auto;
1490
1434
  }
1435
+
1436
+ /* Pages and navigation */
1437
+
1438
+ .alert {
1439
+ margin: 16px 20px 0;
1440
+ }
1441
+
1442
+ .app {
1443
+ display: grid;
1444
+ grid-template-columns: 184px minmax(0, 1fr);
1445
+ align-items: start;
1446
+ min-height: calc(100vh - 57px);
1447
+ }
1448
+
1449
+ .page-nav {
1450
+ position: sticky;
1451
+ top: 57px;
1452
+ display: grid;
1453
+ align-content: start;
1454
+ gap: 2px;
1455
+ padding: 16px 10px;
1456
+ border-right: 1px solid var(--border);
1457
+ min-height: calc(100vh - 57px);
1458
+ }
1459
+
1460
+ .page-nav a {
1461
+ display: flex;
1462
+ align-items: center;
1463
+ gap: 9px;
1464
+ height: 34px;
1465
+ padding: 0 10px;
1466
+ color: var(--text-2);
1467
+ font-weight: 550;
1468
+ text-decoration: none;
1469
+ }
1470
+
1471
+ .page-nav a:hover {
1472
+ background: var(--surface-2);
1473
+ color: var(--text);
1474
+ }
1475
+
1476
+ .page-nav a[aria-current="page"] {
1477
+ background: var(--accent-soft);
1478
+ color: var(--text);
1479
+ font-weight: 650;
1480
+ }
1481
+
1482
+ .page-nav svg {
1483
+ color: var(--text-3);
1484
+ }
1485
+
1486
+ .page-nav a[aria-current="page"] svg {
1487
+ color: var(--text);
1488
+ }
1489
+
1490
+ .nav-count {
1491
+ min-width: 18px;
1492
+ height: 18px;
1493
+ margin-left: auto;
1494
+ padding: 0 5px;
1495
+ background: var(--accent);
1496
+ color: var(--accent-contrast);
1497
+ font-size: 10.5px;
1498
+ line-height: 18px;
1499
+ text-align: center;
1500
+ font-variant-numeric: tabular-nums;
1501
+ }
1502
+
1503
+ #warning-count {
1504
+ background: var(--surface-2);
1505
+ color: var(--warning-text);
1506
+ font-weight: 650;
1507
+ }
1508
+
1509
+ .page {
1510
+ display: grid;
1511
+ gap: 16px;
1512
+ min-width: 0;
1513
+ padding: 16px 20px 24px;
1514
+ align-content: start;
1515
+ }
1516
+
1517
+ @media (max-width: 860px) {
1518
+ .app {
1519
+ grid-template-columns: minmax(0, 1fr);
1520
+ }
1521
+
1522
+ .page-nav {
1523
+ position: static;
1524
+ display: flex;
1525
+ gap: 2px;
1526
+ min-height: 0;
1527
+ padding: 6px 12px;
1528
+ overflow-x: auto;
1529
+ border-right: 0;
1530
+ border-bottom: 1px solid var(--border);
1531
+ }
1532
+
1533
+ .page-nav a {
1534
+ flex: none;
1535
+ }
1536
+
1537
+ .page {
1538
+ padding: 12px;
1539
+ }
1540
+ }
1541
+
1542
+ /* Generated code */
1543
+
1544
+ .code-layout {
1545
+ display: grid;
1546
+ grid-template-columns: 230px minmax(0, 1fr);
1547
+ border-top: 1px solid var(--border);
1548
+ margin-top: 14px;
1549
+ }
1550
+
1551
+ .code-modules {
1552
+ display: grid;
1553
+ align-content: start;
1554
+ border-right: 1px solid var(--border);
1555
+ }
1556
+
1557
+ .code-module {
1558
+ display: grid;
1559
+ gap: 2px;
1560
+ padding: 10px 14px;
1561
+ border: 0;
1562
+ border-bottom: 1px solid var(--border);
1563
+ background: none;
1564
+ text-align: left;
1565
+ cursor: pointer;
1566
+ }
1567
+
1568
+ .code-module:hover {
1569
+ background: var(--surface-2);
1570
+ }
1571
+
1572
+ .code-module[aria-selected="true"] {
1573
+ background: var(--accent-soft);
1574
+ }
1575
+
1576
+ .code-module-name {
1577
+ font-family: var(--mono);
1578
+ font-size: 12px;
1579
+ font-weight: 600;
1580
+ }
1581
+
1582
+ .code-module-description {
1583
+ color: var(--text-3);
1584
+ font-size: 11.5px;
1585
+ }
1586
+
1587
+ .code-view {
1588
+ display: grid;
1589
+ align-content: start;
1590
+ gap: 8px;
1591
+ min-width: 0;
1592
+ padding: 12px 14px 14px;
1593
+ }
1594
+
1595
+ .code-toolbar {
1596
+ display: flex;
1597
+ align-items: center;
1598
+ gap: 12px;
1599
+ }
1600
+
1601
+ .code-variant {
1602
+ flex: none;
1603
+ width: 220px;
1604
+ }
1605
+
1606
+ .code-size {
1607
+ margin-left: auto;
1608
+ font-size: 12px;
1609
+ font-variant-numeric: tabular-nums;
1610
+ }
1611
+
1612
+ .code-note {
1613
+ font-size: 12px;
1614
+ }
1615
+
1616
+ .code-error {
1617
+ padding: 10px 12px;
1618
+ background: var(--surface-2);
1619
+ color: var(--danger-text);
1620
+ white-space: pre-wrap;
1621
+ }
1622
+
1623
+ .code {
1624
+ max-height: calc(100vh - 260px);
1625
+ min-height: 200px;
1626
+ margin: 0;
1627
+ overflow: auto;
1628
+ border: 1px solid var(--border);
1629
+ background: var(--surface-2);
1630
+ font-family: var(--mono);
1631
+ font-size: 12px;
1632
+ line-height: 1.6;
1633
+ counter-reset: line;
1634
+ }
1635
+
1636
+ .code code {
1637
+ display: block;
1638
+ min-width: max-content;
1639
+ padding: 8px 0;
1640
+ }
1641
+
1642
+ .code-line {
1643
+ display: block;
1644
+ padding-right: 16px;
1645
+ white-space: pre;
1646
+ }
1647
+
1648
+ .code-line::before {
1649
+ counter-increment: line;
1650
+ content: counter(line);
1651
+ display: inline-block;
1652
+ width: 3.5em;
1653
+ margin-right: 14px;
1654
+ padding-right: 10px;
1655
+ border-right: 1px solid var(--border);
1656
+ color: var(--text-3);
1657
+ text-align: right;
1658
+ user-select: none;
1659
+ }
1660
+
1661
+ .tok-comment {
1662
+ color: var(--text-3);
1663
+ font-style: italic;
1664
+ }
1665
+
1666
+ .tok-string {
1667
+ color: var(--success);
1668
+ }
1669
+
1670
+ .tok-keyword {
1671
+ color: var(--text);
1672
+ font-weight: 650;
1673
+ }
1674
+
1675
+ .tok-number {
1676
+ color: var(--warning-text);
1677
+ }
1678
+
1679
+ @media (max-width: 860px) {
1680
+ .code-layout {
1681
+ grid-template-columns: minmax(0, 1fr);
1682
+ }
1683
+
1684
+ .code-modules {
1685
+ display: flex;
1686
+ overflow-x: auto;
1687
+ border-right: 0;
1688
+ border-bottom: 1px solid var(--border);
1689
+ }
1690
+
1691
+ .code-module {
1692
+ flex: none;
1693
+ border-bottom: 0;
1694
+ border-right: 1px solid var(--border);
1695
+ }
1696
+
1697
+ .code-module-description {
1698
+ display: none;
1699
+ }
1700
+ }
1701
+
1702
+ @media (max-width: 1080px) and (min-width: 861px) {
1703
+ .layout {
1704
+ grid-template-columns: minmax(0, 1fr);
1705
+ }
1706
+
1707
+ .side {
1708
+ position: static;
1709
+ }
1710
+ }
@@ -6,6 +6,7 @@ export declare const ROUTE_INSPECTOR_DATA_PATH = "/__vite-file-router-data";
6
6
  export declare const ROUTE_INSPECTOR_EVENTS_PATH = "/__vite-file-router-events";
7
7
  export declare const ROUTE_INSPECTOR_MATCH_PATH = "/__vite-file-router-match";
8
8
  export declare const ROUTE_INSPECTOR_NAVIGATE_PATH = "/__vite-file-router-navigate";
9
+ export declare const ROUTE_INSPECTOR_CODE_PATH = "/__vite-file-router-code";
9
10
  export type RouteInspectorState = {
10
11
  /** Absolute Vite project root, so the panel can open files in an editor. */
11
12
  projectRoot: string;
@@ -58,6 +59,7 @@ export type RouteMatchResult = {
58
59
  loaders: string[];
59
60
  middleware: string[];
60
61
  loading?: string;
62
+ hydrateFallback?: string;
61
63
  error?: string;
62
64
  };
63
65
  export declare function matchRouteManifest(manifest: RouteManifestNode, url: string): Promise<RouteMatchResult>;
package/dist/index.js CHANGED
@@ -12,7 +12,8 @@ var DEFAULT_CONVENTIONS = {
12
12
  error: "error",
13
13
  loader: "loader",
14
14
  action: "action",
15
- middleware: "middleware"
15
+ middleware: "middleware",
16
+ hydrateFallback: "hydrate-fallback"
16
17
  };
17
18
  function getRouteSegment(name) {
18
19
  if (name.startsWith("[...") && name.endsWith("]")) return "*";
@@ -526,6 +527,11 @@ function generateNode(node, root, context, inheritedBoundary) {
526
527
  const instrumentId = getInstrumentId(node, context);
527
528
  route.middleware = `[${instrumentId ? `instrumentMiddleware(${JSON.stringify(instrumentId)}, ${load})` : load}]`;
528
529
  }
530
+ if (node.hydrateFallback) {
531
+ const fallback = genSafeVariableName(`HydrateFallback${context.nextLoadingId++}`);
532
+ context.loadingImports.push(`import ${fallback} from ${JSON.stringify(getImportPath(node.hydrateFallback, context.projectRoot))};`);
533
+ route.HydrateFallback = fallback;
534
+ }
529
535
  let loadingComponent;
530
536
  if (node.loading) {
531
537
  loadingComponent = genSafeVariableName(`Loading${context.nextLoadingId++}`);
@@ -845,6 +851,7 @@ async function matchRouteManifest(manifest, url) {
845
851
  loaders: collect("loader"),
846
852
  middleware: collect("middleware"),
847
853
  ...nearest("loading") ? { loading: nearest("loading") } : {},
854
+ ...nearest("hydrateFallback") ? { hydrateFallback: nearest("hydrateFallback") } : {},
848
855
  ...nearest("error") ? { error: nearest("error") } : {}
849
856
  };
850
857
  }
@@ -1175,6 +1182,44 @@ function routes(input = "src/pages") {
1175
1182
  onActivity: (event) => routeEvents.broadcast("activity", event)
1176
1183
  });
1177
1184
  const routeEvents = createRouteEventStream(getInspectorState, () => [["apps", appTracker.tabs()], ["activity-snapshot", appTracker.activity()]]);
1185
+ /**
1186
+ * Every module the plugin generates, for the DevTools Generated code page.
1187
+ * A module that fails to generate (for example on a route conflict)
1188
+ * reports its error instead of failing the others.
1189
+ */
1190
+ async function getGeneratedCode() {
1191
+ const capture = async (module, create) => {
1192
+ try {
1193
+ return {
1194
+ ...module,
1195
+ code: await create()
1196
+ };
1197
+ } catch (error) {
1198
+ return {
1199
+ ...module,
1200
+ error: error instanceof Error ? error.message : String(error)
1201
+ };
1202
+ }
1203
+ };
1204
+ const typesFile = declarationFile ?? path.resolve(projectRoot, DEFAULT_DTS);
1205
+ return Promise.all([
1206
+ capture({
1207
+ id: ROUTES_MODULE_ID,
1208
+ variant: "dev"
1209
+ }, () => execute(true)),
1210
+ capture({
1211
+ id: ROUTES_MODULE_ID,
1212
+ variant: "build"
1213
+ }, () => execute(false)),
1214
+ capture({ id: MANIFEST_MODULE_ID }, executeManifest),
1215
+ capture({ id: ROUTE_HELPERS_MODULE_ID }, executeRouteHelpers),
1216
+ capture({
1217
+ id: "route-types",
1218
+ file: path.relative(projectRoot, typesFile).split(path.sep).join("/"),
1219
+ written: Boolean(declarationFile)
1220
+ }, () => createRouteDeclarations(scan(root, options.conventions), projectRoot, typesFile))
1221
+ ]);
1222
+ }
1178
1223
  function writeDeclarations() {
1179
1224
  if (!declarationFile) return;
1180
1225
  const content = createRouteDeclarations(scan(root, options.conventions), projectRoot, declarationFile);
@@ -1191,12 +1236,13 @@ function routes(input = "src/pages") {
1191
1236
  return leftSource.localeCompare(rightSource);
1192
1237
  });
1193
1238
  }
1194
- async function execute() {
1239
+ /** `devClient` adds the DevTools client, which only the dev server serves. */
1240
+ async function execute(devClient = dev) {
1195
1241
  const tree = scan(root, options.conventions);
1196
1242
  const conflicts = findPageRouteConflicts(tree);
1197
1243
  if (conflicts.length) {
1198
1244
  const error = new RouteConflictError(conflicts);
1199
- if (!dev) throw error;
1245
+ if (!devClient) throw error;
1200
1246
  const code = `
1201
1247
  import { createElement } from "react";
1202
1248
 
@@ -1220,7 +1266,7 @@ function routes(input = "src/pages") {
1220
1266
  `;
1221
1267
  return format(code, { parser: "typescript" });
1222
1268
  }
1223
- const generated = generate(tree, true, projectRoot, { devClient: dev });
1269
+ const generated = generate(tree, true, projectRoot, { devClient });
1224
1270
  const sections = [
1225
1271
  sortImports(generated.imports).join("\n"),
1226
1272
  generated.helpers.join("\n"),
@@ -1435,7 +1481,8 @@ function routes(input = "src/pages") {
1435
1481
  "/__vite-file-router-data",
1436
1482
  "/__vite-file-router-events",
1437
1483
  "/__vite-file-router-match",
1438
- "/__vite-file-router-navigate"
1484
+ "/__vite-file-router-navigate",
1485
+ "/__vite-file-router-code"
1439
1486
  ].includes(url.pathname)) {
1440
1487
  next();
1441
1488
  return;
@@ -1447,6 +1494,10 @@ function routes(input = "src/pages") {
1447
1494
  res.end("Method Not Allowed");
1448
1495
  return;
1449
1496
  }
1497
+ if (url.pathname === "/__vite-file-router-code") {
1498
+ getGeneratedCode().then((modules) => sendJson(res, { modules }), next);
1499
+ return;
1500
+ }
1450
1501
  if (url.pathname === "/__vite-file-router-navigate") {
1451
1502
  navigateApp(req, res);
1452
1503
  return;
package/dist/scanner.d.ts CHANGED
@@ -10,9 +10,10 @@ export type Node = {
10
10
  loader?: string;
11
11
  action?: string;
12
12
  middleware?: string;
13
+ hydrateFallback?: string;
13
14
  children: Node[];
14
15
  };
15
- export type RouteFileKind = "page" | "layout" | "loading" | "notFound" | "error" | "loader" | "action" | "middleware";
16
+ export type RouteFileKind = "page" | "layout" | "loading" | "notFound" | "error" | "loader" | "action" | "middleware" | "hydrateFallback";
16
17
  export type RouteConventions = Partial<Record<RouteFileKind, string>>;
17
18
  export type RouteManifestNode = {
18
19
  segment: string | null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@troyio/vite-file-router",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "File-system routing plugin for Vite",
5
5
  "type": "module",
6
6
  "private": false,