@troyio/vite-file-router 0.4.1 → 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/README.md +664 -536
- package/devtools/app.js +619 -34
- package/devtools/index.html +6 -0
- package/devtools/style.css +456 -102
- package/dist/devtools-app.d.ts +40 -2
- package/dist/devtools.d.ts +25 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.js +523 -40
- package/dist/scanner.d.ts +2 -1
- package/package.json +1 -1
- package/types/index.d.ts +3 -0
package/devtools/app.js
CHANGED
|
@@ -2,6 +2,8 @@ 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 RECENT_PARAMS_KEY = "vite-file-router:recent-params";
|
|
5
7
|
const TESTER_STORAGE_KEY = "vite-file-router:tester-url";
|
|
6
8
|
|
|
7
9
|
const $ = (selector) => document.querySelector(selector);
|
|
@@ -9,6 +11,7 @@ const treeElement = $("#tree");
|
|
|
9
11
|
const statsElement = $("#stats");
|
|
10
12
|
const errorElement = $("#error");
|
|
11
13
|
const conflictsElement = $("#conflicts");
|
|
14
|
+
const warningsElement = $("#warnings");
|
|
12
15
|
const filterElement = $("#filter");
|
|
13
16
|
const toggleElement = $("#toggle");
|
|
14
17
|
const refreshElement = $("#refresh");
|
|
@@ -30,6 +33,7 @@ const timelineElement = $("#timeline");
|
|
|
30
33
|
const timelineFilter = $("#timeline-filter");
|
|
31
34
|
const timelineTypes = $("#timeline-types");
|
|
32
35
|
const timelineClear = $("#timeline-clear");
|
|
36
|
+
const timelineRoutePill = $("#timeline-route");
|
|
33
37
|
|
|
34
38
|
const MAX_TIMELINE = 200;
|
|
35
39
|
const SLOW_CALL_MS = 500;
|
|
@@ -42,6 +46,7 @@ const FILE_KINDS = [
|
|
|
42
46
|
["notFound", "Not found", "404"],
|
|
43
47
|
["loader", "Loader", "loader"],
|
|
44
48
|
["action", "Action", "action"],
|
|
49
|
+
["middleware", "Middleware", "mw"],
|
|
45
50
|
];
|
|
46
51
|
|
|
47
52
|
const state = {
|
|
@@ -61,7 +66,11 @@ const state = {
|
|
|
61
66
|
clearedThrough: 0,
|
|
62
67
|
unseen: 0,
|
|
63
68
|
timelineType: "all",
|
|
69
|
+
// Exact route ID the timeline is narrowed to, from a route's details.
|
|
70
|
+
timelineRoute: undefined,
|
|
64
71
|
openCalls: new Set(),
|
|
72
|
+
// Signature of the slow-loader warnings last rendered.
|
|
73
|
+
slowSignature: "",
|
|
65
74
|
};
|
|
66
75
|
|
|
67
76
|
// ---------------------------------------------------------------------------
|
|
@@ -267,6 +276,8 @@ function renderNode(node, key, query) {
|
|
|
267
276
|
"span",
|
|
268
277
|
{ class: "row-files" },
|
|
269
278
|
node.routeGroup ? el("span", { class: "chip chip-group" }, "group") : undefined,
|
|
279
|
+
warningChip(key),
|
|
280
|
+
rowOpenButton(node, key),
|
|
270
281
|
fileKinds.map(([kind, label, short]) =>
|
|
271
282
|
el("span", { class: `chip chip-${kind}`, title: `${label}: ${node.files[kind]}` }, short),
|
|
272
283
|
),
|
|
@@ -372,6 +383,132 @@ function renderConflicts() {
|
|
|
372
383
|
);
|
|
373
384
|
}
|
|
374
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
|
+
|
|
375
512
|
function fact(label, ...content) {
|
|
376
513
|
return el("div", {}, el("dt", {}, label), el("dd", {}, ...content));
|
|
377
514
|
}
|
|
@@ -401,6 +538,13 @@ function renderDetails() {
|
|
|
401
538
|
{ class: "details-path" },
|
|
402
539
|
node.routeGroup ? `${node.fullPath} · ${groupName(node)}` : node.fullPath,
|
|
403
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,
|
|
404
548
|
el(
|
|
405
549
|
"dl",
|
|
406
550
|
{ class: "facts" },
|
|
@@ -439,7 +583,9 @@ function renderDetails() {
|
|
|
439
583
|
),
|
|
440
584
|
)
|
|
441
585
|
: undefined,
|
|
586
|
+
node.files.page ? fact("Open in app", openForm(node, params)) : undefined,
|
|
442
587
|
),
|
|
588
|
+
dataStats(node),
|
|
443
589
|
),
|
|
444
590
|
);
|
|
445
591
|
}
|
|
@@ -497,6 +643,7 @@ function renderMatch() {
|
|
|
497
643
|
? el("span", { class: "chip chip-notFound" }, "Not-found page")
|
|
498
644
|
: el("span", { class: "chip chip-loader" }, "Matched"),
|
|
499
645
|
el("span", { class: "result-pathname" }, result.pathname),
|
|
646
|
+
testedUrl() ? el("span", { class: "result-open" }, openButton(testedUrl())) : undefined,
|
|
500
647
|
),
|
|
501
648
|
el(
|
|
502
649
|
"div",
|
|
@@ -544,6 +691,7 @@ function renderMatch() {
|
|
|
544
691
|
result.page ? fact("Page", fileLink(result.page)) : undefined,
|
|
545
692
|
result.notFound ? fact("Not-found page", fileLink(result.notFound)) : undefined,
|
|
546
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,
|
|
547
695
|
result.loaders.length ? fact("Loaders", result.loaders.map(fileLink)) : undefined,
|
|
548
696
|
result.loading ? fact("Loading fallback", fileLink(result.loading)) : undefined,
|
|
549
697
|
result.error ? fact("Error boundary", fileLink(result.error)) : undefined,
|
|
@@ -551,6 +699,14 @@ function renderMatch() {
|
|
|
551
699
|
);
|
|
552
700
|
}
|
|
553
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
|
+
|
|
554
710
|
async function runMatch() {
|
|
555
711
|
const url = testerInput.value.trim();
|
|
556
712
|
writeStorage(TESTER_STORAGE_KEY, url);
|
|
@@ -670,6 +826,170 @@ function renderCurrent() {
|
|
|
670
826
|
);
|
|
671
827
|
}
|
|
672
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
|
+
|
|
673
993
|
// ---------------------------------------------------------------------------
|
|
674
994
|
// Loader and action timeline
|
|
675
995
|
|
|
@@ -719,6 +1039,8 @@ function outcomeChip(call) {
|
|
|
719
1039
|
return el("span", { class: "chip chip-loading" }, `${call.status} redirect`);
|
|
720
1040
|
case "error":
|
|
721
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()");
|
|
722
1044
|
case "response":
|
|
723
1045
|
return el("span", { class: "chip chip-layout" }, `${call.status} response`);
|
|
724
1046
|
default:
|
|
@@ -744,17 +1066,216 @@ function callText(call) {
|
|
|
744
1066
|
.toLowerCase();
|
|
745
1067
|
}
|
|
746
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
|
+
|
|
747
1270
|
function renderCall(call) {
|
|
748
1271
|
const found = nodeForRouteId(call.routeId);
|
|
749
|
-
const routeFile =
|
|
750
|
-
call.type === "action" ? found?.node.files.action : found?.node.files.loader;
|
|
751
1272
|
const details = el(
|
|
752
1273
|
"details",
|
|
753
1274
|
{ class: "call", open: state.openCalls.has(call.id) || undefined },
|
|
754
1275
|
el(
|
|
755
1276
|
"summary",
|
|
756
1277
|
{ class: "call-summary" },
|
|
757
|
-
el("span", { class: `chip chip-${
|
|
1278
|
+
el("span", { class: `chip chip-${{ action: "action", revalidate: "group", middleware: "middleware" }[call.type] ?? "page"}` }, call.type),
|
|
758
1279
|
el("span", { class: "call-route", title: call.routeId }, routeLabel(call.routeId)),
|
|
759
1280
|
call.type === "revalidate"
|
|
760
1281
|
? outcomeChip(call)
|
|
@@ -776,50 +1297,83 @@ function renderCall(call) {
|
|
|
776
1297
|
el("time", { datetime: new Date(call.startedAt).toISOString() }, formatTime(call.startedAt)),
|
|
777
1298
|
),
|
|
778
1299
|
),
|
|
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
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();
|
|
811
1306
|
details.addEventListener("toggle", () => {
|
|
812
1307
|
if (details.open) state.openCalls.add(call.id);
|
|
813
1308
|
else state.openCalls.delete(call.id);
|
|
1309
|
+
build();
|
|
814
1310
|
});
|
|
815
1311
|
return el("li", {}, details);
|
|
816
1312
|
}
|
|
817
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
|
+
|
|
818
1371
|
function visibleCalls() {
|
|
819
1372
|
const query = timelineFilter.value.trim().toLowerCase();
|
|
820
1373
|
return state.activity
|
|
821
1374
|
.filter((call) => call.id > state.clearedThrough)
|
|
822
1375
|
.filter((call) => state.timelineType === "all" || call.type === state.timelineType)
|
|
1376
|
+
.filter((call) => !state.timelineRoute || call.routeId === state.timelineRoute)
|
|
823
1377
|
.filter((call) => !query || callText(call).includes(query))
|
|
824
1378
|
.reverse();
|
|
825
1379
|
}
|
|
@@ -828,6 +1382,25 @@ function renderTimeline() {
|
|
|
828
1382
|
for (const button of timelineTypes.querySelectorAll("button")) {
|
|
829
1383
|
button.setAttribute("aria-pressed", String(button.dataset.type === state.timelineType));
|
|
830
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
|
+
}
|
|
831
1404
|
const hasCalls = state.activity.some((call) => call.id > state.clearedThrough);
|
|
832
1405
|
timelineElement.dataset.empty = hasCalls
|
|
833
1406
|
? "No calls match this filter."
|
|
@@ -844,6 +1417,11 @@ function addActivity(calls) {
|
|
|
844
1417
|
}
|
|
845
1418
|
if (timelineView.hidden) state.unseen += calls.length;
|
|
846
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();
|
|
847
1425
|
}
|
|
848
1426
|
|
|
849
1427
|
function showView(name) {
|
|
@@ -867,6 +1445,8 @@ function applyData(data) {
|
|
|
867
1445
|
if (!state.nodes.has(state.selected)) state.selected = "0";
|
|
868
1446
|
renderStats();
|
|
869
1447
|
renderConflicts();
|
|
1448
|
+
state.slowSignature = "";
|
|
1449
|
+
renderWarnings();
|
|
870
1450
|
updateCurrentKeys();
|
|
871
1451
|
renderTree();
|
|
872
1452
|
renderDetails();
|
|
@@ -906,6 +1486,9 @@ function connect() {
|
|
|
906
1486
|
updateCurrentKeys();
|
|
907
1487
|
renderCurrent();
|
|
908
1488
|
renderTree();
|
|
1489
|
+
// "Open in" buttons name the followed tab.
|
|
1490
|
+
if (!detailsElement.contains(document.activeElement)) renderDetails();
|
|
1491
|
+
renderMatch();
|
|
909
1492
|
});
|
|
910
1493
|
events.addEventListener("activity-snapshot", (event) => {
|
|
911
1494
|
// Sent on every (re)connect; keep only calls this panel hasn't seen.
|
|
@@ -936,6 +1519,8 @@ timelineClear.addEventListener("click", () => {
|
|
|
936
1519
|
state.clearedThrough = state.activity.at(-1)?.id ?? state.clearedThrough;
|
|
937
1520
|
state.openCalls.clear();
|
|
938
1521
|
renderTimeline();
|
|
1522
|
+
refreshSlowWarnings();
|
|
1523
|
+
renderDetails();
|
|
939
1524
|
});
|
|
940
1525
|
refreshElement.addEventListener("click", refresh);
|
|
941
1526
|
toggleElement.addEventListener("click", () => {
|