@troyio/vite-file-router 0.4.0 → 0.4.1

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,961 @@
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 TESTER_STORAGE_KEY = "vite-file-router:tester-url";
6
+
7
+ const $ = (selector) => document.querySelector(selector);
8
+ const treeElement = $("#tree");
9
+ const statsElement = $("#stats");
10
+ const errorElement = $("#error");
11
+ const conflictsElement = $("#conflicts");
12
+ const filterElement = $("#filter");
13
+ const toggleElement = $("#toggle");
14
+ const refreshElement = $("#refresh");
15
+ const liveElement = $("#live");
16
+ const liveLabelElement = $("#live-label");
17
+ const testerForm = $("#tester");
18
+ const testerInput = $("#tester-input");
19
+ const testerResult = $("#tester-result");
20
+ const detailsElement = $("#details");
21
+ const currentElement = $("#current");
22
+ const currentSubtitle = $("#current-subtitle");
23
+ const tabPicker = $("#tab-picker");
24
+ const inspectTab = $("#tab-inspect");
25
+ const timelineTab = $("#tab-timeline");
26
+ const inspectView = $("#view-inspect");
27
+ const timelineView = $("#view-timeline");
28
+ const timelineCount = $("#timeline-count");
29
+ const timelineElement = $("#timeline");
30
+ const timelineFilter = $("#timeline-filter");
31
+ const timelineTypes = $("#timeline-types");
32
+ const timelineClear = $("#timeline-clear");
33
+
34
+ const MAX_TIMELINE = 200;
35
+ const SLOW_CALL_MS = 500;
36
+
37
+ const FILE_KINDS = [
38
+ ["page", "Page", "page"],
39
+ ["layout", "Layout", "layout"],
40
+ ["loading", "Loading", "loading"],
41
+ ["error", "Error boundary", "error"],
42
+ ["notFound", "Not found", "404"],
43
+ ["loader", "Loader", "loader"],
44
+ ["action", "Action", "action"],
45
+ ];
46
+
47
+ const state = {
48
+ data: undefined,
49
+ snapshot: undefined,
50
+ nodes: new Map(),
51
+ collapsed: new Set(),
52
+ selected: "0",
53
+ match: undefined,
54
+ matchedKeys: new Set(),
55
+ // App tabs reported by the dev-only client in `virtual:routes`.
56
+ tabs: [],
57
+ followedTab: undefined,
58
+ currentKeys: new Set(),
59
+ // Loader, action, and revalidation calls, oldest first.
60
+ activity: [],
61
+ clearedThrough: 0,
62
+ unseen: 0,
63
+ timelineType: "all",
64
+ openCalls: new Set(),
65
+ };
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // Helpers
69
+
70
+ function el(tag, attributes = {}, ...children) {
71
+ const element = document.createElement(tag);
72
+ for (const [name, value] of Object.entries(attributes)) {
73
+ if (value === undefined || value === false) continue;
74
+ if (name === "class") element.className = value;
75
+ else if (name.startsWith("on")) element.addEventListener(name.slice(2), value);
76
+ else element.setAttribute(name, value === true ? "" : value);
77
+ }
78
+ element.append(...children.flat().filter((child) => child !== undefined && child !== null));
79
+ return element;
80
+ }
81
+
82
+ function icon(name) {
83
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
84
+ svg.setAttribute("aria-hidden", "true");
85
+ const use = document.createElementNS("http://www.w3.org/2000/svg", "use");
86
+ use.setAttribute("href", `#icon-${name}`);
87
+ svg.append(use);
88
+ return svg;
89
+ }
90
+
91
+ let toastTimer;
92
+ function toast(message) {
93
+ document.querySelector(".toast")?.remove();
94
+ const element = el("div", { class: "toast", role: "status" }, message);
95
+ document.body.append(element);
96
+ clearTimeout(toastTimer);
97
+ toastTimer = setTimeout(() => element.remove(), 2200);
98
+ }
99
+
100
+ function readStorage(key) {
101
+ try {
102
+ return localStorage.getItem(key) ?? "";
103
+ } catch {
104
+ return "";
105
+ }
106
+ }
107
+
108
+ function writeStorage(key, value) {
109
+ try {
110
+ localStorage.setItem(key, value);
111
+ } catch {
112
+ // Storage can be unavailable in sandboxed frames; the tester still works.
113
+ }
114
+ }
115
+
116
+ function firstFile(node) {
117
+ const file = Object.values(node.files)[0];
118
+ if (file) return file;
119
+ for (const child of node.children) {
120
+ const descendant = firstFile(child);
121
+ if (descendant) return descendant;
122
+ }
123
+ return undefined;
124
+ }
125
+
126
+ /**
127
+ * The manifest omits route-group names from URLs, so read the directory name
128
+ * from a file inside the group: it sits at the group's depth below the routes
129
+ * directory.
130
+ */
131
+ function groupName(node) {
132
+ const file = firstFile(node);
133
+ const key = [...state.nodes].find(([, entry]) => entry.node === node)?.[0];
134
+ if (!file || !key || !state.data) return "(group)";
135
+ const depth = key.split(".").length - 1;
136
+ const base = state.data.directory.split("/").filter(Boolean).length;
137
+ return file.split("/")[base + depth - 1] ?? "(group)";
138
+ }
139
+
140
+ function segmentLabel(node, key) {
141
+ if (key === "0") return [el("span", { class: "slash" }, "/")];
142
+ if (node.routeGroup) return [el("span", { class: "group" }, groupName(node))];
143
+ const segment = node.segment;
144
+ const className = segment.endsWith("?")
145
+ ? "optional"
146
+ : segment.startsWith(":") || segment === "*"
147
+ ? "param"
148
+ : undefined;
149
+ return [el("span", { class: "slash" }, "/"), el("span", { class: className }, segment)];
150
+ }
151
+
152
+ function sameNode(node, segment) {
153
+ return (
154
+ node.fullPath === segment.fullPath &&
155
+ node.routeGroup === segment.routeGroup &&
156
+ JSON.stringify(node.files) === JSON.stringify(segment.files)
157
+ );
158
+ }
159
+
160
+ function examplePath(fullPath) {
161
+ return (
162
+ fullPath
163
+ .split("/")
164
+ .filter((segment) => !segment.endsWith("?"))
165
+ .map((segment) => (segment === "*" ? "any/path" : segment.startsWith(":") ? "1" : segment))
166
+ .join("/") || "/"
167
+ );
168
+ }
169
+
170
+ // ---------------------------------------------------------------------------
171
+ // Editor integration
172
+
173
+ async function openInEditor(file, button) {
174
+ if (!state.data) return;
175
+ const absolute = `${state.data.projectRoot.replace(/[\\/]$/, "")}/${file}`;
176
+ try {
177
+ const response = await fetch(`${OPEN_URL}?file=${encodeURIComponent(absolute)}`);
178
+ if (!response.ok) throw new Error(`status ${response.status}`);
179
+ button?.classList.add("opened");
180
+ setTimeout(() => button?.classList.remove("opened"), 900);
181
+ toast(`Opened ${file.split("/").pop()} in your editor`);
182
+ } catch (error) {
183
+ toast(`Couldn't open the file: ${error.message}`);
184
+ }
185
+ }
186
+
187
+ function fileLink(file) {
188
+ const button = el(
189
+ "button",
190
+ {
191
+ class: "file-link",
192
+ type: "button",
193
+ title: `Open ${file} in your editor`,
194
+ onclick: () => openInEditor(file, button),
195
+ },
196
+ // `direction: rtl` keeps the filename visible when the path is truncated;
197
+ // the bidi isolate stops punctuation from being reordered.
198
+ el("span", {}, `⁨${file}⁩`),
199
+ icon("open"),
200
+ );
201
+ return button;
202
+ }
203
+
204
+ // ---------------------------------------------------------------------------
205
+ // Tree
206
+
207
+ function indexNodes(node, key = "0", parent) {
208
+ state.nodes.set(key, { node, parent });
209
+ node.children.forEach((child, index) => indexNodes(child, `${key}.${index}`, key));
210
+ }
211
+
212
+ function searchText(node) {
213
+ return [
214
+ node.fullPath,
215
+ node.routeGroup ? groupName(node) : "",
216
+ ...node.params,
217
+ ...Object.entries(node.files).flat(),
218
+ ]
219
+ .join(" ")
220
+ .toLowerCase();
221
+ }
222
+
223
+ function renderNode(node, key, query) {
224
+ const childItems = node.children
225
+ .map((child, index) => renderNode(child, `${key}.${index}`, query))
226
+ .filter(Boolean);
227
+ if (query && !searchText(node).includes(query) && childItems.length === 0) return null;
228
+
229
+ const hasChildren = node.children.length > 0;
230
+ const expanded = Boolean(query) || !state.collapsed.has(key);
231
+ const fileKinds = FILE_KINDS.filter(([kind]) => node.files[kind]);
232
+
233
+ const row = el(
234
+ "div",
235
+ {
236
+ class: `row${state.matchedKeys.has(key) ? " matched" : ""}${state.currentKeys.has(key) ? " current" : ""}`,
237
+ role: "treeitem",
238
+ tabindex: state.selected === key ? "0" : "-1",
239
+ "aria-selected": String(state.selected === key),
240
+ "aria-expanded": hasChildren ? String(expanded) : undefined,
241
+ "data-key": key,
242
+ onclick: () => select(key),
243
+ onkeydown: (event) => onRowKey(event, key, hasChildren, expanded),
244
+ },
245
+ hasChildren
246
+ ? el(
247
+ "button",
248
+ {
249
+ class: "twisty",
250
+ type: "button",
251
+ tabindex: "-1",
252
+ "aria-label": expanded ? "Collapse" : "Expand",
253
+ "aria-expanded": String(expanded),
254
+ onclick: (event) => {
255
+ event.stopPropagation();
256
+ toggle(key);
257
+ },
258
+ },
259
+ icon("chevron"),
260
+ )
261
+ : el("span", { class: "twisty-spacer" }),
262
+ el("span", { class: "segment" }, segmentLabel(node, key)),
263
+ key !== "0" && !node.routeGroup && node.fullPath !== `/${node.segment}`
264
+ ? el("span", { class: "full-path" }, node.fullPath)
265
+ : undefined,
266
+ el(
267
+ "span",
268
+ { class: "row-files" },
269
+ node.routeGroup ? el("span", { class: "chip chip-group" }, "group") : undefined,
270
+ fileKinds.map(([kind, label, short]) =>
271
+ el("span", { class: `chip chip-${kind}`, title: `${label}: ${node.files[kind]}` }, short),
272
+ ),
273
+ ),
274
+ );
275
+
276
+ return el(
277
+ "li",
278
+ { role: "none" },
279
+ row,
280
+ hasChildren && expanded ? el("ul", { role: "group" }, childItems) : undefined,
281
+ );
282
+ }
283
+
284
+ function renderTree() {
285
+ const query = filterElement.value.trim().toLowerCase();
286
+ const root = state.data && renderNode(state.data.manifest, "0", query);
287
+ treeElement.replaceChildren(
288
+ root
289
+ ? el("ul", { role: "none" }, root)
290
+ : el("p", { class: "empty" }, state.data ? "No routes match this filter." : "Loading routes…"),
291
+ );
292
+
293
+ const allExpanded = state.collapsed.size === 0;
294
+ toggleElement.replaceChildren(icon(allExpanded ? "collapse" : "expand"));
295
+ toggleElement.setAttribute("aria-label", allExpanded ? "Collapse all" : "Expand all");
296
+ toggleElement.title = allExpanded ? "Collapse all" : "Expand all";
297
+ toggleElement.disabled = Boolean(query);
298
+ }
299
+
300
+ function toggle(key) {
301
+ if (state.collapsed.has(key)) state.collapsed.delete(key);
302
+ else state.collapsed.add(key);
303
+ renderTree();
304
+ focusRow(key);
305
+ }
306
+
307
+ function select(key) {
308
+ state.selected = key;
309
+ renderTree();
310
+ renderDetails();
311
+ focusRow(key);
312
+ }
313
+
314
+ function focusRow(key) {
315
+ treeElement.querySelector(`[data-key="${CSS.escape(key)}"]`)?.focus();
316
+ }
317
+
318
+ function onRowKey(event, key, hasChildren, expanded) {
319
+ const rows = [...treeElement.querySelectorAll(".row")];
320
+ const index = rows.findIndex((row) => row.dataset.key === key);
321
+ const move = (offset) => {
322
+ const target = rows[index + offset];
323
+ if (target) select(target.dataset.key);
324
+ };
325
+ if (event.key === "ArrowDown") move(1);
326
+ else if (event.key === "ArrowUp") move(-1);
327
+ else if (event.key === "ArrowRight" && hasChildren && !expanded) toggle(key);
328
+ else if (event.key === "ArrowLeft" && hasChildren && expanded) toggle(key);
329
+ else if (event.key === "ArrowLeft") {
330
+ const parent = state.nodes.get(key)?.parent;
331
+ if (parent) select(parent);
332
+ } else return;
333
+ event.preventDefault();
334
+ }
335
+
336
+ // ---------------------------------------------------------------------------
337
+ // Summary, conflicts, and details
338
+
339
+ function renderStats() {
340
+ const counts = { pages: 0, dynamic: 0, layouts: 0, loaders: 0 };
341
+ for (const { node } of state.nodes.values()) {
342
+ if (node.files.page) counts.pages++;
343
+ if (node.files.page && /[:*]/.test(node.fullPath)) counts.dynamic++;
344
+ if (node.files.layout) counts.layouts++;
345
+ if (node.files.loader) counts.loaders++;
346
+ }
347
+ statsElement.replaceChildren(
348
+ ...[
349
+ [counts.pages, counts.pages === 1 ? "page" : "pages"],
350
+ [counts.dynamic, "dynamic"],
351
+ [counts.layouts, counts.layouts === 1 ? "layout" : "layouts"],
352
+ [counts.loaders, counts.loaders === 1 ? "loader" : "loaders"],
353
+ ].map(([value, label]) => el("div", { class: "stat" }, el("dt", {}, label), el("dd", {}, String(value)))),
354
+ );
355
+ }
356
+
357
+ function renderConflicts() {
358
+ const conflicts = state.data?.conflicts ?? [];
359
+ conflictsElement.hidden = conflicts.length === 0;
360
+ if (!conflicts.length) return;
361
+ conflictsElement.replaceChildren(
362
+ el("h2", {}, conflicts.length === 1 ? "1 route conflict" : `${conflicts.length} route conflicts`),
363
+ el("p", {}, "These pages resolve to the same URL pattern. Rename or move one of them in each group."),
364
+ ...conflicts.map(({ routePath, files }) =>
365
+ el(
366
+ "div",
367
+ { class: "conflict" },
368
+ el("code", {}, routePath),
369
+ el("div", { class: "facts" }, files.map((file) => fileLink(file))),
370
+ ),
371
+ ),
372
+ );
373
+ }
374
+
375
+ function fact(label, ...content) {
376
+ return el("div", {}, el("dt", {}, label), el("dd", {}, ...content));
377
+ }
378
+
379
+ function renderDetails() {
380
+ const entry = state.nodes.get(state.selected) ?? state.nodes.get("0");
381
+ if (!entry) {
382
+ detailsElement.replaceChildren(el("p", { class: "empty" }, "Select a route to see its files."));
383
+ return;
384
+ }
385
+ const { node } = entry;
386
+ const files = FILE_KINDS.filter(([kind]) => node.files[kind]);
387
+ const params = [];
388
+ for (let key = state.selected; key; key = state.nodes.get(key)?.parent) {
389
+ const current = state.nodes.get(key)?.node;
390
+ current?.params.forEach((param) =>
391
+ params.unshift({ param, optional: current.optionalParams?.includes(param) }),
392
+ );
393
+ }
394
+
395
+ detailsElement.replaceChildren(
396
+ el(
397
+ "div",
398
+ { class: "details-body" },
399
+ el(
400
+ "div",
401
+ { class: "details-path" },
402
+ node.routeGroup ? `${node.fullPath} · ${groupName(node)}` : node.fullPath,
403
+ ),
404
+ el(
405
+ "dl",
406
+ { class: "facts" },
407
+ node.routeGroup
408
+ ? fact("Route group", el("span", { class: "muted" }, "Organizes files without adding a URL segment."))
409
+ : undefined,
410
+ params.length
411
+ ? fact(
412
+ "Params",
413
+ el(
414
+ "div",
415
+ { class: "params" },
416
+ params.map(({ param, optional }) =>
417
+ el("span", { class: "param-pill" }, el("span", {}, optional ? `${param}?` : param)),
418
+ ),
419
+ ),
420
+ )
421
+ : undefined,
422
+ files.length
423
+ ? files.map(([kind, label]) => fact(label, fileLink(node.files[kind])))
424
+ : fact("Files", el("span", { class: "muted" }, "No route files in this directory.")),
425
+ node.files.page
426
+ ? fact(
427
+ "Try it",
428
+ el(
429
+ "button",
430
+ {
431
+ class: "link-button",
432
+ type: "button",
433
+ onclick: () => {
434
+ testerInput.value = examplePath(node.fullPath);
435
+ runMatch();
436
+ },
437
+ },
438
+ `Test ${examplePath(node.fullPath)}`,
439
+ ),
440
+ )
441
+ : undefined,
442
+ ),
443
+ ),
444
+ );
445
+ }
446
+
447
+ // ---------------------------------------------------------------------------
448
+ // URL tester
449
+
450
+ function keysForChain(chain) {
451
+ const keys = new Set();
452
+ if (!state.data || !chain.length) return keys;
453
+ let key = "0";
454
+ let node = state.data.manifest;
455
+ if (!sameNode(node, chain[0])) return keys;
456
+ keys.add(key);
457
+ for (const segment of chain.slice(1)) {
458
+ const index = node.children.findIndex((child) => sameNode(child, segment));
459
+ if (index === -1) break;
460
+ key = `${key}.${index}`;
461
+ node = node.children[index];
462
+ keys.add(key);
463
+ }
464
+ return keys;
465
+ }
466
+
467
+ function renderMatch() {
468
+ const result = state.match;
469
+ if (!result) {
470
+ testerResult.replaceChildren();
471
+ return;
472
+ }
473
+ if (result.failure) {
474
+ testerResult.replaceChildren(el("p", { class: "muted" }, result.failure));
475
+ return;
476
+ }
477
+ if (!result.matched) {
478
+ testerResult.replaceChildren(
479
+ el(
480
+ "div",
481
+ { class: "result-status" },
482
+ el("span", { class: "chip chip-error" }, "No match"),
483
+ el("span", { class: "result-pathname" }, result.pathname),
484
+ ),
485
+ el("p", { class: "muted" }, "No route handles this URL, and there's no not-found page above it."),
486
+ );
487
+ return;
488
+ }
489
+
490
+ const params = Object.entries(result.params);
491
+ const keys = [...keysForChain(result.chain)];
492
+ testerResult.replaceChildren(
493
+ el(
494
+ "div",
495
+ { class: "result-status" },
496
+ result.notFound
497
+ ? el("span", { class: "chip chip-notFound" }, "Not-found page")
498
+ : el("span", { class: "chip chip-loader" }, "Matched"),
499
+ el("span", { class: "result-pathname" }, result.pathname),
500
+ ),
501
+ el(
502
+ "div",
503
+ { class: "crumbs", "aria-label": "Matched routes" },
504
+ result.chain.flatMap((segment, index) => {
505
+ const key = keys[index];
506
+ const label =
507
+ index === 0
508
+ ? "/"
509
+ : segment.routeGroup
510
+ ? groupName(state.nodes.get(key)?.node ?? segment)
511
+ : segment.segment;
512
+ return [
513
+ index > 0 ? el("span", { class: "crumb-sep", "aria-hidden": "true" }, "›") : undefined,
514
+ el(
515
+ "button",
516
+ { class: "crumb", type: "button", onclick: () => key && select(key) },
517
+ label,
518
+ ),
519
+ ];
520
+ }),
521
+ ),
522
+ el(
523
+ "dl",
524
+ { class: "facts" },
525
+ params.length
526
+ ? fact(
527
+ "Params",
528
+ el(
529
+ "div",
530
+ { class: "params" },
531
+ params.map(([name, value]) =>
532
+ el(
533
+ "span",
534
+ { class: "param-pill" },
535
+ el("span", {}, name),
536
+ value === undefined
537
+ ? el("span", { class: "missing" }, "undefined")
538
+ : el("span", {}, value),
539
+ ),
540
+ ),
541
+ ),
542
+ )
543
+ : undefined,
544
+ result.page ? fact("Page", fileLink(result.page)) : undefined,
545
+ result.notFound ? fact("Not-found page", fileLink(result.notFound)) : undefined,
546
+ result.layouts.length ? fact("Layouts, outermost first", result.layouts.map(fileLink)) : undefined,
547
+ result.loaders.length ? fact("Loaders", result.loaders.map(fileLink)) : undefined,
548
+ result.loading ? fact("Loading fallback", fileLink(result.loading)) : undefined,
549
+ result.error ? fact("Error boundary", fileLink(result.error)) : undefined,
550
+ ),
551
+ );
552
+ }
553
+
554
+ async function runMatch() {
555
+ const url = testerInput.value.trim();
556
+ writeStorage(TESTER_STORAGE_KEY, url);
557
+ if (!url) {
558
+ state.match = undefined;
559
+ state.matchedKeys = new Set();
560
+ renderMatch();
561
+ renderTree();
562
+ return;
563
+ }
564
+ const target = url.startsWith("/") || /^[a-z]+:/i.test(url) ? url : `/${url}`;
565
+ try {
566
+ const response = await fetch(`${MATCH_URL}?url=${encodeURIComponent(target)}`, { cache: "no-store" });
567
+ if (!response.ok) throw new Error(`Request failed (${response.status})`);
568
+ state.match = await response.json();
569
+ state.matchedKeys = state.match.matched ? keysForChain(state.match.chain) : new Set();
570
+ for (const key of state.matchedKeys) {
571
+ for (let parent = state.nodes.get(key)?.parent; parent; parent = state.nodes.get(parent)?.parent) {
572
+ state.collapsed.delete(parent);
573
+ }
574
+ }
575
+ } catch (error) {
576
+ state.match = { failure: `Couldn't match the URL: ${error.message}` };
577
+ state.matchedKeys = new Set();
578
+ }
579
+ renderMatch();
580
+ renderTree();
581
+ }
582
+
583
+ // ---------------------------------------------------------------------------
584
+ // Data and live updates
585
+
586
+ // ---------------------------------------------------------------------------
587
+ // Current page
588
+
589
+ function followedTab() {
590
+ return state.tabs.find((tab) => tab.id === state.followedTab) ?? state.tabs[0];
591
+ }
592
+
593
+ function updateCurrentKeys() {
594
+ const tab = followedTab();
595
+ state.currentKeys =
596
+ tab?.match?.matched && state.data ? keysForChain(tab.match.chain) : new Set();
597
+ }
598
+
599
+ function renderCurrent() {
600
+ const tab = followedTab();
601
+ tabPicker.hidden = state.tabs.length < 2;
602
+ tabPicker.replaceChildren(
603
+ ...state.tabs.map((item, index) =>
604
+ el(
605
+ "button",
606
+ {
607
+ type: "button",
608
+ "aria-pressed": String(item === tab),
609
+ title: item.url,
610
+ onclick: () => {
611
+ state.followedTab = item.id;
612
+ updateCurrentKeys();
613
+ renderCurrent();
614
+ renderTree();
615
+ },
616
+ },
617
+ item.title || `Tab ${index + 1}`,
618
+ ),
619
+ ),
620
+ );
621
+
622
+ if (!tab) {
623
+ currentSubtitle.textContent = "Follows the route your app is showing.";
624
+ currentElement.replaceChildren(
625
+ el("p", { class: "current-empty" }, "Open your app in a browser tab to follow its current route."),
626
+ );
627
+ return;
628
+ }
629
+
630
+ currentSubtitle.textContent =
631
+ state.tabs.length > 1
632
+ ? `${state.tabs.length} app tabs are open. Pick one to follow.`
633
+ : "Updates as your app navigates.";
634
+ const { match } = tab;
635
+ const params = match.matched ? Object.entries(match.params) : [];
636
+ currentElement.replaceChildren(
637
+ el(
638
+ "div",
639
+ { class: "current-url" },
640
+ el("span", { class: "current-dot", "aria-hidden": "true" }),
641
+ el("code", { title: tab.url }, tab.url),
642
+ ),
643
+ match.matched
644
+ ? el(
645
+ "dl",
646
+ { class: "facts" },
647
+ params.length
648
+ ? fact(
649
+ "Params",
650
+ el(
651
+ "div",
652
+ { class: "params" },
653
+ params.map(([name, value]) =>
654
+ el(
655
+ "span",
656
+ { class: "param-pill" },
657
+ el("span", {}, name),
658
+ value === undefined
659
+ ? el("span", { class: "missing" }, "undefined")
660
+ : el("span", {}, value),
661
+ ),
662
+ ),
663
+ ),
664
+ )
665
+ : undefined,
666
+ match.page ? fact("Page", fileLink(match.page)) : undefined,
667
+ match.notFound ? fact("Not-found page", fileLink(match.notFound)) : undefined,
668
+ )
669
+ : el("p", { class: "muted" }, "No route matches this URL."),
670
+ );
671
+ }
672
+
673
+ // ---------------------------------------------------------------------------
674
+ // Loader and action timeline
675
+
676
+ function nodeForRouteId(routeId) {
677
+ const directory = routeId.replace(/^file-route:/, "");
678
+ for (const [key, { node }] of state.nodes) {
679
+ const file = node.files.loader ?? node.files.action ?? Object.values(node.files)[0];
680
+ if (file && file.slice(0, file.lastIndexOf("/")) === directory) return { key, node };
681
+ }
682
+ return undefined;
683
+ }
684
+
685
+ function routeLabel(routeId) {
686
+ const found = nodeForRouteId(routeId);
687
+ if (!found) return routeId.replace(/^file-route:/, "");
688
+ return found.node.routeGroup ? `${found.node.fullPath} (${groupName(found.node)})` : found.node.fullPath;
689
+ }
690
+
691
+ function shortUrl(url) {
692
+ if (!url) return "";
693
+ try {
694
+ const parsed = new URL(url);
695
+ return parsed.pathname + parsed.search;
696
+ } catch {
697
+ return url;
698
+ }
699
+ }
700
+
701
+ function formatTime(timestamp) {
702
+ const date = new Date(timestamp);
703
+ return `${date.toLocaleTimeString([], { hour12: false })}.${String(date.getMilliseconds()).padStart(3, "0")}`;
704
+ }
705
+
706
+ function formatDuration(duration) {
707
+ if (duration === undefined) return "";
708
+ return duration < 10 ? `${duration.toFixed(1)} ms` : `${Math.round(duration)} ms`;
709
+ }
710
+
711
+ function outcomeChip(call) {
712
+ if (call.type === "revalidate") {
713
+ return call.decision
714
+ ? el("span", { class: "chip chip-loader" }, "revalidate")
715
+ : el("span", { class: "chip chip-skipped" }, "skipped");
716
+ }
717
+ switch (call.outcome) {
718
+ case "redirect":
719
+ return el("span", { class: "chip chip-loading" }, `${call.status} redirect`);
720
+ case "error":
721
+ return el("span", { class: "chip chip-error" }, call.status ? `${call.status} thrown` : "error");
722
+ case "response":
723
+ return el("span", { class: "chip chip-layout" }, `${call.status} response`);
724
+ default:
725
+ return el("span", { class: "chip chip-loader" }, "data");
726
+ }
727
+ }
728
+
729
+ function callText(call) {
730
+ return [
731
+ call.type,
732
+ routeLabel(call.routeId),
733
+ call.routeId,
734
+ call.url,
735
+ call.method,
736
+ call.outcome,
737
+ call.status,
738
+ call.location,
739
+ call.message,
740
+ call.type === "revalidate" ? (call.decision ? "revalidate" : "skipped") : "",
741
+ ]
742
+ .filter((value) => value !== undefined)
743
+ .join(" ")
744
+ .toLowerCase();
745
+ }
746
+
747
+ function renderCall(call) {
748
+ const found = nodeForRouteId(call.routeId);
749
+ const routeFile =
750
+ call.type === "action" ? found?.node.files.action : found?.node.files.loader;
751
+ const details = el(
752
+ "details",
753
+ { class: "call", open: state.openCalls.has(call.id) || undefined },
754
+ el(
755
+ "summary",
756
+ { class: "call-summary" },
757
+ el("span", { class: `chip chip-${call.type === "action" ? "action" : call.type === "revalidate" ? "group" : "page"}` }, call.type),
758
+ el("span", { class: "call-route", title: call.routeId }, routeLabel(call.routeId)),
759
+ call.type === "revalidate"
760
+ ? outcomeChip(call)
761
+ : el(
762
+ "span",
763
+ { class: "call-duration-outcome" },
764
+ outcomeChip(call),
765
+ " ",
766
+ el(
767
+ "span",
768
+ { class: `call-duration${call.duration >= SLOW_CALL_MS ? " slow" : ""}` },
769
+ formatDuration(call.duration),
770
+ ),
771
+ ),
772
+ el(
773
+ "span",
774
+ { class: "call-meta" },
775
+ el("span", {}, `${call.method && call.method !== "GET" ? `${call.method} ` : ""}${shortUrl(call.url)}`),
776
+ el("time", { datetime: new Date(call.startedAt).toISOString() }, formatTime(call.startedAt)),
777
+ ),
778
+ ),
779
+ el(
780
+ "div",
781
+ { class: "call-body" },
782
+ el(
783
+ "dl",
784
+ { class: "facts" },
785
+ routeFile ? fact(call.type === "action" ? "Action" : "Loader", fileLink(routeFile)) : undefined,
786
+ call.url ? fact("Request", el("code", { class: "file-path" }, `${call.method ?? ""} ${call.url}`.trim())) : undefined,
787
+ call.location ? fact("Redirects to", el("code", { class: "file-path" }, call.location)) : undefined,
788
+ call.message ? fact("Error", el("span", {}, call.message)) : undefined,
789
+ call.type === "revalidate"
790
+ ? fact(
791
+ "Decision",
792
+ el(
793
+ "span",
794
+ {},
795
+ call.decision
796
+ ? "shouldRevalidate returned true, so the loader runs."
797
+ : "shouldRevalidate returned false, so the loader was skipped.",
798
+ call.defaultShouldRevalidate === undefined
799
+ ? ""
800
+ : ` React Router's default was to ${call.defaultShouldRevalidate ? "revalidate" : "skip"}.`,
801
+ ),
802
+ )
803
+ : undefined,
804
+ call.preview ? fact("Returned data", el("pre", { class: "call-preview" }, call.preview)) : undefined,
805
+ call.tabId && state.tabs.length > 1
806
+ ? fact("Tab", el("span", { class: "muted" }, state.tabs.find((tab) => tab.id === call.tabId)?.title || call.tabId))
807
+ : undefined,
808
+ ),
809
+ ),
810
+ );
811
+ details.addEventListener("toggle", () => {
812
+ if (details.open) state.openCalls.add(call.id);
813
+ else state.openCalls.delete(call.id);
814
+ });
815
+ return el("li", {}, details);
816
+ }
817
+
818
+ function visibleCalls() {
819
+ const query = timelineFilter.value.trim().toLowerCase();
820
+ return state.activity
821
+ .filter((call) => call.id > state.clearedThrough)
822
+ .filter((call) => state.timelineType === "all" || call.type === state.timelineType)
823
+ .filter((call) => !query || callText(call).includes(query))
824
+ .reverse();
825
+ }
826
+
827
+ function renderTimeline() {
828
+ for (const button of timelineTypes.querySelectorAll("button")) {
829
+ button.setAttribute("aria-pressed", String(button.dataset.type === state.timelineType));
830
+ }
831
+ const hasCalls = state.activity.some((call) => call.id > state.clearedThrough);
832
+ timelineElement.dataset.empty = hasCalls
833
+ ? "No calls match this filter."
834
+ : "No loader or action calls yet. Navigate in your app to record some.";
835
+ timelineElement.replaceChildren(...visibleCalls().map(renderCall));
836
+ timelineCount.hidden = state.unseen === 0;
837
+ timelineCount.textContent = state.unseen > 99 ? "99+" : String(state.unseen);
838
+ }
839
+
840
+ function addActivity(calls) {
841
+ state.activity.push(...calls);
842
+ if (state.activity.length > MAX_TIMELINE) {
843
+ state.activity.splice(0, state.activity.length - MAX_TIMELINE);
844
+ }
845
+ if (timelineView.hidden) state.unseen += calls.length;
846
+ renderTimeline();
847
+ }
848
+
849
+ function showView(name) {
850
+ const timeline = name === "timeline";
851
+ inspectView.hidden = timeline;
852
+ timelineView.hidden = !timeline;
853
+ inspectTab.setAttribute("aria-selected", String(!timeline));
854
+ timelineTab.setAttribute("aria-selected", String(timeline));
855
+ if (timeline) state.unseen = 0;
856
+ renderTimeline();
857
+ }
858
+
859
+ function applyData(data) {
860
+ const snapshot = JSON.stringify(data);
861
+ errorElement.hidden = true;
862
+ if (snapshot === state.snapshot) return;
863
+ state.data = data;
864
+ state.snapshot = snapshot;
865
+ state.nodes = new Map();
866
+ indexNodes(data.manifest);
867
+ if (!state.nodes.has(state.selected)) state.selected = "0";
868
+ renderStats();
869
+ renderConflicts();
870
+ updateCurrentKeys();
871
+ renderTree();
872
+ renderDetails();
873
+ renderCurrent();
874
+ renderTimeline();
875
+ if (testerInput.value.trim()) runMatch();
876
+ }
877
+
878
+ async function refresh() {
879
+ refreshElement.classList.remove("spinning");
880
+ void refreshElement.offsetWidth;
881
+ refreshElement.classList.add("spinning");
882
+ try {
883
+ const response = await fetch(DATA_URL, { cache: "no-store" });
884
+ if (!response.ok) throw new Error(`Request failed (${response.status})`);
885
+ applyData(await response.json());
886
+ } catch (error) {
887
+ errorElement.textContent = `Unable to load routes: ${error.message}`;
888
+ errorElement.hidden = false;
889
+ }
890
+ }
891
+
892
+ function setLive(stateName, label) {
893
+ liveElement.dataset.state = stateName;
894
+ liveLabelElement.textContent = label;
895
+ }
896
+
897
+ function connect() {
898
+ if (!("EventSource" in window)) {
899
+ setLive("offline", "Manual refresh");
900
+ return;
901
+ }
902
+ const events = new EventSource(EVENTS_URL);
903
+ events.addEventListener("open", () => setLive("live", "Live"));
904
+ events.addEventListener("apps", (event) => {
905
+ state.tabs = JSON.parse(event.data);
906
+ updateCurrentKeys();
907
+ renderCurrent();
908
+ renderTree();
909
+ });
910
+ events.addEventListener("activity-snapshot", (event) => {
911
+ // Sent on every (re)connect; keep only calls this panel hasn't seen.
912
+ const known = new Set(state.activity.map((call) => call.id));
913
+ const calls = JSON.parse(event.data).filter((call) => !known.has(call.id));
914
+ if (calls.length) addActivity(calls);
915
+ });
916
+ events.addEventListener("activity", (event) => addActivity([JSON.parse(event.data)]));
917
+ events.addEventListener("routes", (event) => {
918
+ setLive("live", "Live");
919
+ applyData(JSON.parse(event.data));
920
+ });
921
+ // EventSource reconnects on its own, including after a dev-server restart.
922
+ events.addEventListener("error", () => setLive("reconnecting", "Reconnecting"));
923
+ }
924
+
925
+ filterElement.addEventListener("input", renderTree);
926
+ inspectTab.addEventListener("click", () => showView("inspect"));
927
+ timelineTab.addEventListener("click", () => showView("timeline"));
928
+ timelineFilter.addEventListener("input", renderTimeline);
929
+ timelineTypes.addEventListener("click", (event) => {
930
+ const type = event.target.closest("button")?.dataset.type;
931
+ if (!type) return;
932
+ state.timelineType = type;
933
+ renderTimeline();
934
+ });
935
+ timelineClear.addEventListener("click", () => {
936
+ state.clearedThrough = state.activity.at(-1)?.id ?? state.clearedThrough;
937
+ state.openCalls.clear();
938
+ renderTimeline();
939
+ });
940
+ refreshElement.addEventListener("click", refresh);
941
+ toggleElement.addEventListener("click", () => {
942
+ if (state.collapsed.size === 0) {
943
+ for (const [key, { node }] of state.nodes) {
944
+ if (key !== "0" && node.children.length) state.collapsed.add(key);
945
+ }
946
+ } else {
947
+ state.collapsed.clear();
948
+ }
949
+ renderTree();
950
+ });
951
+ testerForm.addEventListener("submit", (event) => {
952
+ event.preventDefault();
953
+ runMatch();
954
+ });
955
+
956
+ testerInput.value = readStorage(TESTER_STORAGE_KEY);
957
+ renderTree();
958
+ renderCurrent();
959
+ renderTimeline();
960
+ refresh();
961
+ connect();