@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/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 inspectTab = $("#tab-inspect");
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-${call.type === "action" ? "action" : call.type === "revalidate" ? "group" : "page"}` }, call.type),
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 (timelineView.hidden) state.unseen += calls.length;
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
- inspectTab.addEventListener("click", () => showView("inspect"));
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
- renderTimeline();
1741
+ applyPage();
960
1742
  refresh();
961
1743
  connect();