@troyio/vite-file-router 0.4.0 → 0.5.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/devtools/app.js CHANGED
@@ -1,143 +1,1546 @@
1
- const treeElement = document.querySelector("#tree");
2
- const statusElement = document.querySelector("#status");
3
- const errorElement = document.querySelector("#error");
4
- const filterElement = document.querySelector("#filter");
5
- const toggleElement = document.querySelector("#toggle");
6
- const fileLabels = {
7
- page: "Page",
8
- layout: "Layout",
9
- loading: "Loading",
10
- notFound: "Not found",
11
- error: "Error boundary",
12
- };
13
-
14
- let manifest;
15
- let manifestSnapshot;
16
- let expanded = false;
17
- let expansionTouched = false;
18
- let refreshing = false;
19
-
20
- function createElement(tag, className, text) {
21
- const element = document.createElement(tag);
22
- if (className) element.className = className;
23
- if (text !== undefined) element.textContent = text;
24
- return element;
25
- }
26
-
27
- function renderNode(node, query, depth = 0) {
28
- const children = node.children
29
- .map((child) => renderNode(child, query, depth + 1))
30
- .filter(Boolean);
31
- const fileEntries = Object.entries(node.files ?? {});
32
- const searchText = [
33
- node.fullPath,
34
- ...node.params,
35
- ...fileEntries.flatMap(([kind, file]) => [kind, file]),
36
- ]
37
- .join(" ")
38
- .toLowerCase();
39
- const matches = !query || searchText.includes(query);
40
- if (!matches && children.length === 0) return null;
41
-
42
- const item = createElement("li", "route-node");
43
- const details = createElement("details", "route-details");
44
- details.open = query
45
- ? true
46
- : expansionTouched
47
- ? expanded
48
- : depth < 2;
49
- const summary = createElement("summary", "route-summary");
50
- const routePath = node.routeGroup
51
- ? `${node.fullPath} (route group)`
52
- : node.fullPath;
53
- summary.append(createElement("code", "route-path", routePath));
54
-
55
- if (node.routeGroup) {
56
- summary.append(createElement("span", "badge group-badge", "group"));
57
- }
58
- for (const param of node.params) {
59
- const optional = node.optionalParams?.includes(param);
60
- summary.append(
61
- createElement(
62
- "span",
63
- `badge ${optional ? "optional-badge" : "param-badge"}`,
64
- optional ? `${param}?` : param,
65
- ),
66
- );
67
- }
68
- details.append(summary);
69
-
70
- if (fileEntries.length) {
71
- const files = createElement("ul", "file-list");
72
- for (const [kind, file] of fileEntries) {
73
- const row = createElement("li", "file-row");
74
- row.append(
75
- createElement("span", "file-kind", fileLabels[kind] ?? kind),
76
- createElement("code", "file-path", file),
77
- );
78
- files.append(row);
79
- }
80
- details.append(files);
81
- }
82
-
83
- if (children.length) {
84
- const childList = createElement("ul", "route-list");
85
- childList.append(...children);
86
- details.append(childList);
87
- }
88
- item.append(details);
89
- return item;
90
- }
91
-
92
- function render() {
93
- if (!manifest) return;
94
- const query = filterElement.value.trim().toLowerCase();
95
- const root = renderNode(manifest, query);
96
- treeElement.replaceChildren();
97
-
98
- if (!root) {
99
- treeElement.append(createElement("p", "empty", "No routes match this filter."));
100
- } else {
101
- const list = createElement("ul", "route-list root-list");
102
- list.append(root);
103
- treeElement.append(list);
104
- }
105
- toggleElement.textContent = expanded ? "Collapse all" : "Expand all";
106
- toggleElement.disabled = Boolean(query);
107
- }
108
-
109
- async function refresh() {
110
- if (refreshing) return;
111
- refreshing = true;
112
- try {
113
- const response = await fetch("/__vite-file-router-data", {
114
- cache: "no-store",
115
- });
116
- if (!response.ok) throw new Error(`Request failed (${response.status})`);
117
- const nextManifest = await response.json();
118
- const nextSnapshot = JSON.stringify(nextManifest);
119
- const manifestChanged = nextSnapshot !== manifestSnapshot;
120
- manifest = nextManifest;
121
- manifestSnapshot = nextSnapshot;
122
- errorElement.hidden = true;
123
- statusElement.textContent = "Route data refreshes automatically.";
124
- if (manifestChanged) render();
125
- } catch (error) {
126
- errorElement.textContent = `Unable to load routes: ${error.message}`;
127
- errorElement.hidden = false;
128
- statusElement.textContent = "Route data is unavailable.";
129
- } finally {
130
- refreshing = false;
131
- }
132
- }
133
-
134
- filterElement.addEventListener("input", render);
135
- document.querySelector("#refresh").addEventListener("click", refresh);
136
- toggleElement.addEventListener("click", () => {
137
- expansionTouched = true;
138
- expanded = !expanded;
139
- render();
140
- });
141
-
142
- refresh();
143
- window.setInterval(refresh, 2000);
1
+ const DATA_URL = "/__vite-file-router-data";
2
+ const EVENTS_URL = "/__vite-file-router-events";
3
+ const MATCH_URL = "/__vite-file-router-match";
4
+ const OPEN_URL = "/__open-in-editor";
5
+ const NAVIGATE_URL = "/__vite-file-router-navigate";
6
+ const RECENT_PARAMS_KEY = "vite-file-router:recent-params";
7
+ const TESTER_STORAGE_KEY = "vite-file-router:tester-url";
8
+
9
+ const $ = (selector) => document.querySelector(selector);
10
+ const treeElement = $("#tree");
11
+ const statsElement = $("#stats");
12
+ const errorElement = $("#error");
13
+ const conflictsElement = $("#conflicts");
14
+ const warningsElement = $("#warnings");
15
+ const filterElement = $("#filter");
16
+ const toggleElement = $("#toggle");
17
+ const refreshElement = $("#refresh");
18
+ const liveElement = $("#live");
19
+ const liveLabelElement = $("#live-label");
20
+ const testerForm = $("#tester");
21
+ const testerInput = $("#tester-input");
22
+ const testerResult = $("#tester-result");
23
+ const detailsElement = $("#details");
24
+ const currentElement = $("#current");
25
+ const currentSubtitle = $("#current-subtitle");
26
+ const tabPicker = $("#tab-picker");
27
+ const inspectTab = $("#tab-inspect");
28
+ const timelineTab = $("#tab-timeline");
29
+ const inspectView = $("#view-inspect");
30
+ const timelineView = $("#view-timeline");
31
+ const timelineCount = $("#timeline-count");
32
+ const timelineElement = $("#timeline");
33
+ const timelineFilter = $("#timeline-filter");
34
+ const timelineTypes = $("#timeline-types");
35
+ const timelineClear = $("#timeline-clear");
36
+ const timelineRoutePill = $("#timeline-route");
37
+
38
+ const MAX_TIMELINE = 200;
39
+ const SLOW_CALL_MS = 500;
40
+
41
+ const FILE_KINDS = [
42
+ ["page", "Page", "page"],
43
+ ["layout", "Layout", "layout"],
44
+ ["loading", "Loading", "loading"],
45
+ ["error", "Error boundary", "error"],
46
+ ["notFound", "Not found", "404"],
47
+ ["loader", "Loader", "loader"],
48
+ ["action", "Action", "action"],
49
+ ["middleware", "Middleware", "mw"],
50
+ ];
51
+
52
+ const state = {
53
+ data: undefined,
54
+ snapshot: undefined,
55
+ nodes: new Map(),
56
+ collapsed: new Set(),
57
+ selected: "0",
58
+ match: undefined,
59
+ matchedKeys: new Set(),
60
+ // App tabs reported by the dev-only client in `virtual:routes`.
61
+ tabs: [],
62
+ followedTab: undefined,
63
+ currentKeys: new Set(),
64
+ // Loader, action, and revalidation calls, oldest first.
65
+ activity: [],
66
+ clearedThrough: 0,
67
+ unseen: 0,
68
+ timelineType: "all",
69
+ // Exact route ID the timeline is narrowed to, from a route's details.
70
+ timelineRoute: undefined,
71
+ openCalls: new Set(),
72
+ // Signature of the slow-loader warnings last rendered.
73
+ slowSignature: "",
74
+ };
75
+
76
+ // ---------------------------------------------------------------------------
77
+ // Helpers
78
+
79
+ function el(tag, attributes = {}, ...children) {
80
+ const element = document.createElement(tag);
81
+ for (const [name, value] of Object.entries(attributes)) {
82
+ if (value === undefined || value === false) continue;
83
+ if (name === "class") element.className = value;
84
+ else if (name.startsWith("on")) element.addEventListener(name.slice(2), value);
85
+ else element.setAttribute(name, value === true ? "" : value);
86
+ }
87
+ element.append(...children.flat().filter((child) => child !== undefined && child !== null));
88
+ return element;
89
+ }
90
+
91
+ function icon(name) {
92
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
93
+ svg.setAttribute("aria-hidden", "true");
94
+ const use = document.createElementNS("http://www.w3.org/2000/svg", "use");
95
+ use.setAttribute("href", `#icon-${name}`);
96
+ svg.append(use);
97
+ return svg;
98
+ }
99
+
100
+ let toastTimer;
101
+ function toast(message) {
102
+ document.querySelector(".toast")?.remove();
103
+ const element = el("div", { class: "toast", role: "status" }, message);
104
+ document.body.append(element);
105
+ clearTimeout(toastTimer);
106
+ toastTimer = setTimeout(() => element.remove(), 2200);
107
+ }
108
+
109
+ function readStorage(key) {
110
+ try {
111
+ return localStorage.getItem(key) ?? "";
112
+ } catch {
113
+ return "";
114
+ }
115
+ }
116
+
117
+ function writeStorage(key, value) {
118
+ try {
119
+ localStorage.setItem(key, value);
120
+ } catch {
121
+ // Storage can be unavailable in sandboxed frames; the tester still works.
122
+ }
123
+ }
124
+
125
+ function firstFile(node) {
126
+ const file = Object.values(node.files)[0];
127
+ if (file) return file;
128
+ for (const child of node.children) {
129
+ const descendant = firstFile(child);
130
+ if (descendant) return descendant;
131
+ }
132
+ return undefined;
133
+ }
134
+
135
+ /**
136
+ * The manifest omits route-group names from URLs, so read the directory name
137
+ * from a file inside the group: it sits at the group's depth below the routes
138
+ * directory.
139
+ */
140
+ function groupName(node) {
141
+ const file = firstFile(node);
142
+ const key = [...state.nodes].find(([, entry]) => entry.node === node)?.[0];
143
+ if (!file || !key || !state.data) return "(group)";
144
+ const depth = key.split(".").length - 1;
145
+ const base = state.data.directory.split("/").filter(Boolean).length;
146
+ return file.split("/")[base + depth - 1] ?? "(group)";
147
+ }
148
+
149
+ function segmentLabel(node, key) {
150
+ if (key === "0") return [el("span", { class: "slash" }, "/")];
151
+ if (node.routeGroup) return [el("span", { class: "group" }, groupName(node))];
152
+ const segment = node.segment;
153
+ const className = segment.endsWith("?")
154
+ ? "optional"
155
+ : segment.startsWith(":") || segment === "*"
156
+ ? "param"
157
+ : undefined;
158
+ return [el("span", { class: "slash" }, "/"), el("span", { class: className }, segment)];
159
+ }
160
+
161
+ function sameNode(node, segment) {
162
+ return (
163
+ node.fullPath === segment.fullPath &&
164
+ node.routeGroup === segment.routeGroup &&
165
+ JSON.stringify(node.files) === JSON.stringify(segment.files)
166
+ );
167
+ }
168
+
169
+ function examplePath(fullPath) {
170
+ return (
171
+ fullPath
172
+ .split("/")
173
+ .filter((segment) => !segment.endsWith("?"))
174
+ .map((segment) => (segment === "*" ? "any/path" : segment.startsWith(":") ? "1" : segment))
175
+ .join("/") || "/"
176
+ );
177
+ }
178
+
179
+ // ---------------------------------------------------------------------------
180
+ // Editor integration
181
+
182
+ async function openInEditor(file, button) {
183
+ if (!state.data) return;
184
+ const absolute = `${state.data.projectRoot.replace(/[\\/]$/, "")}/${file}`;
185
+ try {
186
+ const response = await fetch(`${OPEN_URL}?file=${encodeURIComponent(absolute)}`);
187
+ if (!response.ok) throw new Error(`status ${response.status}`);
188
+ button?.classList.add("opened");
189
+ setTimeout(() => button?.classList.remove("opened"), 900);
190
+ toast(`Opened ${file.split("/").pop()} in your editor`);
191
+ } catch (error) {
192
+ toast(`Couldn't open the file: ${error.message}`);
193
+ }
194
+ }
195
+
196
+ function fileLink(file) {
197
+ const button = el(
198
+ "button",
199
+ {
200
+ class: "file-link",
201
+ type: "button",
202
+ title: `Open ${file} in your editor`,
203
+ onclick: () => openInEditor(file, button),
204
+ },
205
+ // `direction: rtl` keeps the filename visible when the path is truncated;
206
+ // the bidi isolate stops punctuation from being reordered.
207
+ el("span", {}, `⁨${file}⁩`),
208
+ icon("open"),
209
+ );
210
+ return button;
211
+ }
212
+
213
+ // ---------------------------------------------------------------------------
214
+ // Tree
215
+
216
+ function indexNodes(node, key = "0", parent) {
217
+ state.nodes.set(key, { node, parent });
218
+ node.children.forEach((child, index) => indexNodes(child, `${key}.${index}`, key));
219
+ }
220
+
221
+ function searchText(node) {
222
+ return [
223
+ node.fullPath,
224
+ node.routeGroup ? groupName(node) : "",
225
+ ...node.params,
226
+ ...Object.entries(node.files).flat(),
227
+ ]
228
+ .join(" ")
229
+ .toLowerCase();
230
+ }
231
+
232
+ function renderNode(node, key, query) {
233
+ const childItems = node.children
234
+ .map((child, index) => renderNode(child, `${key}.${index}`, query))
235
+ .filter(Boolean);
236
+ if (query && !searchText(node).includes(query) && childItems.length === 0) return null;
237
+
238
+ const hasChildren = node.children.length > 0;
239
+ const expanded = Boolean(query) || !state.collapsed.has(key);
240
+ const fileKinds = FILE_KINDS.filter(([kind]) => node.files[kind]);
241
+
242
+ const row = el(
243
+ "div",
244
+ {
245
+ class: `row${state.matchedKeys.has(key) ? " matched" : ""}${state.currentKeys.has(key) ? " current" : ""}`,
246
+ role: "treeitem",
247
+ tabindex: state.selected === key ? "0" : "-1",
248
+ "aria-selected": String(state.selected === key),
249
+ "aria-expanded": hasChildren ? String(expanded) : undefined,
250
+ "data-key": key,
251
+ onclick: () => select(key),
252
+ onkeydown: (event) => onRowKey(event, key, hasChildren, expanded),
253
+ },
254
+ hasChildren
255
+ ? el(
256
+ "button",
257
+ {
258
+ class: "twisty",
259
+ type: "button",
260
+ tabindex: "-1",
261
+ "aria-label": expanded ? "Collapse" : "Expand",
262
+ "aria-expanded": String(expanded),
263
+ onclick: (event) => {
264
+ event.stopPropagation();
265
+ toggle(key);
266
+ },
267
+ },
268
+ icon("chevron"),
269
+ )
270
+ : el("span", { class: "twisty-spacer" }),
271
+ el("span", { class: "segment" }, segmentLabel(node, key)),
272
+ key !== "0" && !node.routeGroup && node.fullPath !== `/${node.segment}`
273
+ ? el("span", { class: "full-path" }, node.fullPath)
274
+ : undefined,
275
+ el(
276
+ "span",
277
+ { class: "row-files" },
278
+ node.routeGroup ? el("span", { class: "chip chip-group" }, "group") : undefined,
279
+ warningChip(key),
280
+ rowOpenButton(node, key),
281
+ fileKinds.map(([kind, label, short]) =>
282
+ el("span", { class: `chip chip-${kind}`, title: `${label}: ${node.files[kind]}` }, short),
283
+ ),
284
+ ),
285
+ );
286
+
287
+ return el(
288
+ "li",
289
+ { role: "none" },
290
+ row,
291
+ hasChildren && expanded ? el("ul", { role: "group" }, childItems) : undefined,
292
+ );
293
+ }
294
+
295
+ function renderTree() {
296
+ const query = filterElement.value.trim().toLowerCase();
297
+ const root = state.data && renderNode(state.data.manifest, "0", query);
298
+ treeElement.replaceChildren(
299
+ root
300
+ ? el("ul", { role: "none" }, root)
301
+ : el("p", { class: "empty" }, state.data ? "No routes match this filter." : "Loading routes…"),
302
+ );
303
+
304
+ const allExpanded = state.collapsed.size === 0;
305
+ toggleElement.replaceChildren(icon(allExpanded ? "collapse" : "expand"));
306
+ toggleElement.setAttribute("aria-label", allExpanded ? "Collapse all" : "Expand all");
307
+ toggleElement.title = allExpanded ? "Collapse all" : "Expand all";
308
+ toggleElement.disabled = Boolean(query);
309
+ }
310
+
311
+ function toggle(key) {
312
+ if (state.collapsed.has(key)) state.collapsed.delete(key);
313
+ else state.collapsed.add(key);
314
+ renderTree();
315
+ focusRow(key);
316
+ }
317
+
318
+ function select(key) {
319
+ state.selected = key;
320
+ renderTree();
321
+ renderDetails();
322
+ focusRow(key);
323
+ }
324
+
325
+ function focusRow(key) {
326
+ treeElement.querySelector(`[data-key="${CSS.escape(key)}"]`)?.focus();
327
+ }
328
+
329
+ function onRowKey(event, key, hasChildren, expanded) {
330
+ const rows = [...treeElement.querySelectorAll(".row")];
331
+ const index = rows.findIndex((row) => row.dataset.key === key);
332
+ const move = (offset) => {
333
+ const target = rows[index + offset];
334
+ if (target) select(target.dataset.key);
335
+ };
336
+ if (event.key === "ArrowDown") move(1);
337
+ else if (event.key === "ArrowUp") move(-1);
338
+ else if (event.key === "ArrowRight" && hasChildren && !expanded) toggle(key);
339
+ else if (event.key === "ArrowLeft" && hasChildren && expanded) toggle(key);
340
+ else if (event.key === "ArrowLeft") {
341
+ const parent = state.nodes.get(key)?.parent;
342
+ if (parent) select(parent);
343
+ } else return;
344
+ event.preventDefault();
345
+ }
346
+
347
+ // ---------------------------------------------------------------------------
348
+ // Summary, conflicts, and details
349
+
350
+ function renderStats() {
351
+ const counts = { pages: 0, dynamic: 0, layouts: 0, loaders: 0 };
352
+ for (const { node } of state.nodes.values()) {
353
+ if (node.files.page) counts.pages++;
354
+ if (node.files.page && /[:*]/.test(node.fullPath)) counts.dynamic++;
355
+ if (node.files.layout) counts.layouts++;
356
+ if (node.files.loader) counts.loaders++;
357
+ }
358
+ statsElement.replaceChildren(
359
+ ...[
360
+ [counts.pages, counts.pages === 1 ? "page" : "pages"],
361
+ [counts.dynamic, "dynamic"],
362
+ [counts.layouts, counts.layouts === 1 ? "layout" : "layouts"],
363
+ [counts.loaders, counts.loaders === 1 ? "loader" : "loaders"],
364
+ ].map(([value, label]) => el("div", { class: "stat" }, el("dt", {}, label), el("dd", {}, String(value)))),
365
+ );
366
+ }
367
+
368
+ function renderConflicts() {
369
+ const conflicts = state.data?.conflicts ?? [];
370
+ conflictsElement.hidden = conflicts.length === 0;
371
+ if (!conflicts.length) return;
372
+ conflictsElement.replaceChildren(
373
+ el("h2", {}, conflicts.length === 1 ? "1 route conflict" : `${conflicts.length} route conflicts`),
374
+ el("p", {}, "These pages resolve to the same URL pattern. Rename or move one of them in each group."),
375
+ ...conflicts.map(({ routePath, files }) =>
376
+ el(
377
+ "div",
378
+ { class: "conflict" },
379
+ el("code", {}, routePath),
380
+ el("div", { class: "facts" }, files.map((file) => fileLink(file))),
381
+ ),
382
+ ),
383
+ );
384
+ }
385
+
386
+ // ---------------------------------------------------------------------------
387
+ // Missing-file warnings
388
+
389
+ /**
390
+ * Loaders the timeline measured as slow. Loading files only cover lazy page
391
+ * code, so these suggest pending UI instead of another file.
392
+ */
393
+ function slowLoaderWarnings() {
394
+ const slowest = new Map();
395
+ for (const call of state.activity) {
396
+ if (call.id <= state.clearedThrough) continue;
397
+ if (call.type !== "loader" || !(call.duration >= SLOW_CALL_MS)) continue;
398
+ if (!(slowest.get(call.routeId) >= call.duration)) slowest.set(call.routeId, call.duration);
399
+ }
400
+ return [...slowest].flatMap(([routeId, duration]) => {
401
+ const found = nodeForRouteId(routeId);
402
+ if (!found) return [];
403
+ return [
404
+ {
405
+ kind: "slow-loader",
406
+ severity: "info",
407
+ key: found.key,
408
+ 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.`,
410
+ files: found.node.files.loader ? [found.node.files.loader] : [],
411
+ },
412
+ ];
413
+ });
414
+ }
415
+
416
+ function allWarnings() {
417
+ return [...(state.data?.warnings ?? []), ...slowLoaderWarnings()];
418
+ }
419
+
420
+ function warningsFor(key) {
421
+ return allWarnings().filter((warning) => warning.key === key);
422
+ }
423
+
424
+ function warningChip(key) {
425
+ const warnings = warningsFor(key);
426
+ if (!warnings.length) return undefined;
427
+ const severe = warnings.some((warning) => warning.severity === "warning");
428
+ return el(
429
+ "span",
430
+ {
431
+ class: `chip chip-warn${severe ? "" : " chip-hint"}`,
432
+ title: warnings.map((warning) => warning.message).join("\n"),
433
+ },
434
+ severe ? "warn" : "hint",
435
+ );
436
+ }
437
+
438
+ function renderWarning(warning, withRoute = true) {
439
+ return el(
440
+ "li",
441
+ { class: `warning warning-${warning.severity}` },
442
+ el(
443
+ "div",
444
+ { class: "warning-head" },
445
+ el("span", { class: `chip chip-warn${warning.severity === "warning" ? "" : " chip-hint"}` }, warning.severity === "warning" ? "warn" : "hint"),
446
+ withRoute
447
+ ? el(
448
+ "button",
449
+ { class: "link-button warning-route", type: "button", onclick: () => revealRoute(warning.key) },
450
+ warning.fullPath,
451
+ )
452
+ : undefined,
453
+ ),
454
+ el("p", {}, warning.message),
455
+ warning.files.length ? el("div", { class: "facts" }, warning.files.map((file) => fileLink(file))) : undefined,
456
+ );
457
+ }
458
+
459
+ function revealRoute(key) {
460
+ if (!state.nodes.has(key)) return;
461
+ for (let parent = state.nodes.get(key)?.parent; parent; parent = state.nodes.get(parent)?.parent) {
462
+ state.collapsed.delete(parent);
463
+ }
464
+ showView("inspect");
465
+ select(key);
466
+ }
467
+
468
+ function renderWarnings() {
469
+ const warnings = allWarnings();
470
+ warningsElement.hidden = warnings.length === 0;
471
+ if (!warnings.length) return;
472
+ const severe = warnings.filter((warning) => warning.severity === "warning").length;
473
+ const open = warningsElement.querySelector("details")?.open ?? false;
474
+ warningsElement.replaceChildren(
475
+ el(
476
+ "details",
477
+ { open: open || undefined },
478
+ el(
479
+ "summary",
480
+ {},
481
+ el("h2", {}, warnings.length === 1 ? "1 route warning" : `${warnings.length} route warnings`),
482
+ el(
483
+ "span",
484
+ { class: "muted" },
485
+ severe === warnings.length
486
+ ? "Missing files and exports"
487
+ : [
488
+ severe ? `${severe} warning${severe === 1 ? "" : "s"}` : "",
489
+ `${warnings.length - severe} hint${warnings.length - severe === 1 ? "" : "s"}`,
490
+ ]
491
+ .filter(Boolean)
492
+ .join(", "),
493
+ ),
494
+ ),
495
+ el("ul", { class: "warning-list" }, warnings.map((warning) => renderWarning(warning))),
496
+ ),
497
+ );
498
+ }
499
+
500
+ /** Re-renders warnings when the timeline finds a new slow loader. */
501
+ function refreshSlowWarnings() {
502
+ const signature = slowLoaderWarnings()
503
+ .map((warning) => `${warning.key}:${warning.message}`)
504
+ .join("|");
505
+ if (signature === state.slowSignature) return;
506
+ state.slowSignature = signature;
507
+ renderWarnings();
508
+ renderTree();
509
+ renderDetails();
510
+ }
511
+
512
+ function fact(label, ...content) {
513
+ return el("div", {}, el("dt", {}, label), el("dd", {}, ...content));
514
+ }
515
+
516
+ function renderDetails() {
517
+ const entry = state.nodes.get(state.selected) ?? state.nodes.get("0");
518
+ if (!entry) {
519
+ detailsElement.replaceChildren(el("p", { class: "empty" }, "Select a route to see its files."));
520
+ return;
521
+ }
522
+ const { node } = entry;
523
+ const files = FILE_KINDS.filter(([kind]) => node.files[kind]);
524
+ const params = [];
525
+ for (let key = state.selected; key; key = state.nodes.get(key)?.parent) {
526
+ const current = state.nodes.get(key)?.node;
527
+ current?.params.forEach((param) =>
528
+ params.unshift({ param, optional: current.optionalParams?.includes(param) }),
529
+ );
530
+ }
531
+
532
+ detailsElement.replaceChildren(
533
+ el(
534
+ "div",
535
+ { class: "details-body" },
536
+ el(
537
+ "div",
538
+ { class: "details-path" },
539
+ node.routeGroup ? `${node.fullPath} · ${groupName(node)}` : node.fullPath,
540
+ ),
541
+ warningsFor(state.selected).length
542
+ ? el(
543
+ "ul",
544
+ { class: "warning-list" },
545
+ warningsFor(state.selected).map((warning) => renderWarning(warning, false)),
546
+ )
547
+ : undefined,
548
+ el(
549
+ "dl",
550
+ { class: "facts" },
551
+ node.routeGroup
552
+ ? fact("Route group", el("span", { class: "muted" }, "Organizes files without adding a URL segment."))
553
+ : undefined,
554
+ params.length
555
+ ? fact(
556
+ "Params",
557
+ el(
558
+ "div",
559
+ { class: "params" },
560
+ params.map(({ param, optional }) =>
561
+ el("span", { class: "param-pill" }, el("span", {}, optional ? `${param}?` : param)),
562
+ ),
563
+ ),
564
+ )
565
+ : undefined,
566
+ files.length
567
+ ? files.map(([kind, label]) => fact(label, fileLink(node.files[kind])))
568
+ : fact("Files", el("span", { class: "muted" }, "No route files in this directory.")),
569
+ node.files.page
570
+ ? fact(
571
+ "Try it",
572
+ el(
573
+ "button",
574
+ {
575
+ class: "link-button",
576
+ type: "button",
577
+ onclick: () => {
578
+ testerInput.value = examplePath(node.fullPath);
579
+ runMatch();
580
+ },
581
+ },
582
+ `Test ${examplePath(node.fullPath)}`,
583
+ ),
584
+ )
585
+ : undefined,
586
+ node.files.page ? fact("Open in app", openForm(node, params)) : undefined,
587
+ ),
588
+ dataStats(node),
589
+ ),
590
+ );
591
+ }
592
+
593
+ // ---------------------------------------------------------------------------
594
+ // URL tester
595
+
596
+ function keysForChain(chain) {
597
+ const keys = new Set();
598
+ if (!state.data || !chain.length) return keys;
599
+ let key = "0";
600
+ let node = state.data.manifest;
601
+ if (!sameNode(node, chain[0])) return keys;
602
+ keys.add(key);
603
+ for (const segment of chain.slice(1)) {
604
+ const index = node.children.findIndex((child) => sameNode(child, segment));
605
+ if (index === -1) break;
606
+ key = `${key}.${index}`;
607
+ node = node.children[index];
608
+ keys.add(key);
609
+ }
610
+ return keys;
611
+ }
612
+
613
+ function renderMatch() {
614
+ const result = state.match;
615
+ if (!result) {
616
+ testerResult.replaceChildren();
617
+ return;
618
+ }
619
+ if (result.failure) {
620
+ testerResult.replaceChildren(el("p", { class: "muted" }, result.failure));
621
+ return;
622
+ }
623
+ if (!result.matched) {
624
+ testerResult.replaceChildren(
625
+ el(
626
+ "div",
627
+ { class: "result-status" },
628
+ el("span", { class: "chip chip-error" }, "No match"),
629
+ el("span", { class: "result-pathname" }, result.pathname),
630
+ ),
631
+ el("p", { class: "muted" }, "No route handles this URL, and there's no not-found page above it."),
632
+ );
633
+ return;
634
+ }
635
+
636
+ const params = Object.entries(result.params);
637
+ const keys = [...keysForChain(result.chain)];
638
+ testerResult.replaceChildren(
639
+ el(
640
+ "div",
641
+ { class: "result-status" },
642
+ result.notFound
643
+ ? el("span", { class: "chip chip-notFound" }, "Not-found page")
644
+ : el("span", { class: "chip chip-loader" }, "Matched"),
645
+ el("span", { class: "result-pathname" }, result.pathname),
646
+ testedUrl() ? el("span", { class: "result-open" }, openButton(testedUrl())) : undefined,
647
+ ),
648
+ el(
649
+ "div",
650
+ { class: "crumbs", "aria-label": "Matched routes" },
651
+ result.chain.flatMap((segment, index) => {
652
+ const key = keys[index];
653
+ const label =
654
+ index === 0
655
+ ? "/"
656
+ : segment.routeGroup
657
+ ? groupName(state.nodes.get(key)?.node ?? segment)
658
+ : segment.segment;
659
+ return [
660
+ index > 0 ? el("span", { class: "crumb-sep", "aria-hidden": "true" }, "›") : undefined,
661
+ el(
662
+ "button",
663
+ { class: "crumb", type: "button", onclick: () => key && select(key) },
664
+ label,
665
+ ),
666
+ ];
667
+ }),
668
+ ),
669
+ el(
670
+ "dl",
671
+ { class: "facts" },
672
+ params.length
673
+ ? fact(
674
+ "Params",
675
+ el(
676
+ "div",
677
+ { class: "params" },
678
+ params.map(([name, value]) =>
679
+ el(
680
+ "span",
681
+ { class: "param-pill" },
682
+ el("span", {}, name),
683
+ value === undefined
684
+ ? el("span", { class: "missing" }, "undefined")
685
+ : el("span", {}, value),
686
+ ),
687
+ ),
688
+ ),
689
+ )
690
+ : undefined,
691
+ result.page ? fact("Page", fileLink(result.page)) : undefined,
692
+ result.notFound ? fact("Not-found page", fileLink(result.notFound)) : undefined,
693
+ result.layouts.length ? fact("Layouts, outermost first", result.layouts.map(fileLink)) : undefined,
694
+ result.middleware?.length ? fact("Middleware, outermost first", result.middleware.map(fileLink)) : undefined,
695
+ result.loaders.length ? fact("Loaders", result.loaders.map(fileLink)) : undefined,
696
+ result.loading ? fact("Loading fallback", fileLink(result.loading)) : undefined,
697
+ result.error ? fact("Error boundary", fileLink(result.error)) : undefined,
698
+ ),
699
+ );
700
+ }
701
+
702
+ /** The tester's URL as a same-origin path, or undefined. */
703
+ function testedUrl() {
704
+ const url = testerInput.value.trim();
705
+ if (!url) return undefined;
706
+ const target = url.startsWith("/") ? url : /^[a-z]+:/i.test(url) ? undefined : `/${url}`;
707
+ return target && !target.startsWith("//") ? target : undefined;
708
+ }
709
+
710
+ async function runMatch() {
711
+ const url = testerInput.value.trim();
712
+ writeStorage(TESTER_STORAGE_KEY, url);
713
+ if (!url) {
714
+ state.match = undefined;
715
+ state.matchedKeys = new Set();
716
+ renderMatch();
717
+ renderTree();
718
+ return;
719
+ }
720
+ const target = url.startsWith("/") || /^[a-z]+:/i.test(url) ? url : `/${url}`;
721
+ try {
722
+ const response = await fetch(`${MATCH_URL}?url=${encodeURIComponent(target)}`, { cache: "no-store" });
723
+ if (!response.ok) throw new Error(`Request failed (${response.status})`);
724
+ state.match = await response.json();
725
+ state.matchedKeys = state.match.matched ? keysForChain(state.match.chain) : new Set();
726
+ for (const key of state.matchedKeys) {
727
+ for (let parent = state.nodes.get(key)?.parent; parent; parent = state.nodes.get(parent)?.parent) {
728
+ state.collapsed.delete(parent);
729
+ }
730
+ }
731
+ } catch (error) {
732
+ state.match = { failure: `Couldn't match the URL: ${error.message}` };
733
+ state.matchedKeys = new Set();
734
+ }
735
+ renderMatch();
736
+ renderTree();
737
+ }
738
+
739
+ // ---------------------------------------------------------------------------
740
+ // Data and live updates
741
+
742
+ // ---------------------------------------------------------------------------
743
+ // Current page
744
+
745
+ function followedTab() {
746
+ return state.tabs.find((tab) => tab.id === state.followedTab) ?? state.tabs[0];
747
+ }
748
+
749
+ function updateCurrentKeys() {
750
+ const tab = followedTab();
751
+ state.currentKeys =
752
+ tab?.match?.matched && state.data ? keysForChain(tab.match.chain) : new Set();
753
+ }
754
+
755
+ function renderCurrent() {
756
+ const tab = followedTab();
757
+ tabPicker.hidden = state.tabs.length < 2;
758
+ tabPicker.replaceChildren(
759
+ ...state.tabs.map((item, index) =>
760
+ el(
761
+ "button",
762
+ {
763
+ type: "button",
764
+ "aria-pressed": String(item === tab),
765
+ title: item.url,
766
+ onclick: () => {
767
+ state.followedTab = item.id;
768
+ updateCurrentKeys();
769
+ renderCurrent();
770
+ renderTree();
771
+ },
772
+ },
773
+ item.title || `Tab ${index + 1}`,
774
+ ),
775
+ ),
776
+ );
777
+
778
+ if (!tab) {
779
+ currentSubtitle.textContent = "Follows the route your app is showing.";
780
+ currentElement.replaceChildren(
781
+ el("p", { class: "current-empty" }, "Open your app in a browser tab to follow its current route."),
782
+ );
783
+ return;
784
+ }
785
+
786
+ currentSubtitle.textContent =
787
+ state.tabs.length > 1
788
+ ? `${state.tabs.length} app tabs are open. Pick one to follow.`
789
+ : "Updates as your app navigates.";
790
+ const { match } = tab;
791
+ const params = match.matched ? Object.entries(match.params) : [];
792
+ currentElement.replaceChildren(
793
+ el(
794
+ "div",
795
+ { class: "current-url" },
796
+ el("span", { class: "current-dot", "aria-hidden": "true" }),
797
+ el("code", { title: tab.url }, tab.url),
798
+ ),
799
+ match.matched
800
+ ? el(
801
+ "dl",
802
+ { class: "facts" },
803
+ params.length
804
+ ? fact(
805
+ "Params",
806
+ el(
807
+ "div",
808
+ { class: "params" },
809
+ params.map(([name, value]) =>
810
+ el(
811
+ "span",
812
+ { class: "param-pill" },
813
+ el("span", {}, name),
814
+ value === undefined
815
+ ? el("span", { class: "missing" }, "undefined")
816
+ : el("span", {}, value),
817
+ ),
818
+ ),
819
+ ),
820
+ )
821
+ : undefined,
822
+ match.page ? fact("Page", fileLink(match.page)) : undefined,
823
+ match.notFound ? fact("Not-found page", fileLink(match.notFound)) : undefined,
824
+ )
825
+ : el("p", { class: "muted" }, "No route matches this URL."),
826
+ );
827
+ }
828
+
829
+ // ---------------------------------------------------------------------------
830
+ // Open in app
831
+
832
+ /**
833
+ * Builds a URL for a route pattern with the same rules as `routePath()`:
834
+ * required params can't be empty, optional ones are dropped when empty (and
835
+ * then no later param may be given), and splats keep their slashes.
836
+ */
837
+ function buildPath(fullPath, values) {
838
+ let omitted;
839
+ const segments = [];
840
+ for (const segment of fullPath.split("/").filter(Boolean)) {
841
+ if (segment === "*") {
842
+ const value = (values["*"] ?? "").trim();
843
+ if (value && omitted) throw new Error(`Fill in "${omitted}" before the rest of the path.`);
844
+ if (value) segments.push(value.replace(/^\/+/, "").split("/").map(encodeURIComponent).join("/"));
845
+ continue;
846
+ }
847
+ if (!segment.startsWith(":")) {
848
+ segments.push(segment);
849
+ continue;
850
+ }
851
+ const optional = segment.endsWith("?");
852
+ const name = segment.slice(1, optional ? -1 : undefined);
853
+ const value = (values[name] ?? "").trim();
854
+ if (!value) {
855
+ if (!optional) throw new Error(`Fill in "${name}".`);
856
+ omitted ??= name;
857
+ continue;
858
+ }
859
+ if (omitted) throw new Error(`Fill in "${omitted}" before "${name}".`);
860
+ segments.push(encodeURIComponent(value));
861
+ }
862
+ return `/${segments.join("/")}`;
863
+ }
864
+
865
+ function readRecentParams() {
866
+ try {
867
+ return JSON.parse(readStorage(RECENT_PARAMS_KEY) || "{}") ?? {};
868
+ } catch {
869
+ return {};
870
+ }
871
+ }
872
+
873
+ function rememberParams(fullPath, values) {
874
+ const recent = readRecentParams();
875
+ delete recent[fullPath];
876
+ recent[fullPath] = values;
877
+ const entries = Object.entries(recent).slice(-50);
878
+ writeStorage(RECENT_PARAMS_KEY, JSON.stringify(Object.fromEntries(entries)));
879
+ }
880
+
881
+ function openTarget() {
882
+ const tab = followedTab();
883
+ return tab ? `Open in ${tab.title || "app"}` : "Open in new tab";
884
+ }
885
+
886
+ async function openInApp(url) {
887
+ const tab = followedTab();
888
+ if (!tab) {
889
+ window.open(url, "_blank", "noopener");
890
+ toast(`Opened ${url} in a new tab`);
891
+ return;
892
+ }
893
+ try {
894
+ const response = await fetch(NAVIGATE_URL, {
895
+ method: "POST",
896
+ headers: { "Content-Type": "application/json" },
897
+ body: JSON.stringify({ tabId: tab.id, url }),
898
+ });
899
+ if (!response.ok) throw new Error((await response.text()) || `status ${response.status}`);
900
+ toast(`Opened ${url} in ${tab.title || "your app"}`);
901
+ } catch (error) {
902
+ toast(`Couldn't open the page: ${error.message}`);
903
+ }
904
+ }
905
+
906
+ function openButton(url) {
907
+ return el(
908
+ "button",
909
+ { class: "button button-quiet", type: "button", onclick: () => openInApp(url) },
910
+ icon("go"),
911
+ openTarget(),
912
+ );
913
+ }
914
+
915
+ /** "Open in app" for a route: a button, or a form when it has params. */
916
+ function openForm(node, params) {
917
+ if (!params.length) return openButton(node.fullPath);
918
+ const saved = readRecentParams()[node.fullPath] ?? {};
919
+ const message = el("p", { class: "form-error", role: "alert", hidden: true });
920
+ const inputs = params.map(({ param, optional }) =>
921
+ el(
922
+ "label",
923
+ { class: "param-field" },
924
+ el(
925
+ "span",
926
+ {},
927
+ param === "*" ? "rest of path" : param,
928
+ optional ? el("span", { class: "muted" }, " optional") : undefined,
929
+ ),
930
+ el("input", {
931
+ name: param,
932
+ type: "text",
933
+ value: saved[param] ?? "",
934
+ placeholder: param === "*" ? "a/b/c" : optional ? "" : "required",
935
+ autocomplete: "off",
936
+ spellcheck: "false",
937
+ }),
938
+ ),
939
+ );
940
+ const form = el(
941
+ "form",
942
+ {
943
+ class: "open-form",
944
+ id: "open-form",
945
+ onsubmit: (event) => {
946
+ event.preventDefault();
947
+ const values = Object.fromEntries(new FormData(form));
948
+ try {
949
+ const url = buildPath(node.fullPath, values);
950
+ message.hidden = true;
951
+ rememberParams(node.fullPath, values);
952
+ openInApp(url);
953
+ } catch (error) {
954
+ message.textContent = error.message;
955
+ message.hidden = false;
956
+ }
957
+ },
958
+ },
959
+ inputs,
960
+ message,
961
+ el("button", { class: "button button-quiet", type: "submit" }, icon("go"), openTarget()),
962
+ );
963
+ return form;
964
+ }
965
+
966
+ /** The tree row's open button: navigates directly, or shows the params form. */
967
+ function rowOpenButton(node, key) {
968
+ if (!node.files.page) return undefined;
969
+ const dynamic = /(^|\/)(:|\*)/.test(node.fullPath);
970
+ return el(
971
+ "button",
972
+ {
973
+ class: "row-open",
974
+ type: "button",
975
+ tabindex: "-1",
976
+ title: dynamic ? "Fill in params to open this route" : `${openTarget()}: ${node.fullPath}`,
977
+ "aria-label": dynamic ? "Fill in params to open this route" : `${openTarget()}: ${node.fullPath}`,
978
+ onclick: (event) => {
979
+ event.stopPropagation();
980
+ if (!dynamic) {
981
+ openInApp(node.fullPath);
982
+ return;
983
+ }
984
+ showView("inspect");
985
+ select(key);
986
+ document.querySelector("#open-form input")?.focus();
987
+ },
988
+ },
989
+ icon("go"),
990
+ );
991
+ }
992
+
993
+ // ---------------------------------------------------------------------------
994
+ // Loader and action timeline
995
+
996
+ function nodeForRouteId(routeId) {
997
+ const directory = routeId.replace(/^file-route:/, "");
998
+ for (const [key, { node }] of state.nodes) {
999
+ const file = node.files.loader ?? node.files.action ?? Object.values(node.files)[0];
1000
+ if (file && file.slice(0, file.lastIndexOf("/")) === directory) return { key, node };
1001
+ }
1002
+ return undefined;
1003
+ }
1004
+
1005
+ function routeLabel(routeId) {
1006
+ const found = nodeForRouteId(routeId);
1007
+ if (!found) return routeId.replace(/^file-route:/, "");
1008
+ return found.node.routeGroup ? `${found.node.fullPath} (${groupName(found.node)})` : found.node.fullPath;
1009
+ }
1010
+
1011
+ function shortUrl(url) {
1012
+ if (!url) return "";
1013
+ try {
1014
+ const parsed = new URL(url);
1015
+ return parsed.pathname + parsed.search;
1016
+ } catch {
1017
+ return url;
1018
+ }
1019
+ }
1020
+
1021
+ function formatTime(timestamp) {
1022
+ const date = new Date(timestamp);
1023
+ return `${date.toLocaleTimeString([], { hour12: false })}.${String(date.getMilliseconds()).padStart(3, "0")}`;
1024
+ }
1025
+
1026
+ function formatDuration(duration) {
1027
+ if (duration === undefined) return "";
1028
+ return duration < 10 ? `${duration.toFixed(1)} ms` : `${Math.round(duration)} ms`;
1029
+ }
1030
+
1031
+ function outcomeChip(call) {
1032
+ if (call.type === "revalidate") {
1033
+ return call.decision
1034
+ ? el("span", { class: "chip chip-loader" }, "revalidate")
1035
+ : el("span", { class: "chip chip-skipped" }, "skipped");
1036
+ }
1037
+ switch (call.outcome) {
1038
+ case "redirect":
1039
+ return el("span", { class: "chip chip-loading" }, `${call.status} redirect`);
1040
+ case "error":
1041
+ return el("span", { class: "chip chip-error" }, call.status ? `${call.status} thrown` : "error");
1042
+ case "next":
1043
+ return el("span", { class: "chip chip-loader" }, "next()");
1044
+ case "response":
1045
+ return el("span", { class: "chip chip-layout" }, `${call.status} response`);
1046
+ default:
1047
+ return el("span", { class: "chip chip-loader" }, "data");
1048
+ }
1049
+ }
1050
+
1051
+ function callText(call) {
1052
+ return [
1053
+ call.type,
1054
+ routeLabel(call.routeId),
1055
+ call.routeId,
1056
+ call.url,
1057
+ call.method,
1058
+ call.outcome,
1059
+ call.status,
1060
+ call.location,
1061
+ call.message,
1062
+ call.type === "revalidate" ? (call.decision ? "revalidate" : "skipped") : "",
1063
+ ]
1064
+ .filter((value) => value !== undefined)
1065
+ .join(" ")
1066
+ .toLowerCase();
1067
+ }
1068
+
1069
+ // ---------------------------------------------------------------------------
1070
+ // Call inspector
1071
+
1072
+ const MAX_TREE_CHILDREN = 200;
1073
+
1074
+ function jsonPreview(value) {
1075
+ if (Array.isArray(value)) return `Array(${value.length})`;
1076
+ if (value && typeof value === "object") {
1077
+ const keys = Object.keys(value);
1078
+ return `{${keys.slice(0, 4).join(", ")}${keys.length > 4 ? ", …" : ""}}`;
1079
+ }
1080
+ return undefined;
1081
+ }
1082
+
1083
+ function jsonLeaf(value) {
1084
+ const type = value === null ? "null" : typeof value;
1085
+ const text = type === "string" ? JSON.stringify(value) : String(value);
1086
+ return el("span", { class: `json-${type}` }, text);
1087
+ }
1088
+
1089
+ /** Renders parsed JSON as nested, collapsible `<details>` elements. */
1090
+ function jsonTree(value, name, depth = 0) {
1091
+ const label = name === undefined ? undefined : el("span", { class: "json-key" }, `${name}: `);
1092
+ if (!value || typeof value !== "object") {
1093
+ return el("div", { class: "json-row" }, label, jsonLeaf(value));
1094
+ }
1095
+ const entries = Array.isArray(value) ? value.map((item, index) => [index, item]) : Object.entries(value);
1096
+ return el(
1097
+ "details",
1098
+ { class: "json-node", open: depth < 2 || undefined },
1099
+ el("summary", {}, label, el("span", { class: "json-preview" }, jsonPreview(value))),
1100
+ el(
1101
+ "div",
1102
+ { class: "json-children" },
1103
+ entries.slice(0, MAX_TREE_CHILDREN).map(([key, item]) => jsonTree(item, key, depth + 1)),
1104
+ entries.length > MAX_TREE_CHILDREN
1105
+ ? el("div", { class: "muted" }, `… ${entries.length - MAX_TREE_CHILDREN} more`)
1106
+ : undefined,
1107
+ ),
1108
+ );
1109
+ }
1110
+
1111
+ function copyButton(text, label = "Copy") {
1112
+ return el(
1113
+ "button",
1114
+ {
1115
+ class: "link-button",
1116
+ type: "button",
1117
+ onclick: async () => {
1118
+ try {
1119
+ await navigator.clipboard.writeText(text);
1120
+ toast("Copied to the clipboard");
1121
+ } catch {
1122
+ toast("Couldn't copy: the clipboard isn't available here");
1123
+ }
1124
+ },
1125
+ },
1126
+ label,
1127
+ );
1128
+ }
1129
+
1130
+ function renderData(call) {
1131
+ if (call.data === undefined) {
1132
+ return call.preview ? el("pre", { class: "call-preview" }, call.preview) : undefined;
1133
+ }
1134
+ let content;
1135
+ if (call.dataTruncated) {
1136
+ content = el("pre", { class: "call-preview" }, `${call.data}…`);
1137
+ } else {
1138
+ try {
1139
+ const parsed = JSON.parse(call.data);
1140
+ content =
1141
+ parsed && typeof parsed === "object"
1142
+ ? el("div", { class: "json" }, jsonTree(parsed))
1143
+ : el("div", { class: "json" }, jsonLeaf(parsed));
1144
+ } catch {
1145
+ content = el("pre", { class: "call-preview" }, call.data);
1146
+ }
1147
+ }
1148
+ return el(
1149
+ "div",
1150
+ { class: "call-data" },
1151
+ content,
1152
+ el(
1153
+ "div",
1154
+ { class: "call-data-actions" },
1155
+ call.dataTruncated ? el("span", { class: "muted" }, "Truncated at 8 KB.") : undefined,
1156
+ copyButton(call.data, "Copy JSON"),
1157
+ ),
1158
+ );
1159
+ }
1160
+
1161
+ function pairTable(entries) {
1162
+ return el(
1163
+ "table",
1164
+ { class: "pairs" },
1165
+ el(
1166
+ "tbody",
1167
+ {},
1168
+ entries.map(([name, value]) => el("tr", {}, el("th", { scope: "row" }, name), el("td", {}, value))),
1169
+ ),
1170
+ );
1171
+ }
1172
+
1173
+ function paramPills(params) {
1174
+ return el(
1175
+ "div",
1176
+ { class: "params" },
1177
+ Object.entries(params).map(([name, value]) =>
1178
+ el("span", { class: "param-pill" }, el("span", {}, name), el("span", {}, value)),
1179
+ ),
1180
+ );
1181
+ }
1182
+
1183
+ function revalidateText(call) {
1184
+ return [
1185
+ call.decision
1186
+ ? "shouldRevalidate returned true, so the loader runs."
1187
+ : "shouldRevalidate returned false, so the loader was skipped.",
1188
+ call.defaultShouldRevalidate === undefined
1189
+ ? ""
1190
+ : ` React Router's default was to ${call.defaultShouldRevalidate ? "revalidate" : "skip"}.`,
1191
+ ].join("");
1192
+ }
1193
+
1194
+ function renderCallBody(call, found) {
1195
+ const routeFile = found?.node.files[call.type === "revalidate" ? "loader" : call.type];
1196
+ const status =
1197
+ call.status !== undefined && call.outcome !== "redirect" ? String(call.status) : undefined;
1198
+ return el(
1199
+ "div",
1200
+ { class: "call-body" },
1201
+ el(
1202
+ "dl",
1203
+ { class: "facts" },
1204
+ found
1205
+ ? fact(
1206
+ "Route",
1207
+ el("button", { class: "link-button", type: "button", onclick: () => revealRoute(found.key) }, routeLabel(call.routeId)),
1208
+ )
1209
+ : undefined,
1210
+ routeFile
1211
+ ? fact({ action: "Action", middleware: "Middleware" }[call.type] ?? "Loader", fileLink(routeFile))
1212
+ : undefined,
1213
+ call.type === "revalidate"
1214
+ ? [
1215
+ fact("Decision", el("span", {}, revalidateText(call))),
1216
+ call.currentUrl ? fact("From", el("code", { class: "file-path" }, call.currentUrl)) : undefined,
1217
+ call.url ? fact("To", el("code", { class: "file-path" }, call.url)) : undefined,
1218
+ call.formMethod ? fact("Form method", el("code", {}, call.formMethod)) : undefined,
1219
+ call.actionStatus !== undefined ? fact("Action status", el("code", {}, String(call.actionStatus))) : undefined,
1220
+ ]
1221
+ : call.url
1222
+ ? fact("Request", el("code", { class: "file-path" }, `${call.method ?? ""} ${call.url}`.trim()))
1223
+ : undefined,
1224
+ call.params ? fact("Params", paramPills(call.params)) : undefined,
1225
+ call.body?.kind === "form"
1226
+ ? fact(
1227
+ "Form data",
1228
+ call.body.entries.length ? pairTable(call.body.entries) : el("span", { class: "muted" }, "No fields."),
1229
+ )
1230
+ : undefined,
1231
+ call.body?.kind === "text"
1232
+ ? fact(`Body${call.body.contentType ? ` (${call.body.contentType})` : ""}`, el("pre", { class: "call-preview" }, call.body.text))
1233
+ : undefined,
1234
+ status ? fact("Status", el("code", {}, status)) : undefined,
1235
+ call.location ? fact("Redirects to", el("code", { class: "file-path" }, call.location)) : undefined,
1236
+ call.errorName || call.message
1237
+ ? fact(
1238
+ "Error",
1239
+ el(
1240
+ "div",
1241
+ { class: "call-error" },
1242
+ el("strong", {}, call.errorName ? `${call.errorName}: ` : ""),
1243
+ call.message ?? "",
1244
+ call.stack ? el("pre", { class: "call-preview call-stack" }, call.stack) : undefined,
1245
+ ),
1246
+ )
1247
+ : undefined,
1248
+ call.type !== "revalidate" && (call.data !== undefined || call.preview)
1249
+ ? fact(call.outcome === "error" ? "Thrown value" : "Returned data", renderData(call))
1250
+ : undefined,
1251
+ call.responseBody ? fact("Response body", el("pre", { class: "call-preview" }, call.responseBody)) : undefined,
1252
+ call.headers?.length
1253
+ ? fact(
1254
+ "Headers",
1255
+ el(
1256
+ "details",
1257
+ { class: "call-headers" },
1258
+ el("summary", {}, `${call.headers.length} header${call.headers.length === 1 ? "" : "s"}`),
1259
+ pairTable(call.headers),
1260
+ ),
1261
+ )
1262
+ : undefined,
1263
+ call.tabId && state.tabs.length > 1
1264
+ ? fact("Tab", el("span", { class: "muted" }, state.tabs.find((tab) => tab.id === call.tabId)?.title || call.tabId))
1265
+ : undefined,
1266
+ ),
1267
+ );
1268
+ }
1269
+
1270
+ function renderCall(call) {
1271
+ const found = nodeForRouteId(call.routeId);
1272
+ const details = el(
1273
+ "details",
1274
+ { class: "call", open: state.openCalls.has(call.id) || undefined },
1275
+ el(
1276
+ "summary",
1277
+ { class: "call-summary" },
1278
+ el("span", { class: `chip chip-${{ action: "action", revalidate: "group", middleware: "middleware" }[call.type] ?? "page"}` }, call.type),
1279
+ el("span", { class: "call-route", title: call.routeId }, routeLabel(call.routeId)),
1280
+ call.type === "revalidate"
1281
+ ? outcomeChip(call)
1282
+ : el(
1283
+ "span",
1284
+ { class: "call-duration-outcome" },
1285
+ outcomeChip(call),
1286
+ " ",
1287
+ el(
1288
+ "span",
1289
+ { class: `call-duration${call.duration >= SLOW_CALL_MS ? " slow" : ""}` },
1290
+ formatDuration(call.duration),
1291
+ ),
1292
+ ),
1293
+ el(
1294
+ "span",
1295
+ { class: "call-meta" },
1296
+ el("span", {}, `${call.method && call.method !== "GET" ? `${call.method} ` : ""}${shortUrl(call.url)}`),
1297
+ el("time", { datetime: new Date(call.startedAt).toISOString() }, formatTime(call.startedAt)),
1298
+ ),
1299
+ ),
1300
+ );
1301
+ // The body can be large, so it's only built when the call is opened.
1302
+ const build = () => {
1303
+ if (details.open && !details.querySelector(".call-body")) details.append(renderCallBody(call, found));
1304
+ };
1305
+ build();
1306
+ details.addEventListener("toggle", () => {
1307
+ if (details.open) state.openCalls.add(call.id);
1308
+ else state.openCalls.delete(call.id);
1309
+ build();
1310
+ });
1311
+ return el("li", {}, details);
1312
+ }
1313
+
1314
+ // ---------------------------------------------------------------------------
1315
+ // Per-route call stats
1316
+
1317
+ /** The route ID the generated routes give a directory with a loader or action. */
1318
+ function routeIdForNode(node) {
1319
+ const file = node.files.loader ?? node.files.action ?? node.files.middleware;
1320
+ return file ? `file-route:${file.slice(0, file.lastIndexOf("/"))}` : undefined;
1321
+ }
1322
+
1323
+ function dataStats(node) {
1324
+ const routeId = routeIdForNode(node);
1325
+ if (!routeId) return undefined;
1326
+ const calls = state.activity.filter(
1327
+ (call) => call.id > state.clearedThrough && call.routeId === routeId && call.type !== "revalidate",
1328
+ );
1329
+ const summary = (type) => {
1330
+ const list = calls.filter((call) => call.type === type);
1331
+ if (!list.length) return undefined;
1332
+ const durations = list.map((call) => call.duration ?? 0);
1333
+ const average = durations.reduce((total, value) => total + value, 0) / durations.length;
1334
+ const errors = list.filter((call) => call.outcome === "error").length;
1335
+ const last = list.at(-1);
1336
+ return el(
1337
+ "div",
1338
+ { class: "stat-row" },
1339
+ el("span", { class: "stat-type" }, { loader: "Loader", action: "Action", middleware: "Middleware" }[type]),
1340
+ el("span", {}, `${list.length} call${list.length === 1 ? "" : "s"}`),
1341
+ el("span", {}, `avg ${formatDuration(average)}`),
1342
+ el("span", { class: Math.max(...durations) >= SLOW_CALL_MS ? "slow" : undefined }, `max ${formatDuration(Math.max(...durations))}`),
1343
+ errors ? el("span", { class: "error-text" }, `${errors} error${errors === 1 ? "" : "s"}`) : undefined,
1344
+ el("span", { class: "muted" }, "last ", outcomeChip(last)),
1345
+ );
1346
+ };
1347
+ return el(
1348
+ "div",
1349
+ { class: "data-stats" },
1350
+ el("h3", {}, "Data calls"),
1351
+ calls.length
1352
+ ? [summary("middleware"), summary("loader"), summary("action")]
1353
+ : el("p", { class: "muted" }, "No calls recorded yet. Visit this route in your app."),
1354
+ calls.length
1355
+ ? el(
1356
+ "button",
1357
+ {
1358
+ class: "link-button",
1359
+ type: "button",
1360
+ onclick: () => {
1361
+ state.timelineRoute = routeId;
1362
+ showView("timeline");
1363
+ },
1364
+ },
1365
+ "Show these calls in the timeline",
1366
+ )
1367
+ : undefined,
1368
+ );
1369
+ }
1370
+
1371
+ function visibleCalls() {
1372
+ const query = timelineFilter.value.trim().toLowerCase();
1373
+ return state.activity
1374
+ .filter((call) => call.id > state.clearedThrough)
1375
+ .filter((call) => state.timelineType === "all" || call.type === state.timelineType)
1376
+ .filter((call) => !state.timelineRoute || call.routeId === state.timelineRoute)
1377
+ .filter((call) => !query || callText(call).includes(query))
1378
+ .reverse();
1379
+ }
1380
+
1381
+ function renderTimeline() {
1382
+ for (const button of timelineTypes.querySelectorAll("button")) {
1383
+ button.setAttribute("aria-pressed", String(button.dataset.type === state.timelineType));
1384
+ }
1385
+ timelineRoutePill.hidden = !state.timelineRoute;
1386
+ if (state.timelineRoute) {
1387
+ timelineRoutePill.replaceChildren(
1388
+ el("span", {}, "Route ", el("strong", {}, routeLabel(state.timelineRoute))),
1389
+ el(
1390
+ "button",
1391
+ {
1392
+ type: "button",
1393
+ "aria-label": "Show calls for every route",
1394
+ title: "Show calls for every route",
1395
+ onclick: () => {
1396
+ state.timelineRoute = undefined;
1397
+ renderTimeline();
1398
+ },
1399
+ },
1400
+ "×",
1401
+ ),
1402
+ );
1403
+ }
1404
+ const hasCalls = state.activity.some((call) => call.id > state.clearedThrough);
1405
+ timelineElement.dataset.empty = hasCalls
1406
+ ? "No calls match this filter."
1407
+ : "No loader or action calls yet. Navigate in your app to record some.";
1408
+ timelineElement.replaceChildren(...visibleCalls().map(renderCall));
1409
+ timelineCount.hidden = state.unseen === 0;
1410
+ timelineCount.textContent = state.unseen > 99 ? "99+" : String(state.unseen);
1411
+ }
1412
+
1413
+ function addActivity(calls) {
1414
+ state.activity.push(...calls);
1415
+ if (state.activity.length > MAX_TIMELINE) {
1416
+ state.activity.splice(0, state.activity.length - MAX_TIMELINE);
1417
+ }
1418
+ if (timelineView.hidden) state.unseen += calls.length;
1419
+ renderTimeline();
1420
+ refreshSlowWarnings();
1421
+ // Keep the selected route's call stats current.
1422
+ const selected = state.nodes.get(state.selected)?.node;
1423
+ const routeId = selected && routeIdForNode(selected);
1424
+ if (routeId && calls.some((call) => call.routeId === routeId)) renderDetails();
1425
+ }
1426
+
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
+
1437
+ function applyData(data) {
1438
+ const snapshot = JSON.stringify(data);
1439
+ errorElement.hidden = true;
1440
+ if (snapshot === state.snapshot) return;
1441
+ state.data = data;
1442
+ state.snapshot = snapshot;
1443
+ state.nodes = new Map();
1444
+ indexNodes(data.manifest);
1445
+ if (!state.nodes.has(state.selected)) state.selected = "0";
1446
+ renderStats();
1447
+ renderConflicts();
1448
+ state.slowSignature = "";
1449
+ renderWarnings();
1450
+ updateCurrentKeys();
1451
+ renderTree();
1452
+ renderDetails();
1453
+ renderCurrent();
1454
+ renderTimeline();
1455
+ if (testerInput.value.trim()) runMatch();
1456
+ }
1457
+
1458
+ async function refresh() {
1459
+ refreshElement.classList.remove("spinning");
1460
+ void refreshElement.offsetWidth;
1461
+ refreshElement.classList.add("spinning");
1462
+ try {
1463
+ const response = await fetch(DATA_URL, { cache: "no-store" });
1464
+ if (!response.ok) throw new Error(`Request failed (${response.status})`);
1465
+ applyData(await response.json());
1466
+ } catch (error) {
1467
+ errorElement.textContent = `Unable to load routes: ${error.message}`;
1468
+ errorElement.hidden = false;
1469
+ }
1470
+ }
1471
+
1472
+ function setLive(stateName, label) {
1473
+ liveElement.dataset.state = stateName;
1474
+ liveLabelElement.textContent = label;
1475
+ }
1476
+
1477
+ function connect() {
1478
+ if (!("EventSource" in window)) {
1479
+ setLive("offline", "Manual refresh");
1480
+ return;
1481
+ }
1482
+ const events = new EventSource(EVENTS_URL);
1483
+ events.addEventListener("open", () => setLive("live", "Live"));
1484
+ events.addEventListener("apps", (event) => {
1485
+ state.tabs = JSON.parse(event.data);
1486
+ updateCurrentKeys();
1487
+ renderCurrent();
1488
+ renderTree();
1489
+ // "Open in" buttons name the followed tab.
1490
+ if (!detailsElement.contains(document.activeElement)) renderDetails();
1491
+ renderMatch();
1492
+ });
1493
+ events.addEventListener("activity-snapshot", (event) => {
1494
+ // Sent on every (re)connect; keep only calls this panel hasn't seen.
1495
+ const known = new Set(state.activity.map((call) => call.id));
1496
+ const calls = JSON.parse(event.data).filter((call) => !known.has(call.id));
1497
+ if (calls.length) addActivity(calls);
1498
+ });
1499
+ events.addEventListener("activity", (event) => addActivity([JSON.parse(event.data)]));
1500
+ events.addEventListener("routes", (event) => {
1501
+ setLive("live", "Live");
1502
+ applyData(JSON.parse(event.data));
1503
+ });
1504
+ // EventSource reconnects on its own, including after a dev-server restart.
1505
+ events.addEventListener("error", () => setLive("reconnecting", "Reconnecting"));
1506
+ }
1507
+
1508
+ filterElement.addEventListener("input", renderTree);
1509
+ inspectTab.addEventListener("click", () => showView("inspect"));
1510
+ timelineTab.addEventListener("click", () => showView("timeline"));
1511
+ timelineFilter.addEventListener("input", renderTimeline);
1512
+ timelineTypes.addEventListener("click", (event) => {
1513
+ const type = event.target.closest("button")?.dataset.type;
1514
+ if (!type) return;
1515
+ state.timelineType = type;
1516
+ renderTimeline();
1517
+ });
1518
+ timelineClear.addEventListener("click", () => {
1519
+ state.clearedThrough = state.activity.at(-1)?.id ?? state.clearedThrough;
1520
+ state.openCalls.clear();
1521
+ renderTimeline();
1522
+ refreshSlowWarnings();
1523
+ renderDetails();
1524
+ });
1525
+ refreshElement.addEventListener("click", refresh);
1526
+ toggleElement.addEventListener("click", () => {
1527
+ if (state.collapsed.size === 0) {
1528
+ for (const [key, { node }] of state.nodes) {
1529
+ if (key !== "0" && node.children.length) state.collapsed.add(key);
1530
+ }
1531
+ } else {
1532
+ state.collapsed.clear();
1533
+ }
1534
+ renderTree();
1535
+ });
1536
+ testerForm.addEventListener("submit", (event) => {
1537
+ event.preventDefault();
1538
+ runMatch();
1539
+ });
1540
+
1541
+ testerInput.value = readStorage(TESTER_STORAGE_KEY);
1542
+ renderTree();
1543
+ renderCurrent();
1544
+ renderTimeline();
1545
+ refresh();
1546
+ connect();