@troyio/vite-file-router 0.4.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +704 -536
- package/devtools/app.js +833 -51
- package/devtools/index.html +119 -76
- package/devtools/style.css +730 -156
- package/dist/devtools-app.d.ts +40 -2
- package/dist/devtools.d.ts +27 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.js +577 -43
- package/dist/scanner.d.ts +3 -1
- package/package.json +1 -1
- package/types/index.d.ts +3 -0
package/devtools/app.js
CHANGED
|
@@ -2,6 +2,9 @@ const DATA_URL = "/__vite-file-router-data";
|
|
|
2
2
|
const EVENTS_URL = "/__vite-file-router-events";
|
|
3
3
|
const MATCH_URL = "/__vite-file-router-match";
|
|
4
4
|
const OPEN_URL = "/__open-in-editor";
|
|
5
|
+
const NAVIGATE_URL = "/__vite-file-router-navigate";
|
|
6
|
+
const CODE_URL = "/__vite-file-router-code";
|
|
7
|
+
const RECENT_PARAMS_KEY = "vite-file-router:recent-params";
|
|
5
8
|
const TESTER_STORAGE_KEY = "vite-file-router:tester-url";
|
|
6
9
|
|
|
7
10
|
const $ = (selector) => document.querySelector(selector);
|
|
@@ -9,6 +12,7 @@ const treeElement = $("#tree");
|
|
|
9
12
|
const statsElement = $("#stats");
|
|
10
13
|
const errorElement = $("#error");
|
|
11
14
|
const conflictsElement = $("#conflicts");
|
|
15
|
+
const warningsElement = $("#warnings");
|
|
12
16
|
const filterElement = $("#filter");
|
|
13
17
|
const toggleElement = $("#toggle");
|
|
14
18
|
const refreshElement = $("#refresh");
|
|
@@ -21,15 +25,16 @@ const detailsElement = $("#details");
|
|
|
21
25
|
const currentElement = $("#current");
|
|
22
26
|
const currentSubtitle = $("#current-subtitle");
|
|
23
27
|
const tabPicker = $("#tab-picker");
|
|
24
|
-
const
|
|
25
|
-
const timelineTab = $("#tab-timeline");
|
|
26
|
-
const inspectView = $("#view-inspect");
|
|
27
|
-
const timelineView = $("#view-timeline");
|
|
28
|
+
const pageNav = $("#page-nav");
|
|
28
29
|
const timelineCount = $("#timeline-count");
|
|
30
|
+
const warningCount = $("#warning-count");
|
|
31
|
+
const codeModulesElement = $("#code-modules");
|
|
32
|
+
const codeViewElement = $("#code-view");
|
|
29
33
|
const timelineElement = $("#timeline");
|
|
30
34
|
const timelineFilter = $("#timeline-filter");
|
|
31
35
|
const timelineTypes = $("#timeline-types");
|
|
32
36
|
const timelineClear = $("#timeline-clear");
|
|
37
|
+
const timelineRoutePill = $("#timeline-route");
|
|
33
38
|
|
|
34
39
|
const MAX_TIMELINE = 200;
|
|
35
40
|
const SLOW_CALL_MS = 500;
|
|
@@ -42,6 +47,8 @@ const FILE_KINDS = [
|
|
|
42
47
|
["notFound", "Not found", "404"],
|
|
43
48
|
["loader", "Loader", "loader"],
|
|
44
49
|
["action", "Action", "action"],
|
|
50
|
+
["middleware", "Middleware", "mw"],
|
|
51
|
+
["hydrateFallback", "Hydrate fallback", "hydrate"],
|
|
45
52
|
];
|
|
46
53
|
|
|
47
54
|
const state = {
|
|
@@ -61,7 +68,13 @@ const state = {
|
|
|
61
68
|
clearedThrough: 0,
|
|
62
69
|
unseen: 0,
|
|
63
70
|
timelineType: "all",
|
|
71
|
+
// Exact route ID the timeline is narrowed to, from a route's details.
|
|
72
|
+
timelineRoute: undefined,
|
|
64
73
|
openCalls: new Set(),
|
|
74
|
+
// Generated modules for the Generated code page, fetched when it opens.
|
|
75
|
+
code: { modules: undefined, selected: "virtual:routes", variant: "dev", stale: false, failure: undefined },
|
|
76
|
+
// Signature of the slow-loader warnings last rendered.
|
|
77
|
+
slowSignature: "",
|
|
65
78
|
};
|
|
66
79
|
|
|
67
80
|
// ---------------------------------------------------------------------------
|
|
@@ -267,6 +280,8 @@ function renderNode(node, key, query) {
|
|
|
267
280
|
"span",
|
|
268
281
|
{ class: "row-files" },
|
|
269
282
|
node.routeGroup ? el("span", { class: "chip chip-group" }, "group") : undefined,
|
|
283
|
+
warningChip(key),
|
|
284
|
+
rowOpenButton(node, key),
|
|
270
285
|
fileKinds.map(([kind, label, short]) =>
|
|
271
286
|
el("span", { class: `chip chip-${kind}`, title: `${label}: ${node.files[kind]}` }, short),
|
|
272
287
|
),
|
|
@@ -372,6 +387,136 @@ function renderConflicts() {
|
|
|
372
387
|
);
|
|
373
388
|
}
|
|
374
389
|
|
|
390
|
+
// ---------------------------------------------------------------------------
|
|
391
|
+
// Missing-file warnings
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Loaders the timeline measured as slow. Loading files only cover lazy page
|
|
395
|
+
* code, so these suggest pending UI instead of another file.
|
|
396
|
+
*/
|
|
397
|
+
function slowLoaderWarnings() {
|
|
398
|
+
const slowest = new Map();
|
|
399
|
+
for (const call of state.activity) {
|
|
400
|
+
if (call.id <= state.clearedThrough) continue;
|
|
401
|
+
if (call.type !== "loader" || !(call.duration >= SLOW_CALL_MS)) continue;
|
|
402
|
+
if (!(slowest.get(call.routeId) >= call.duration)) slowest.set(call.routeId, call.duration);
|
|
403
|
+
}
|
|
404
|
+
return [...slowest].flatMap(([routeId, duration]) => {
|
|
405
|
+
const found = nodeForRouteId(routeId);
|
|
406
|
+
if (!found) return [];
|
|
407
|
+
return [
|
|
408
|
+
{
|
|
409
|
+
kind: "slow-loader",
|
|
410
|
+
severity: "info",
|
|
411
|
+
key: found.key,
|
|
412
|
+
fullPath: found.node.fullPath,
|
|
413
|
+
message: `This loader took up to ${formatDuration(duration)}. Loading files don't show while loaders run. Add a hydrate-fallback file for the first page load, and show pending UI with useNavigation() in a layout for later navigations.`,
|
|
414
|
+
files: found.node.files.loader ? [found.node.files.loader] : [],
|
|
415
|
+
},
|
|
416
|
+
];
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function allWarnings() {
|
|
421
|
+
return [...(state.data?.warnings ?? []), ...slowLoaderWarnings()];
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function warningsFor(key) {
|
|
425
|
+
return allWarnings().filter((warning) => warning.key === key);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function warningChip(key) {
|
|
429
|
+
const warnings = warningsFor(key);
|
|
430
|
+
if (!warnings.length) return undefined;
|
|
431
|
+
const severe = warnings.some((warning) => warning.severity === "warning");
|
|
432
|
+
return el(
|
|
433
|
+
"span",
|
|
434
|
+
{
|
|
435
|
+
class: `chip chip-warn${severe ? "" : " chip-hint"}`,
|
|
436
|
+
title: warnings.map((warning) => warning.message).join("\n"),
|
|
437
|
+
},
|
|
438
|
+
severe ? "warn" : "hint",
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function renderWarning(warning, withRoute = true) {
|
|
443
|
+
return el(
|
|
444
|
+
"li",
|
|
445
|
+
{ class: `warning warning-${warning.severity}` },
|
|
446
|
+
el(
|
|
447
|
+
"div",
|
|
448
|
+
{ class: "warning-head" },
|
|
449
|
+
el("span", { class: `chip chip-warn${warning.severity === "warning" ? "" : " chip-hint"}` }, warning.severity === "warning" ? "warn" : "hint"),
|
|
450
|
+
withRoute
|
|
451
|
+
? el(
|
|
452
|
+
"button",
|
|
453
|
+
{ class: "link-button warning-route", type: "button", onclick: () => revealRoute(warning.key) },
|
|
454
|
+
warning.fullPath,
|
|
455
|
+
)
|
|
456
|
+
: undefined,
|
|
457
|
+
),
|
|
458
|
+
el("p", {}, warning.message),
|
|
459
|
+
warning.files.length ? el("div", { class: "facts" }, warning.files.map((file) => fileLink(file))) : undefined,
|
|
460
|
+
);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function revealRoute(key) {
|
|
464
|
+
if (!state.nodes.has(key)) return;
|
|
465
|
+
for (let parent = state.nodes.get(key)?.parent; parent; parent = state.nodes.get(parent)?.parent) {
|
|
466
|
+
state.collapsed.delete(parent);
|
|
467
|
+
}
|
|
468
|
+
showView("inspect");
|
|
469
|
+
select(key);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function renderWarnings() {
|
|
473
|
+
const warnings = allWarnings();
|
|
474
|
+
const severeCount = warnings.filter((warning) => warning.severity === "warning").length;
|
|
475
|
+
warningCount.hidden = severeCount === 0;
|
|
476
|
+
warningCount.textContent = String(severeCount);
|
|
477
|
+
warningCount.title = `${severeCount} route warning${severeCount === 1 ? "" : "s"}`;
|
|
478
|
+
warningsElement.hidden = warnings.length === 0;
|
|
479
|
+
if (!warnings.length) return;
|
|
480
|
+
const severe = warnings.filter((warning) => warning.severity === "warning").length;
|
|
481
|
+
const open = warningsElement.querySelector("details")?.open ?? false;
|
|
482
|
+
warningsElement.replaceChildren(
|
|
483
|
+
el(
|
|
484
|
+
"details",
|
|
485
|
+
{ open: open || undefined },
|
|
486
|
+
el(
|
|
487
|
+
"summary",
|
|
488
|
+
{},
|
|
489
|
+
el("h2", {}, warnings.length === 1 ? "1 route warning" : `${warnings.length} route warnings`),
|
|
490
|
+
el(
|
|
491
|
+
"span",
|
|
492
|
+
{ class: "muted" },
|
|
493
|
+
severe === warnings.length
|
|
494
|
+
? "Missing files and exports"
|
|
495
|
+
: [
|
|
496
|
+
severe ? `${severe} warning${severe === 1 ? "" : "s"}` : "",
|
|
497
|
+
`${warnings.length - severe} hint${warnings.length - severe === 1 ? "" : "s"}`,
|
|
498
|
+
]
|
|
499
|
+
.filter(Boolean)
|
|
500
|
+
.join(", "),
|
|
501
|
+
),
|
|
502
|
+
),
|
|
503
|
+
el("ul", { class: "warning-list" }, warnings.map((warning) => renderWarning(warning))),
|
|
504
|
+
),
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/** Re-renders warnings when the timeline finds a new slow loader. */
|
|
509
|
+
function refreshSlowWarnings() {
|
|
510
|
+
const signature = slowLoaderWarnings()
|
|
511
|
+
.map((warning) => `${warning.key}:${warning.message}`)
|
|
512
|
+
.join("|");
|
|
513
|
+
if (signature === state.slowSignature) return;
|
|
514
|
+
state.slowSignature = signature;
|
|
515
|
+
renderWarnings();
|
|
516
|
+
renderTree();
|
|
517
|
+
renderDetails();
|
|
518
|
+
}
|
|
519
|
+
|
|
375
520
|
function fact(label, ...content) {
|
|
376
521
|
return el("div", {}, el("dt", {}, label), el("dd", {}, ...content));
|
|
377
522
|
}
|
|
@@ -401,6 +546,13 @@ function renderDetails() {
|
|
|
401
546
|
{ class: "details-path" },
|
|
402
547
|
node.routeGroup ? `${node.fullPath} · ${groupName(node)}` : node.fullPath,
|
|
403
548
|
),
|
|
549
|
+
warningsFor(state.selected).length
|
|
550
|
+
? el(
|
|
551
|
+
"ul",
|
|
552
|
+
{ class: "warning-list" },
|
|
553
|
+
warningsFor(state.selected).map((warning) => renderWarning(warning, false)),
|
|
554
|
+
)
|
|
555
|
+
: undefined,
|
|
404
556
|
el(
|
|
405
557
|
"dl",
|
|
406
558
|
{ class: "facts" },
|
|
@@ -439,7 +591,9 @@ function renderDetails() {
|
|
|
439
591
|
),
|
|
440
592
|
)
|
|
441
593
|
: undefined,
|
|
594
|
+
node.files.page ? fact("Open in app", openForm(node, params)) : undefined,
|
|
442
595
|
),
|
|
596
|
+
dataStats(node),
|
|
443
597
|
),
|
|
444
598
|
);
|
|
445
599
|
}
|
|
@@ -497,6 +651,7 @@ function renderMatch() {
|
|
|
497
651
|
? el("span", { class: "chip chip-notFound" }, "Not-found page")
|
|
498
652
|
: el("span", { class: "chip chip-loader" }, "Matched"),
|
|
499
653
|
el("span", { class: "result-pathname" }, result.pathname),
|
|
654
|
+
testedUrl() ? el("span", { class: "result-open" }, openButton(testedUrl())) : undefined,
|
|
500
655
|
),
|
|
501
656
|
el(
|
|
502
657
|
"div",
|
|
@@ -544,13 +699,23 @@ function renderMatch() {
|
|
|
544
699
|
result.page ? fact("Page", fileLink(result.page)) : undefined,
|
|
545
700
|
result.notFound ? fact("Not-found page", fileLink(result.notFound)) : undefined,
|
|
546
701
|
result.layouts.length ? fact("Layouts, outermost first", result.layouts.map(fileLink)) : undefined,
|
|
702
|
+
result.middleware?.length ? fact("Middleware, outermost first", result.middleware.map(fileLink)) : undefined,
|
|
547
703
|
result.loaders.length ? fact("Loaders", result.loaders.map(fileLink)) : undefined,
|
|
548
704
|
result.loading ? fact("Loading fallback", fileLink(result.loading)) : undefined,
|
|
705
|
+
result.hydrateFallback ? fact("Hydrate fallback (first load)", fileLink(result.hydrateFallback)) : undefined,
|
|
549
706
|
result.error ? fact("Error boundary", fileLink(result.error)) : undefined,
|
|
550
707
|
),
|
|
551
708
|
);
|
|
552
709
|
}
|
|
553
710
|
|
|
711
|
+
/** The tester's URL as a same-origin path, or undefined. */
|
|
712
|
+
function testedUrl() {
|
|
713
|
+
const url = testerInput.value.trim();
|
|
714
|
+
if (!url) return undefined;
|
|
715
|
+
const target = url.startsWith("/") ? url : /^[a-z]+:/i.test(url) ? undefined : `/${url}`;
|
|
716
|
+
return target && !target.startsWith("//") ? target : undefined;
|
|
717
|
+
}
|
|
718
|
+
|
|
554
719
|
async function runMatch() {
|
|
555
720
|
const url = testerInput.value.trim();
|
|
556
721
|
writeStorage(TESTER_STORAGE_KEY, url);
|
|
@@ -670,6 +835,365 @@ function renderCurrent() {
|
|
|
670
835
|
);
|
|
671
836
|
}
|
|
672
837
|
|
|
838
|
+
// ---------------------------------------------------------------------------
|
|
839
|
+
// Open in app
|
|
840
|
+
|
|
841
|
+
/**
|
|
842
|
+
* Builds a URL for a route pattern with the same rules as `routePath()`:
|
|
843
|
+
* required params can't be empty, optional ones are dropped when empty (and
|
|
844
|
+
* then no later param may be given), and splats keep their slashes.
|
|
845
|
+
*/
|
|
846
|
+
function buildPath(fullPath, values) {
|
|
847
|
+
let omitted;
|
|
848
|
+
const segments = [];
|
|
849
|
+
for (const segment of fullPath.split("/").filter(Boolean)) {
|
|
850
|
+
if (segment === "*") {
|
|
851
|
+
const value = (values["*"] ?? "").trim();
|
|
852
|
+
if (value && omitted) throw new Error(`Fill in "${omitted}" before the rest of the path.`);
|
|
853
|
+
if (value) segments.push(value.replace(/^\/+/, "").split("/").map(encodeURIComponent).join("/"));
|
|
854
|
+
continue;
|
|
855
|
+
}
|
|
856
|
+
if (!segment.startsWith(":")) {
|
|
857
|
+
segments.push(segment);
|
|
858
|
+
continue;
|
|
859
|
+
}
|
|
860
|
+
const optional = segment.endsWith("?");
|
|
861
|
+
const name = segment.slice(1, optional ? -1 : undefined);
|
|
862
|
+
const value = (values[name] ?? "").trim();
|
|
863
|
+
if (!value) {
|
|
864
|
+
if (!optional) throw new Error(`Fill in "${name}".`);
|
|
865
|
+
omitted ??= name;
|
|
866
|
+
continue;
|
|
867
|
+
}
|
|
868
|
+
if (omitted) throw new Error(`Fill in "${omitted}" before "${name}".`);
|
|
869
|
+
segments.push(encodeURIComponent(value));
|
|
870
|
+
}
|
|
871
|
+
return `/${segments.join("/")}`;
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
function readRecentParams() {
|
|
875
|
+
try {
|
|
876
|
+
return JSON.parse(readStorage(RECENT_PARAMS_KEY) || "{}") ?? {};
|
|
877
|
+
} catch {
|
|
878
|
+
return {};
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
function rememberParams(fullPath, values) {
|
|
883
|
+
const recent = readRecentParams();
|
|
884
|
+
delete recent[fullPath];
|
|
885
|
+
recent[fullPath] = values;
|
|
886
|
+
const entries = Object.entries(recent).slice(-50);
|
|
887
|
+
writeStorage(RECENT_PARAMS_KEY, JSON.stringify(Object.fromEntries(entries)));
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
function openTarget() {
|
|
891
|
+
const tab = followedTab();
|
|
892
|
+
return tab ? `Open in ${tab.title || "app"}` : "Open in new tab";
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
async function openInApp(url) {
|
|
896
|
+
const tab = followedTab();
|
|
897
|
+
if (!tab) {
|
|
898
|
+
window.open(url, "_blank", "noopener");
|
|
899
|
+
toast(`Opened ${url} in a new tab`);
|
|
900
|
+
return;
|
|
901
|
+
}
|
|
902
|
+
try {
|
|
903
|
+
const response = await fetch(NAVIGATE_URL, {
|
|
904
|
+
method: "POST",
|
|
905
|
+
headers: { "Content-Type": "application/json" },
|
|
906
|
+
body: JSON.stringify({ tabId: tab.id, url }),
|
|
907
|
+
});
|
|
908
|
+
if (!response.ok) throw new Error((await response.text()) || `status ${response.status}`);
|
|
909
|
+
toast(`Opened ${url} in ${tab.title || "your app"}`);
|
|
910
|
+
} catch (error) {
|
|
911
|
+
toast(`Couldn't open the page: ${error.message}`);
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
function openButton(url) {
|
|
916
|
+
return el(
|
|
917
|
+
"button",
|
|
918
|
+
{ class: "button button-quiet", type: "button", onclick: () => openInApp(url) },
|
|
919
|
+
icon("go"),
|
|
920
|
+
openTarget(),
|
|
921
|
+
);
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
/** "Open in app" for a route: a button, or a form when it has params. */
|
|
925
|
+
function openForm(node, params) {
|
|
926
|
+
if (!params.length) return openButton(node.fullPath);
|
|
927
|
+
const saved = readRecentParams()[node.fullPath] ?? {};
|
|
928
|
+
const message = el("p", { class: "form-error", role: "alert", hidden: true });
|
|
929
|
+
const inputs = params.map(({ param, optional }) =>
|
|
930
|
+
el(
|
|
931
|
+
"label",
|
|
932
|
+
{ class: "param-field" },
|
|
933
|
+
el(
|
|
934
|
+
"span",
|
|
935
|
+
{},
|
|
936
|
+
param === "*" ? "rest of path" : param,
|
|
937
|
+
optional ? el("span", { class: "muted" }, " optional") : undefined,
|
|
938
|
+
),
|
|
939
|
+
el("input", {
|
|
940
|
+
name: param,
|
|
941
|
+
type: "text",
|
|
942
|
+
value: saved[param] ?? "",
|
|
943
|
+
placeholder: param === "*" ? "a/b/c" : optional ? "" : "required",
|
|
944
|
+
autocomplete: "off",
|
|
945
|
+
spellcheck: "false",
|
|
946
|
+
}),
|
|
947
|
+
),
|
|
948
|
+
);
|
|
949
|
+
const form = el(
|
|
950
|
+
"form",
|
|
951
|
+
{
|
|
952
|
+
class: "open-form",
|
|
953
|
+
id: "open-form",
|
|
954
|
+
onsubmit: (event) => {
|
|
955
|
+
event.preventDefault();
|
|
956
|
+
const values = Object.fromEntries(new FormData(form));
|
|
957
|
+
try {
|
|
958
|
+
const url = buildPath(node.fullPath, values);
|
|
959
|
+
message.hidden = true;
|
|
960
|
+
rememberParams(node.fullPath, values);
|
|
961
|
+
openInApp(url);
|
|
962
|
+
} catch (error) {
|
|
963
|
+
message.textContent = error.message;
|
|
964
|
+
message.hidden = false;
|
|
965
|
+
}
|
|
966
|
+
},
|
|
967
|
+
},
|
|
968
|
+
inputs,
|
|
969
|
+
message,
|
|
970
|
+
el("button", { class: "button button-quiet", type: "submit" }, icon("go"), openTarget()),
|
|
971
|
+
);
|
|
972
|
+
return form;
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
/** The tree row's open button: navigates directly, or shows the params form. */
|
|
976
|
+
function rowOpenButton(node, key) {
|
|
977
|
+
if (!node.files.page) return undefined;
|
|
978
|
+
const dynamic = /(^|\/)(:|\*)/.test(node.fullPath);
|
|
979
|
+
return el(
|
|
980
|
+
"button",
|
|
981
|
+
{
|
|
982
|
+
class: "row-open",
|
|
983
|
+
type: "button",
|
|
984
|
+
tabindex: "-1",
|
|
985
|
+
title: dynamic ? "Fill in params to open this route" : `${openTarget()}: ${node.fullPath}`,
|
|
986
|
+
"aria-label": dynamic ? "Fill in params to open this route" : `${openTarget()}: ${node.fullPath}`,
|
|
987
|
+
onclick: (event) => {
|
|
988
|
+
event.stopPropagation();
|
|
989
|
+
if (!dynamic) {
|
|
990
|
+
openInApp(node.fullPath);
|
|
991
|
+
return;
|
|
992
|
+
}
|
|
993
|
+
showView("inspect");
|
|
994
|
+
select(key);
|
|
995
|
+
document.querySelector("#open-form input")?.focus();
|
|
996
|
+
},
|
|
997
|
+
},
|
|
998
|
+
icon("go"),
|
|
999
|
+
);
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
// ---------------------------------------------------------------------------
|
|
1003
|
+
// Pages
|
|
1004
|
+
|
|
1005
|
+
const PAGES = ["routes", "timeline", "code"];
|
|
1006
|
+
|
|
1007
|
+
function currentPage() {
|
|
1008
|
+
const name = window.location.hash.slice(1);
|
|
1009
|
+
return PAGES.includes(name) ? name : "routes";
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
/** Shows a page. `inspect` is the Routes page's older name. */
|
|
1013
|
+
function showView(name) {
|
|
1014
|
+
const page = name === "inspect" ? "routes" : name;
|
|
1015
|
+
if (window.location.hash !== `#${page}`) {
|
|
1016
|
+
history.replaceState(null, "", `#${page}`);
|
|
1017
|
+
}
|
|
1018
|
+
applyPage();
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
function applyPage() {
|
|
1022
|
+
const page = currentPage();
|
|
1023
|
+
for (const name of PAGES) $(`#page-${name}`).hidden = name !== page;
|
|
1024
|
+
for (const link of pageNav.querySelectorAll("a")) {
|
|
1025
|
+
if (link.dataset.page === page) link.setAttribute("aria-current", "page");
|
|
1026
|
+
else link.removeAttribute("aria-current");
|
|
1027
|
+
}
|
|
1028
|
+
if (page === "timeline") state.unseen = 0;
|
|
1029
|
+
renderTimeline();
|
|
1030
|
+
if (page === "code" && (!state.code.modules || state.code.stale)) loadCode();
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
// ---------------------------------------------------------------------------
|
|
1034
|
+
// Generated code
|
|
1035
|
+
|
|
1036
|
+
const CODE_MODULES = [
|
|
1037
|
+
["virtual:routes", "Route objects passed to React Router, with lazy imports and preloading."],
|
|
1038
|
+
["virtual:route-manifest", "The route tree as data: paths, params, and files."],
|
|
1039
|
+
["virtual:route-helpers", "routePath(), useRouteParams(), and useRouteData()."],
|
|
1040
|
+
["route-types", "Types for route patterns, params, and loader data."],
|
|
1041
|
+
];
|
|
1042
|
+
|
|
1043
|
+
const KEYWORDS = new Set(
|
|
1044
|
+
"import export from const let var function return async await if else for of in new typeof type interface declare module extends default throw try catch null undefined true false class keyof infer as".split(
|
|
1045
|
+
" ",
|
|
1046
|
+
),
|
|
1047
|
+
);
|
|
1048
|
+
const TOKEN_PATTERN =
|
|
1049
|
+
/(\/\/[^\n]*|\/\*[\s\S]*?\*\/)|("(?:[^"\\\n]|\\.)*"|'(?:[^'\\\n]|\\.)*'|`(?:[^`\\]|\\.)*`)|\b([A-Za-z_$][\w$]*)\b|\b(\d+(?:\.\d+)?)\b/g;
|
|
1050
|
+
|
|
1051
|
+
/** A small highlighter for the generated JavaScript and TypeScript. */
|
|
1052
|
+
function highlight(code) {
|
|
1053
|
+
const pieces = [];
|
|
1054
|
+
let last = 0;
|
|
1055
|
+
for (const match of code.matchAll(TOKEN_PATTERN)) {
|
|
1056
|
+
const [text, comment, string, word, number] = match;
|
|
1057
|
+
const kind = comment
|
|
1058
|
+
? "comment"
|
|
1059
|
+
: string
|
|
1060
|
+
? "string"
|
|
1061
|
+
: number
|
|
1062
|
+
? "number"
|
|
1063
|
+
: word && KEYWORDS.has(word)
|
|
1064
|
+
? "keyword"
|
|
1065
|
+
: undefined;
|
|
1066
|
+
if (!kind) continue;
|
|
1067
|
+
if (match.index > last) pieces.push([undefined, code.slice(last, match.index)]);
|
|
1068
|
+
pieces.push([kind, text]);
|
|
1069
|
+
last = match.index + text.length;
|
|
1070
|
+
}
|
|
1071
|
+
if (last < code.length) pieces.push([undefined, code.slice(last)]);
|
|
1072
|
+
|
|
1073
|
+
// Split the pieces into lines, so each line can carry its number.
|
|
1074
|
+
const lines = [[]];
|
|
1075
|
+
for (const [kind, text] of pieces) {
|
|
1076
|
+
text.split("\n").forEach((part, index) => {
|
|
1077
|
+
if (index > 0) lines.push([]);
|
|
1078
|
+
if (part) lines.at(-1).push(kind ? el("span", { class: `tok-${kind}` }, part) : document.createTextNode(part));
|
|
1079
|
+
});
|
|
1080
|
+
}
|
|
1081
|
+
if (lines.length > 1 && lines.at(-1).length === 0) lines.pop();
|
|
1082
|
+
return lines.map((parts) => el("span", { class: "code-line" }, parts.length ? parts : ""));
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
function formatBytes(bytes) {
|
|
1086
|
+
return bytes < 1024 ? `${bytes} B` : `${(bytes / 1024).toFixed(1)} KB`;
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
function selectedModule() {
|
|
1090
|
+
const { modules, selected, variant } = state.code;
|
|
1091
|
+
return modules?.find(
|
|
1092
|
+
(module) => module.id === selected && (module.variant === undefined || module.variant === variant),
|
|
1093
|
+
);
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
function renderCode() {
|
|
1097
|
+
const { modules, selected, variant, failure } = state.code;
|
|
1098
|
+
codeModulesElement.replaceChildren(
|
|
1099
|
+
...CODE_MODULES.map(([id, description]) =>
|
|
1100
|
+
el(
|
|
1101
|
+
"button",
|
|
1102
|
+
{
|
|
1103
|
+
class: "code-module",
|
|
1104
|
+
type: "button",
|
|
1105
|
+
role: "tab",
|
|
1106
|
+
"aria-selected": String(id === selected),
|
|
1107
|
+
onclick: () => {
|
|
1108
|
+
state.code.selected = id;
|
|
1109
|
+
renderCode();
|
|
1110
|
+
},
|
|
1111
|
+
},
|
|
1112
|
+
el("span", { class: "code-module-name" }, id === "route-types" ? "Route types (.d.ts)" : id),
|
|
1113
|
+
el("span", { class: "code-module-description" }, description),
|
|
1114
|
+
),
|
|
1115
|
+
),
|
|
1116
|
+
);
|
|
1117
|
+
|
|
1118
|
+
if (failure) {
|
|
1119
|
+
codeViewElement.replaceChildren(el("p", { class: "empty" }, failure));
|
|
1120
|
+
return;
|
|
1121
|
+
}
|
|
1122
|
+
if (!modules) {
|
|
1123
|
+
codeViewElement.replaceChildren(el("p", { class: "empty" }, "Loading generated code…"));
|
|
1124
|
+
return;
|
|
1125
|
+
}
|
|
1126
|
+
const module = selectedModule();
|
|
1127
|
+
const code = module?.code ?? "";
|
|
1128
|
+
const notes = [];
|
|
1129
|
+
if (module?.id === "virtual:routes") {
|
|
1130
|
+
notes.push(
|
|
1131
|
+
variant === "dev"
|
|
1132
|
+
? "As the dev server serves it, with the DevTools client and call reporting."
|
|
1133
|
+
: "As vite build generates it, without any DevTools code.",
|
|
1134
|
+
);
|
|
1135
|
+
}
|
|
1136
|
+
if (module?.id === "route-types") {
|
|
1137
|
+
notes.push(
|
|
1138
|
+
module.written
|
|
1139
|
+
? `Written to ${module.file}.`
|
|
1140
|
+
: `Not written: the dts option is false, or the project has no tsconfig.json or jsconfig.json. With dts set, it goes to ${module.file}.`,
|
|
1141
|
+
);
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
codeViewElement.replaceChildren(
|
|
1145
|
+
el(
|
|
1146
|
+
"div",
|
|
1147
|
+
{ class: "code-toolbar" },
|
|
1148
|
+
module?.id === "virtual:routes"
|
|
1149
|
+
? el(
|
|
1150
|
+
"div",
|
|
1151
|
+
{ class: "segmented code-variant", role: "group", "aria-label": "Variant" },
|
|
1152
|
+
["dev", "build"].map((name) =>
|
|
1153
|
+
el(
|
|
1154
|
+
"button",
|
|
1155
|
+
{
|
|
1156
|
+
type: "button",
|
|
1157
|
+
"aria-pressed": String(name === variant),
|
|
1158
|
+
onclick: () => {
|
|
1159
|
+
state.code.variant = name;
|
|
1160
|
+
renderCode();
|
|
1161
|
+
},
|
|
1162
|
+
},
|
|
1163
|
+
name === "dev" ? "Dev server" : "Build",
|
|
1164
|
+
),
|
|
1165
|
+
),
|
|
1166
|
+
)
|
|
1167
|
+
: undefined,
|
|
1168
|
+
el(
|
|
1169
|
+
"span",
|
|
1170
|
+
{ class: "code-size muted" },
|
|
1171
|
+
module?.code === undefined
|
|
1172
|
+
? ""
|
|
1173
|
+
: `${code.split("\n").length - (code.endsWith("\n") ? 1 : 0)} lines · ${formatBytes(new TextEncoder().encode(code).length)}`,
|
|
1174
|
+
),
|
|
1175
|
+
module?.code !== undefined ? copyButton(code, "Copy") : undefined,
|
|
1176
|
+
),
|
|
1177
|
+
notes.length ? el("p", { class: "code-note muted" }, notes.join(" ")) : undefined,
|
|
1178
|
+
module?.error
|
|
1179
|
+
? el("p", { class: "code-error" }, module.error)
|
|
1180
|
+
: el("pre", { class: "code", tabindex: "0" }, el("code", {}, highlight(code))),
|
|
1181
|
+
);
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
async function loadCode() {
|
|
1185
|
+
state.code.stale = false;
|
|
1186
|
+
try {
|
|
1187
|
+
const response = await fetch(CODE_URL, { cache: "no-store" });
|
|
1188
|
+
if (!response.ok) throw new Error(`Request failed (${response.status})`);
|
|
1189
|
+
state.code.modules = (await response.json()).modules;
|
|
1190
|
+
state.code.failure = undefined;
|
|
1191
|
+
} catch (error) {
|
|
1192
|
+
state.code.failure = `Couldn't load the generated code: ${error.message}`;
|
|
1193
|
+
}
|
|
1194
|
+
renderCode();
|
|
1195
|
+
}
|
|
1196
|
+
|
|
673
1197
|
// ---------------------------------------------------------------------------
|
|
674
1198
|
// Loader and action timeline
|
|
675
1199
|
|
|
@@ -719,6 +1243,8 @@ function outcomeChip(call) {
|
|
|
719
1243
|
return el("span", { class: "chip chip-loading" }, `${call.status} redirect`);
|
|
720
1244
|
case "error":
|
|
721
1245
|
return el("span", { class: "chip chip-error" }, call.status ? `${call.status} thrown` : "error");
|
|
1246
|
+
case "next":
|
|
1247
|
+
return el("span", { class: "chip chip-loader" }, "next()");
|
|
722
1248
|
case "response":
|
|
723
1249
|
return el("span", { class: "chip chip-layout" }, `${call.status} response`);
|
|
724
1250
|
default:
|
|
@@ -744,17 +1270,216 @@ function callText(call) {
|
|
|
744
1270
|
.toLowerCase();
|
|
745
1271
|
}
|
|
746
1272
|
|
|
1273
|
+
// ---------------------------------------------------------------------------
|
|
1274
|
+
// Call inspector
|
|
1275
|
+
|
|
1276
|
+
const MAX_TREE_CHILDREN = 200;
|
|
1277
|
+
|
|
1278
|
+
function jsonPreview(value) {
|
|
1279
|
+
if (Array.isArray(value)) return `Array(${value.length})`;
|
|
1280
|
+
if (value && typeof value === "object") {
|
|
1281
|
+
const keys = Object.keys(value);
|
|
1282
|
+
return `{${keys.slice(0, 4).join(", ")}${keys.length > 4 ? ", …" : ""}}`;
|
|
1283
|
+
}
|
|
1284
|
+
return undefined;
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
function jsonLeaf(value) {
|
|
1288
|
+
const type = value === null ? "null" : typeof value;
|
|
1289
|
+
const text = type === "string" ? JSON.stringify(value) : String(value);
|
|
1290
|
+
return el("span", { class: `json-${type}` }, text);
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
/** Renders parsed JSON as nested, collapsible `<details>` elements. */
|
|
1294
|
+
function jsonTree(value, name, depth = 0) {
|
|
1295
|
+
const label = name === undefined ? undefined : el("span", { class: "json-key" }, `${name}: `);
|
|
1296
|
+
if (!value || typeof value !== "object") {
|
|
1297
|
+
return el("div", { class: "json-row" }, label, jsonLeaf(value));
|
|
1298
|
+
}
|
|
1299
|
+
const entries = Array.isArray(value) ? value.map((item, index) => [index, item]) : Object.entries(value);
|
|
1300
|
+
return el(
|
|
1301
|
+
"details",
|
|
1302
|
+
{ class: "json-node", open: depth < 2 || undefined },
|
|
1303
|
+
el("summary", {}, label, el("span", { class: "json-preview" }, jsonPreview(value))),
|
|
1304
|
+
el(
|
|
1305
|
+
"div",
|
|
1306
|
+
{ class: "json-children" },
|
|
1307
|
+
entries.slice(0, MAX_TREE_CHILDREN).map(([key, item]) => jsonTree(item, key, depth + 1)),
|
|
1308
|
+
entries.length > MAX_TREE_CHILDREN
|
|
1309
|
+
? el("div", { class: "muted" }, `… ${entries.length - MAX_TREE_CHILDREN} more`)
|
|
1310
|
+
: undefined,
|
|
1311
|
+
),
|
|
1312
|
+
);
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
function copyButton(text, label = "Copy") {
|
|
1316
|
+
return el(
|
|
1317
|
+
"button",
|
|
1318
|
+
{
|
|
1319
|
+
class: "link-button",
|
|
1320
|
+
type: "button",
|
|
1321
|
+
onclick: async () => {
|
|
1322
|
+
try {
|
|
1323
|
+
await navigator.clipboard.writeText(text);
|
|
1324
|
+
toast("Copied to the clipboard");
|
|
1325
|
+
} catch {
|
|
1326
|
+
toast("Couldn't copy: the clipboard isn't available here");
|
|
1327
|
+
}
|
|
1328
|
+
},
|
|
1329
|
+
},
|
|
1330
|
+
label,
|
|
1331
|
+
);
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
function renderData(call) {
|
|
1335
|
+
if (call.data === undefined) {
|
|
1336
|
+
return call.preview ? el("pre", { class: "call-preview" }, call.preview) : undefined;
|
|
1337
|
+
}
|
|
1338
|
+
let content;
|
|
1339
|
+
if (call.dataTruncated) {
|
|
1340
|
+
content = el("pre", { class: "call-preview" }, `${call.data}…`);
|
|
1341
|
+
} else {
|
|
1342
|
+
try {
|
|
1343
|
+
const parsed = JSON.parse(call.data);
|
|
1344
|
+
content =
|
|
1345
|
+
parsed && typeof parsed === "object"
|
|
1346
|
+
? el("div", { class: "json" }, jsonTree(parsed))
|
|
1347
|
+
: el("div", { class: "json" }, jsonLeaf(parsed));
|
|
1348
|
+
} catch {
|
|
1349
|
+
content = el("pre", { class: "call-preview" }, call.data);
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
return el(
|
|
1353
|
+
"div",
|
|
1354
|
+
{ class: "call-data" },
|
|
1355
|
+
content,
|
|
1356
|
+
el(
|
|
1357
|
+
"div",
|
|
1358
|
+
{ class: "call-data-actions" },
|
|
1359
|
+
call.dataTruncated ? el("span", { class: "muted" }, "Truncated at 8 KB.") : undefined,
|
|
1360
|
+
copyButton(call.data, "Copy JSON"),
|
|
1361
|
+
),
|
|
1362
|
+
);
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
function pairTable(entries) {
|
|
1366
|
+
return el(
|
|
1367
|
+
"table",
|
|
1368
|
+
{ class: "pairs" },
|
|
1369
|
+
el(
|
|
1370
|
+
"tbody",
|
|
1371
|
+
{},
|
|
1372
|
+
entries.map(([name, value]) => el("tr", {}, el("th", { scope: "row" }, name), el("td", {}, value))),
|
|
1373
|
+
),
|
|
1374
|
+
);
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
function paramPills(params) {
|
|
1378
|
+
return el(
|
|
1379
|
+
"div",
|
|
1380
|
+
{ class: "params" },
|
|
1381
|
+
Object.entries(params).map(([name, value]) =>
|
|
1382
|
+
el("span", { class: "param-pill" }, el("span", {}, name), el("span", {}, value)),
|
|
1383
|
+
),
|
|
1384
|
+
);
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
function revalidateText(call) {
|
|
1388
|
+
return [
|
|
1389
|
+
call.decision
|
|
1390
|
+
? "shouldRevalidate returned true, so the loader runs."
|
|
1391
|
+
: "shouldRevalidate returned false, so the loader was skipped.",
|
|
1392
|
+
call.defaultShouldRevalidate === undefined
|
|
1393
|
+
? ""
|
|
1394
|
+
: ` React Router's default was to ${call.defaultShouldRevalidate ? "revalidate" : "skip"}.`,
|
|
1395
|
+
].join("");
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
function renderCallBody(call, found) {
|
|
1399
|
+
const routeFile = found?.node.files[call.type === "revalidate" ? "loader" : call.type];
|
|
1400
|
+
const status =
|
|
1401
|
+
call.status !== undefined && call.outcome !== "redirect" ? String(call.status) : undefined;
|
|
1402
|
+
return el(
|
|
1403
|
+
"div",
|
|
1404
|
+
{ class: "call-body" },
|
|
1405
|
+
el(
|
|
1406
|
+
"dl",
|
|
1407
|
+
{ class: "facts" },
|
|
1408
|
+
found
|
|
1409
|
+
? fact(
|
|
1410
|
+
"Route",
|
|
1411
|
+
el("button", { class: "link-button", type: "button", onclick: () => revealRoute(found.key) }, routeLabel(call.routeId)),
|
|
1412
|
+
)
|
|
1413
|
+
: undefined,
|
|
1414
|
+
routeFile
|
|
1415
|
+
? fact({ action: "Action", middleware: "Middleware" }[call.type] ?? "Loader", fileLink(routeFile))
|
|
1416
|
+
: undefined,
|
|
1417
|
+
call.type === "revalidate"
|
|
1418
|
+
? [
|
|
1419
|
+
fact("Decision", el("span", {}, revalidateText(call))),
|
|
1420
|
+
call.currentUrl ? fact("From", el("code", { class: "file-path" }, call.currentUrl)) : undefined,
|
|
1421
|
+
call.url ? fact("To", el("code", { class: "file-path" }, call.url)) : undefined,
|
|
1422
|
+
call.formMethod ? fact("Form method", el("code", {}, call.formMethod)) : undefined,
|
|
1423
|
+
call.actionStatus !== undefined ? fact("Action status", el("code", {}, String(call.actionStatus))) : undefined,
|
|
1424
|
+
]
|
|
1425
|
+
: call.url
|
|
1426
|
+
? fact("Request", el("code", { class: "file-path" }, `${call.method ?? ""} ${call.url}`.trim()))
|
|
1427
|
+
: undefined,
|
|
1428
|
+
call.params ? fact("Params", paramPills(call.params)) : undefined,
|
|
1429
|
+
call.body?.kind === "form"
|
|
1430
|
+
? fact(
|
|
1431
|
+
"Form data",
|
|
1432
|
+
call.body.entries.length ? pairTable(call.body.entries) : el("span", { class: "muted" }, "No fields."),
|
|
1433
|
+
)
|
|
1434
|
+
: undefined,
|
|
1435
|
+
call.body?.kind === "text"
|
|
1436
|
+
? fact(`Body${call.body.contentType ? ` (${call.body.contentType})` : ""}`, el("pre", { class: "call-preview" }, call.body.text))
|
|
1437
|
+
: undefined,
|
|
1438
|
+
status ? fact("Status", el("code", {}, status)) : undefined,
|
|
1439
|
+
call.location ? fact("Redirects to", el("code", { class: "file-path" }, call.location)) : undefined,
|
|
1440
|
+
call.errorName || call.message
|
|
1441
|
+
? fact(
|
|
1442
|
+
"Error",
|
|
1443
|
+
el(
|
|
1444
|
+
"div",
|
|
1445
|
+
{ class: "call-error" },
|
|
1446
|
+
el("strong", {}, call.errorName ? `${call.errorName}: ` : ""),
|
|
1447
|
+
call.message ?? "",
|
|
1448
|
+
call.stack ? el("pre", { class: "call-preview call-stack" }, call.stack) : undefined,
|
|
1449
|
+
),
|
|
1450
|
+
)
|
|
1451
|
+
: undefined,
|
|
1452
|
+
call.type !== "revalidate" && (call.data !== undefined || call.preview)
|
|
1453
|
+
? fact(call.outcome === "error" ? "Thrown value" : "Returned data", renderData(call))
|
|
1454
|
+
: undefined,
|
|
1455
|
+
call.responseBody ? fact("Response body", el("pre", { class: "call-preview" }, call.responseBody)) : undefined,
|
|
1456
|
+
call.headers?.length
|
|
1457
|
+
? fact(
|
|
1458
|
+
"Headers",
|
|
1459
|
+
el(
|
|
1460
|
+
"details",
|
|
1461
|
+
{ class: "call-headers" },
|
|
1462
|
+
el("summary", {}, `${call.headers.length} header${call.headers.length === 1 ? "" : "s"}`),
|
|
1463
|
+
pairTable(call.headers),
|
|
1464
|
+
),
|
|
1465
|
+
)
|
|
1466
|
+
: undefined,
|
|
1467
|
+
call.tabId && state.tabs.length > 1
|
|
1468
|
+
? fact("Tab", el("span", { class: "muted" }, state.tabs.find((tab) => tab.id === call.tabId)?.title || call.tabId))
|
|
1469
|
+
: undefined,
|
|
1470
|
+
),
|
|
1471
|
+
);
|
|
1472
|
+
}
|
|
1473
|
+
|
|
747
1474
|
function renderCall(call) {
|
|
748
1475
|
const found = nodeForRouteId(call.routeId);
|
|
749
|
-
const routeFile =
|
|
750
|
-
call.type === "action" ? found?.node.files.action : found?.node.files.loader;
|
|
751
1476
|
const details = el(
|
|
752
1477
|
"details",
|
|
753
1478
|
{ class: "call", open: state.openCalls.has(call.id) || undefined },
|
|
754
1479
|
el(
|
|
755
1480
|
"summary",
|
|
756
1481
|
{ class: "call-summary" },
|
|
757
|
-
el("span", { class: `chip chip-${
|
|
1482
|
+
el("span", { class: `chip chip-${{ action: "action", revalidate: "group", middleware: "middleware" }[call.type] ?? "page"}` }, call.type),
|
|
758
1483
|
el("span", { class: "call-route", title: call.routeId }, routeLabel(call.routeId)),
|
|
759
1484
|
call.type === "revalidate"
|
|
760
1485
|
? outcomeChip(call)
|
|
@@ -776,50 +1501,83 @@ function renderCall(call) {
|
|
|
776
1501
|
el("time", { datetime: new Date(call.startedAt).toISOString() }, formatTime(call.startedAt)),
|
|
777
1502
|
),
|
|
778
1503
|
),
|
|
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
1504
|
);
|
|
1505
|
+
// The body can be large, so it's only built when the call is opened.
|
|
1506
|
+
const build = () => {
|
|
1507
|
+
if (details.open && !details.querySelector(".call-body")) details.append(renderCallBody(call, found));
|
|
1508
|
+
};
|
|
1509
|
+
build();
|
|
811
1510
|
details.addEventListener("toggle", () => {
|
|
812
1511
|
if (details.open) state.openCalls.add(call.id);
|
|
813
1512
|
else state.openCalls.delete(call.id);
|
|
1513
|
+
build();
|
|
814
1514
|
});
|
|
815
1515
|
return el("li", {}, details);
|
|
816
1516
|
}
|
|
817
1517
|
|
|
1518
|
+
// ---------------------------------------------------------------------------
|
|
1519
|
+
// Per-route call stats
|
|
1520
|
+
|
|
1521
|
+
/** The route ID the generated routes give a directory with a loader or action. */
|
|
1522
|
+
function routeIdForNode(node) {
|
|
1523
|
+
const file = node.files.loader ?? node.files.action ?? node.files.middleware;
|
|
1524
|
+
return file ? `file-route:${file.slice(0, file.lastIndexOf("/"))}` : undefined;
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
function dataStats(node) {
|
|
1528
|
+
const routeId = routeIdForNode(node);
|
|
1529
|
+
if (!routeId) return undefined;
|
|
1530
|
+
const calls = state.activity.filter(
|
|
1531
|
+
(call) => call.id > state.clearedThrough && call.routeId === routeId && call.type !== "revalidate",
|
|
1532
|
+
);
|
|
1533
|
+
const summary = (type) => {
|
|
1534
|
+
const list = calls.filter((call) => call.type === type);
|
|
1535
|
+
if (!list.length) return undefined;
|
|
1536
|
+
const durations = list.map((call) => call.duration ?? 0);
|
|
1537
|
+
const average = durations.reduce((total, value) => total + value, 0) / durations.length;
|
|
1538
|
+
const errors = list.filter((call) => call.outcome === "error").length;
|
|
1539
|
+
const last = list.at(-1);
|
|
1540
|
+
return el(
|
|
1541
|
+
"div",
|
|
1542
|
+
{ class: "stat-row" },
|
|
1543
|
+
el("span", { class: "stat-type" }, { loader: "Loader", action: "Action", middleware: "Middleware" }[type]),
|
|
1544
|
+
el("span", {}, `${list.length} call${list.length === 1 ? "" : "s"}`),
|
|
1545
|
+
el("span", {}, `avg ${formatDuration(average)}`),
|
|
1546
|
+
el("span", { class: Math.max(...durations) >= SLOW_CALL_MS ? "slow" : undefined }, `max ${formatDuration(Math.max(...durations))}`),
|
|
1547
|
+
errors ? el("span", { class: "error-text" }, `${errors} error${errors === 1 ? "" : "s"}`) : undefined,
|
|
1548
|
+
el("span", { class: "muted" }, "last ", outcomeChip(last)),
|
|
1549
|
+
);
|
|
1550
|
+
};
|
|
1551
|
+
return el(
|
|
1552
|
+
"div",
|
|
1553
|
+
{ class: "data-stats" },
|
|
1554
|
+
el("h3", {}, "Data calls"),
|
|
1555
|
+
calls.length
|
|
1556
|
+
? [summary("middleware"), summary("loader"), summary("action")]
|
|
1557
|
+
: el("p", { class: "muted" }, "No calls recorded yet. Visit this route in your app."),
|
|
1558
|
+
calls.length
|
|
1559
|
+
? el(
|
|
1560
|
+
"button",
|
|
1561
|
+
{
|
|
1562
|
+
class: "link-button",
|
|
1563
|
+
type: "button",
|
|
1564
|
+
onclick: () => {
|
|
1565
|
+
state.timelineRoute = routeId;
|
|
1566
|
+
showView("timeline");
|
|
1567
|
+
},
|
|
1568
|
+
},
|
|
1569
|
+
"Show these calls in the timeline",
|
|
1570
|
+
)
|
|
1571
|
+
: undefined,
|
|
1572
|
+
);
|
|
1573
|
+
}
|
|
1574
|
+
|
|
818
1575
|
function visibleCalls() {
|
|
819
1576
|
const query = timelineFilter.value.trim().toLowerCase();
|
|
820
1577
|
return state.activity
|
|
821
1578
|
.filter((call) => call.id > state.clearedThrough)
|
|
822
1579
|
.filter((call) => state.timelineType === "all" || call.type === state.timelineType)
|
|
1580
|
+
.filter((call) => !state.timelineRoute || call.routeId === state.timelineRoute)
|
|
823
1581
|
.filter((call) => !query || callText(call).includes(query))
|
|
824
1582
|
.reverse();
|
|
825
1583
|
}
|
|
@@ -828,6 +1586,25 @@ function renderTimeline() {
|
|
|
828
1586
|
for (const button of timelineTypes.querySelectorAll("button")) {
|
|
829
1587
|
button.setAttribute("aria-pressed", String(button.dataset.type === state.timelineType));
|
|
830
1588
|
}
|
|
1589
|
+
timelineRoutePill.hidden = !state.timelineRoute;
|
|
1590
|
+
if (state.timelineRoute) {
|
|
1591
|
+
timelineRoutePill.replaceChildren(
|
|
1592
|
+
el("span", {}, "Route ", el("strong", {}, routeLabel(state.timelineRoute))),
|
|
1593
|
+
el(
|
|
1594
|
+
"button",
|
|
1595
|
+
{
|
|
1596
|
+
type: "button",
|
|
1597
|
+
"aria-label": "Show calls for every route",
|
|
1598
|
+
title: "Show calls for every route",
|
|
1599
|
+
onclick: () => {
|
|
1600
|
+
state.timelineRoute = undefined;
|
|
1601
|
+
renderTimeline();
|
|
1602
|
+
},
|
|
1603
|
+
},
|
|
1604
|
+
"×",
|
|
1605
|
+
),
|
|
1606
|
+
);
|
|
1607
|
+
}
|
|
831
1608
|
const hasCalls = state.activity.some((call) => call.id > state.clearedThrough);
|
|
832
1609
|
timelineElement.dataset.empty = hasCalls
|
|
833
1610
|
? "No calls match this filter."
|
|
@@ -842,19 +1619,15 @@ function addActivity(calls) {
|
|
|
842
1619
|
if (state.activity.length > MAX_TIMELINE) {
|
|
843
1620
|
state.activity.splice(0, state.activity.length - MAX_TIMELINE);
|
|
844
1621
|
}
|
|
845
|
-
if (
|
|
1622
|
+
if (currentPage() !== "timeline") state.unseen += calls.length;
|
|
846
1623
|
renderTimeline();
|
|
1624
|
+
refreshSlowWarnings();
|
|
1625
|
+
// Keep the selected route's call stats current.
|
|
1626
|
+
const selected = state.nodes.get(state.selected)?.node;
|
|
1627
|
+
const routeId = selected && routeIdForNode(selected);
|
|
1628
|
+
if (routeId && calls.some((call) => call.routeId === routeId)) renderDetails();
|
|
847
1629
|
}
|
|
848
1630
|
|
|
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
1631
|
|
|
859
1632
|
function applyData(data) {
|
|
860
1633
|
const snapshot = JSON.stringify(data);
|
|
@@ -867,12 +1640,17 @@ function applyData(data) {
|
|
|
867
1640
|
if (!state.nodes.has(state.selected)) state.selected = "0";
|
|
868
1641
|
renderStats();
|
|
869
1642
|
renderConflicts();
|
|
1643
|
+
state.slowSignature = "";
|
|
1644
|
+
renderWarnings();
|
|
870
1645
|
updateCurrentKeys();
|
|
871
1646
|
renderTree();
|
|
872
1647
|
renderDetails();
|
|
873
1648
|
renderCurrent();
|
|
874
1649
|
renderTimeline();
|
|
875
1650
|
if (testerInput.value.trim()) runMatch();
|
|
1651
|
+
// Generated code follows the route files; refetch it now if it's showing.
|
|
1652
|
+
state.code.stale = true;
|
|
1653
|
+
if (currentPage() === "code" && state.code.modules) loadCode();
|
|
876
1654
|
}
|
|
877
1655
|
|
|
878
1656
|
async function refresh() {
|
|
@@ -906,6 +1684,9 @@ function connect() {
|
|
|
906
1684
|
updateCurrentKeys();
|
|
907
1685
|
renderCurrent();
|
|
908
1686
|
renderTree();
|
|
1687
|
+
// "Open in" buttons name the followed tab.
|
|
1688
|
+
if (!detailsElement.contains(document.activeElement)) renderDetails();
|
|
1689
|
+
renderMatch();
|
|
909
1690
|
});
|
|
910
1691
|
events.addEventListener("activity-snapshot", (event) => {
|
|
911
1692
|
// Sent on every (re)connect; keep only calls this panel hasn't seen.
|
|
@@ -923,8 +1704,7 @@ function connect() {
|
|
|
923
1704
|
}
|
|
924
1705
|
|
|
925
1706
|
filterElement.addEventListener("input", renderTree);
|
|
926
|
-
|
|
927
|
-
timelineTab.addEventListener("click", () => showView("timeline"));
|
|
1707
|
+
window.addEventListener("hashchange", applyPage);
|
|
928
1708
|
timelineFilter.addEventListener("input", renderTimeline);
|
|
929
1709
|
timelineTypes.addEventListener("click", (event) => {
|
|
930
1710
|
const type = event.target.closest("button")?.dataset.type;
|
|
@@ -936,6 +1716,8 @@ timelineClear.addEventListener("click", () => {
|
|
|
936
1716
|
state.clearedThrough = state.activity.at(-1)?.id ?? state.clearedThrough;
|
|
937
1717
|
state.openCalls.clear();
|
|
938
1718
|
renderTimeline();
|
|
1719
|
+
refreshSlowWarnings();
|
|
1720
|
+
renderDetails();
|
|
939
1721
|
});
|
|
940
1722
|
refreshElement.addEventListener("click", refresh);
|
|
941
1723
|
toggleElement.addEventListener("click", () => {
|
|
@@ -956,6 +1738,6 @@ testerForm.addEventListener("submit", (event) => {
|
|
|
956
1738
|
testerInput.value = readStorage(TESTER_STORAGE_KEY);
|
|
957
1739
|
renderTree();
|
|
958
1740
|
renderCurrent();
|
|
959
|
-
|
|
1741
|
+
applyPage();
|
|
960
1742
|
refresh();
|
|
961
1743
|
connect();
|