@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/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-${call.type === "action" ? "action" : call.type === "revalidate" ? "group" : "page"}` }, call.type),
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", () => {