@next-rsc-debug/devtools 0.1.9 → 0.2.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.
Files changed (34) hide show
  1. package/README.md +94 -3
  2. package/dist/components/DevTools.d.ts.map +1 -1
  3. package/dist/components/DevTools.js +38 -4
  4. package/dist/components/FetchInspector.d.ts +11 -1
  5. package/dist/components/FetchInspector.d.ts.map +1 -1
  6. package/dist/components/FetchInspector.js +10 -4
  7. package/dist/components/FilterControls.d.ts +17 -0
  8. package/dist/components/FilterControls.d.ts.map +1 -0
  9. package/dist/components/FilterControls.js +14 -0
  10. package/dist/components/PanelBoundary.d.ts +35 -0
  11. package/dist/components/PanelBoundary.d.ts.map +1 -0
  12. package/dist/components/PanelBoundary.js +56 -0
  13. package/dist/components/Summary.d.ts +15 -2
  14. package/dist/components/Summary.d.ts.map +1 -1
  15. package/dist/components/Summary.js +9 -6
  16. package/dist/components/Timeline.d.ts +14 -1
  17. package/dist/components/Timeline.d.ts.map +1 -1
  18. package/dist/components/Timeline.js +15 -7
  19. package/dist/components/Warnings.d.ts +12 -2
  20. package/dist/components/Warnings.d.ts.map +1 -1
  21. package/dist/components/Warnings.js +6 -5
  22. package/dist/hooks/useSse.d.ts +10 -1
  23. package/dist/hooks/useSse.d.ts.map +1 -1
  24. package/dist/hooks/useSse.js +63 -9
  25. package/dist/index.d.ts +15 -1
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +15 -2
  28. package/dist/lib/filterOptions.d.ts +17 -0
  29. package/dist/lib/filterOptions.d.ts.map +1 -0
  30. package/dist/{components/Filters.js → lib/filterOptions.js} +8 -5
  31. package/dist/styles.css +68 -0
  32. package/package.json +7 -3
  33. package/dist/components/Filters.d.ts +0 -19
  34. package/dist/components/Filters.d.ts.map +0 -1
package/README.md CHANGED
@@ -2,6 +2,11 @@
2
2
 
3
3
  React-based DevTools UI for Next RSC Debug.
4
4
 
5
+ The dashboard is a normal React page mounted inside your application, not a
6
+ browser extension. There is nothing to install, no permissions to grant, and no
7
+ dependency on a particular browser; events reach it over an SSE route handler
8
+ that you mount yourself.
9
+
5
10
  ## Features
6
11
 
7
12
  - Event timeline
@@ -21,9 +26,10 @@ pnpm add @next-rsc-debug/devtools
21
26
 
22
27
  ## Usage
23
28
 
24
- The package ships `"use client"` directives, so you can import it directly into
25
- an App Router Server Component page. Do **not** wrap it in `next/dynamic` with
26
- `ssr: false` — that is not permitted inside a Server Component on Next.js 16.
29
+ `DevTools` is a Client Component (it opens an `EventSource`), so you can render
30
+ it directly from an App Router Server Component page. Do **not** wrap it in
31
+ `next/dynamic` with `ssr: false` — that is not permitted inside a Server
32
+ Component on Next.js 16.
27
33
 
28
34
  ```tsx
29
35
  // app/rsc-debug/page.tsx
@@ -47,6 +53,91 @@ export { GET } from "next-rsc-debug/route";
47
53
  > and they are not routable, so the page would return 404. Use a path such as
48
54
  > `/rsc-debug` instead.
49
55
 
56
+ ## Client boundaries
57
+
58
+ The package root has **no** `"use client"` directive, so the import path above
59
+ works identically from a Server or a Client Component. The boundary is per
60
+ module:
61
+
62
+ | Export | Boundary | Why |
63
+ | -------------------------------- | ----------------- | --------------------------------- |
64
+ | `DevTools` | `"use client"` | Owns state and the SSE connection |
65
+ | `FilterControls` | `"use client"` | Button `onClick` handlers |
66
+ | `useSse` | `"use client"` | Opens an `EventSource` |
67
+ | `PanelBoundary` | `"use client"` | Class error boundary + Suspense |
68
+ | `Summary` | server-renderable | Reads serializable props only |
69
+ | `Timeline` | server-renderable | `onSelect` is optional |
70
+ | `Warnings` | server-renderable | Reads serializable props only |
71
+ | `FetchInspector` | server-renderable | Renders one event |
72
+ | `FILTER_OPTIONS`, `filterEvents` | plain values | Data and a pure function |
73
+
74
+ Because the barrel is not itself a client module, `FILTER_OPTIONS` and
75
+ `filterEvents` are real values rather than client references, so a Server
76
+ Component can read and call them directly.
77
+
78
+ ### Fault isolation
79
+
80
+ Every panel in `DevTools` is wrapped in a `PanelBoundary`, which composes an
81
+ error boundary with a Suspense boundary:
82
+
83
+ - a panel that throws renders an inline error in place, and **sibling panels
84
+ keep working** — a single malformed event cannot white-screen the dashboard
85
+ - a panel that suspends (a future `use()`, or lazy-loaded panel code) shows a
86
+ skeleton instead of blocking the dashboard
87
+
88
+ Because error boundaries do not reset on their own, `PanelBoundary` takes a
89
+ `resetKey`; changing it clears the caught error and re-renders. `DevTools`
90
+ derives each key from the inputs that panel depends on, so a panel that failed
91
+ once recovers as soon as the offending data changes.
92
+
93
+ `PanelBoundary` is exported, so you can use it when composing your own
94
+ dashboard from the presentational components:
95
+
96
+ ```tsx
97
+ <PanelBoundary label="Timeline" resetKey={`${events.length}`}>
98
+ <Timeline events={events} />
99
+ </PanelBoundary>
100
+ ```
101
+
102
+ ### Rendering panels without the live stream
103
+
104
+ The presentational components can be rendered server-side with events you
105
+ already have, which avoids shipping the SSE client entirely:
106
+
107
+ ```tsx
108
+ import { Summary, Warnings } from "@next-rsc-debug/devtools";
109
+ import { analyzeEvents, getCollector } from "@next-rsc-debug/core";
110
+
111
+ export default function ReportPage() {
112
+ const events = getCollector().list();
113
+ const { warnings } = analyzeEvents(events, { slowThreshold: 500 });
114
+
115
+ return (
116
+ <>
117
+ <Summary events={events} threshold={500} warnings={warnings} />
118
+ <Warnings events={events} threshold={500} warnings={warnings} />
119
+ </>
120
+ );
121
+ }
122
+ ```
123
+
124
+ Pass `warnings` to both to run the analysis once instead of once per panel;
125
+ omit it and each component computes its own.
126
+
127
+ ### Bounded history
128
+
129
+ Two independent caps keep the dashboard responsive on long-running sessions:
130
+
131
+ ```tsx
132
+ // Client-side buffer: defaults to core's DEFAULT_MAX_EVENTS (5000)
133
+ const { events } = useSse("/api/debug-events", { maxEvents: 2000 });
134
+
135
+ // Rendered rows: defaults to 200, showing the most recent
136
+ <Timeline events={events} maxItems={100} />;
137
+ ```
138
+
139
+ When the timeline truncates it says so, rather than silently showing a subset.
140
+
50
141
  ## License
51
142
 
52
143
  MIT
@@ -1 +1 @@
1
- {"version":3,"file":"DevTools.d.ts","sourceRoot":"","sources":["../../src/components/DevTools.tsx"],"names":[],"mappings":"AAcA,MAAM,WAAW,aAAa;IAC5B,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED,wBAAgB,QAAQ,CAAC,EACvB,GAAgC,EAChC,gBAAsB,GACvB,EAAE,aAAa,+BA0Df;AAED,eAAe,QAAQ,CAAC"}
1
+ {"version":3,"file":"DevTools.d.ts","sourceRoot":"","sources":["../../src/components/DevTools.tsx"],"names":[],"mappings":"AAiBA,MAAM,WAAW,aAAa;IAC5B,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED,wBAAgB,QAAQ,CAAC,EACvB,GAAgC,EAChC,gBAAsB,GACvB,EAAE,aAAa,+BA0Gf;AAED,eAAe,QAAQ,CAAC"}
@@ -4,19 +4,53 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
4
4
  * Main DevTools dashboard.
5
5
  */
6
6
  import { useState, useMemo } from "react";
7
+ import { analyzeEvents } from "@next-rsc-debug/core";
7
8
  import { useSse } from "../hooks/useSse.js";
8
9
  import { Summary } from "./Summary.js";
9
10
  import { Timeline } from "./Timeline.js";
10
11
  import { FetchInspector } from "./FetchInspector.js";
11
12
  import { Warnings } from "./Warnings.js";
12
- import { FilterControls, filterEvents } from "./Filters.js";
13
+ import { FilterControls } from "./FilterControls.js";
14
+ import { PanelBoundary } from "./PanelBoundary.js";
15
+ import { filterEvents } from "../lib/filterOptions.js";
13
16
  export function DevTools({ url = "/__next-rsc-debug/events", defaultThreshold = 500, }) {
14
17
  const { connected, events, error } = useSse(url);
15
18
  const [filter, setFilter] = useState("all");
16
19
  const [threshold, setThreshold] = useState(defaultThreshold);
17
20
  const [selectedId, setSelectedId] = useState(undefined);
18
- const filtered = useMemo(() => filterEvents(events, filter, threshold), [events, filter, threshold]);
19
- const selectedEvent = useMemo(() => filtered.find((e) => e.id === selectedId) ?? null, [filtered, selectedId]);
20
- return (_jsxs("div", { className: "nrpd-dashboard", children: [_jsxs("header", { className: "nrpd-header", children: [_jsx("h1", { children: "Next RSC Debug" }), _jsx("span", { className: `nrpd-connection ${connected ? "connected" : "disconnected"}`, children: connected ? "● Connected" : "○ Disconnected" }), error && _jsx("span", { className: "nrpd-error", children: error })] }), _jsx(Summary, { events: events, threshold: threshold }), _jsx(FilterControls, { activeFilter: filter, onFilterChange: setFilter, threshold: threshold, onThresholdChange: setThreshold }), _jsxs("div", { className: "nrpd-main", children: [_jsxs("div", { className: "nrpd-panel nrpd-panel-timeline", children: [_jsx("h2", { children: "Timeline" }), _jsx(Timeline, { events: filtered, selectedId: selectedId, onSelect: (event) => setSelectedId(event.id) })] }), _jsxs("div", { className: "nrpd-panel nrpd-panel-inspector", children: [_jsx("h2", { children: "Event details" }), _jsx(FetchInspector, { event: selectedEvent })] })] }), _jsx("div", { className: "nrpd-panel nrpd-panel-warnings", children: _jsx(Warnings, { events: events, threshold: threshold }) })] }));
21
+ // Analyzed once here and shared with both panels. `Summary` and `Warnings`
22
+ // would otherwise each run the same O(n) pass with identical arguments on
23
+ // every render.
24
+ //
25
+ // These three derivations run in the orchestrator's body, which sits *above*
26
+ // every panel boundary — an exception here would take down the whole
27
+ // dashboard regardless of the boundaries below. They read untrusted event
28
+ // payloads, so they degrade instead of throwing: a bad event costs you that
29
+ // panel's detail, not the whole UI.
30
+ const warnings = useMemo(() => {
31
+ try {
32
+ return analyzeEvents(events, { slowThreshold: threshold }).warnings;
33
+ }
34
+ catch {
35
+ return [];
36
+ }
37
+ }, [events, threshold]);
38
+ const filtered = useMemo(() => {
39
+ try {
40
+ return filterEvents(events, filter, threshold);
41
+ }
42
+ catch {
43
+ return events;
44
+ }
45
+ }, [events, filter, threshold]);
46
+ const selectedEvent = useMemo(() => {
47
+ try {
48
+ return filtered.find((e) => e?.id === selectedId) ?? null;
49
+ }
50
+ catch {
51
+ return null;
52
+ }
53
+ }, [filtered, selectedId]);
54
+ return (_jsxs("div", { className: "nrpd-dashboard", children: [_jsxs("header", { className: "nrpd-header", children: [_jsx("h1", { children: "Next RSC Debug" }), _jsx("span", { className: `nrpd-connection ${connected ? "connected" : "disconnected"}`, children: connected ? "● Connected" : "○ Disconnected" }), error && _jsx("span", { className: "nrpd-error", children: error })] }), _jsx(PanelBoundary, { label: "Summary", resetKey: `summary:${events.length}:${threshold}`, children: _jsx(Summary, { events: events, threshold: threshold, warnings: warnings }) }), _jsx(PanelBoundary, { label: "Filters", resetKey: `filters:${filter}:${threshold}`, children: _jsx(FilterControls, { activeFilter: filter, onFilterChange: setFilter, threshold: threshold, onThresholdChange: setThreshold }) }), _jsxs("div", { className: "nrpd-main", children: [_jsxs("div", { className: "nrpd-panel nrpd-panel-timeline", children: [_jsx("h2", { children: "Timeline" }), _jsx(PanelBoundary, { label: "Timeline", resetKey: `timeline:${filtered.length}:${selectedId ?? ""}`, children: _jsx(Timeline, { events: filtered, selectedId: selectedId, onSelect: (event) => setSelectedId(event.id) }) })] }), _jsxs("div", { className: "nrpd-panel nrpd-panel-inspector", children: [_jsx("h2", { children: "Event details" }), _jsx(PanelBoundary, { label: "Event details", resetKey: `inspector:${selectedEvent?.id ?? "none"}`, children: _jsx(FetchInspector, { event: selectedEvent }) })] })] }), _jsx("div", { className: "nrpd-panel nrpd-panel-warnings", children: _jsx(PanelBoundary, { label: "Warnings", resetKey: `warnings:${events.length}:${threshold}`, children: _jsx(Warnings, { events: events, threshold: threshold, warnings: warnings }) }) })] }));
21
55
  }
22
56
  export default DevTools;
@@ -1,6 +1,16 @@
1
+ /**
2
+ * Fetch inspector component.
3
+ *
4
+ * No `"use client"` directive: it renders from a single serializable `event`.
5
+ */
1
6
  import type { DebugEvent } from "@next-rsc-debug/core";
2
7
  export interface FetchInspectorProps {
3
8
  event: DebugEvent | null;
4
9
  }
5
- export declare function FetchInspector({ event }: FetchInspectorProps): import("react").JSX.Element;
10
+ /**
11
+ * Memoized: the inspector renders from a single `event`, which only changes
12
+ * when the selection changes. Without `memo` it re-rendered on every streamed
13
+ * SSE event because the parent re-renders on each flush.
14
+ */
15
+ export declare const FetchInspector: import("react").MemoExoticComponent<({ event, }: FetchInspectorProps) => import("react").JSX.Element>;
6
16
  //# sourceMappingURL=FetchInspector.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"FetchInspector.d.ts","sourceRoot":"","sources":["../../src/components/FetchInspector.tsx"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,sBAAsB,CAAC;AAEvD,MAAM,WAAW,mBAAmB;IAClC,KAAK,EAAE,UAAU,GAAG,IAAI,CAAC;CAC1B;AAOD,wBAAgB,cAAc,CAAC,EAAE,KAAK,EAAE,EAAE,mBAAmB,+BAkE5D"}
1
+ {"version":3,"file":"FetchInspector.d.ts","sourceRoot":"","sources":["../../src/components/FetchInspector.tsx"],"names":[],"mappings":"AAAA;;;;GAIG;AAGH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,sBAAsB,CAAC;AAEvD,MAAM,WAAW,mBAAmB;IAClC,KAAK,EAAE,UAAU,GAAG,IAAI,CAAC;CAC1B;AAOD;;;;GAIG;AACH,eAAO,MAAM,cAAc,mDAExB,mBAAmB,iCAkEpB,CAAC"}
@@ -1,10 +1,16 @@
1
- "use client";
2
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
2
  /**
4
3
  * Fetch inspector component.
4
+ *
5
+ * No `"use client"` directive: it renders from a single serializable `event`.
5
6
  */
6
- import { useMemo } from "react";
7
- export function FetchInspector({ event }) {
7
+ import { memo, useMemo } from "react";
8
+ /**
9
+ * Memoized: the inspector renders from a single `event`, which only changes
10
+ * when the selection changes. Without `memo` it re-rendered on every streamed
11
+ * SSE event because the parent re-renders on each flush.
12
+ */
13
+ export const FetchInspector = memo(function FetchInspector({ event, }) {
8
14
  const metadata = (event?.metadata ?? {});
9
15
  const rows = useMemo(() => {
10
16
  if (!event)
@@ -39,4 +45,4 @@ export function FetchInspector({ event }) {
39
45
  ? metadata.warnings
40
46
  : [];
41
47
  return (_jsxs("div", { className: "nrpd-inspector", children: [_jsx("table", { className: "nrpd-inspector-table", children: _jsx("tbody", { children: rows.map((row) => (_jsxs("tr", { children: [_jsx("td", { className: "nrpd-inspector-label", children: row.label }), _jsx("td", { className: "nrpd-inspector-value", children: row.value })] }, row.label))) }) }), warnings.length > 0 && (_jsxs("div", { className: "nrpd-warnings", children: [_jsx("h4", { children: "Warnings" }), _jsx("ul", { children: warnings.map((w, i) => (_jsx("li", { children: typeof w === "string" ? w : (w.message ?? "") }, i))) })] }))] }));
42
- }
48
+ });
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Interactive filter controls for the DevTools dashboard.
3
+ *
4
+ * This is the only part of the former `Filters.tsx` that needs the client
5
+ * boundary — the buttons hold `onClick` handlers. The option data and the
6
+ * `filterEvents` predicate live in `../lib/filterOptions.ts`, which is
7
+ * server-safe.
8
+ */
9
+ import { type FilterType } from "../lib/filterOptions.js";
10
+ export interface FilterControlsProps {
11
+ activeFilter: FilterType;
12
+ onFilterChange: (filter: FilterType) => void;
13
+ threshold: number;
14
+ onThresholdChange: (threshold: number) => void;
15
+ }
16
+ export declare function FilterControls({ activeFilter, onFilterChange, threshold, onThresholdChange, }: FilterControlsProps): import("react").JSX.Element;
17
+ //# sourceMappingURL=FilterControls.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"FilterControls.d.ts","sourceRoot":"","sources":["../../src/components/FilterControls.tsx"],"names":[],"mappings":"AAEA;;;;;;;GAOG;AAEH,OAAO,EAAkB,KAAK,UAAU,EAAE,MAAM,yBAAyB,CAAC;AAE1E,MAAM,WAAW,mBAAmB;IAClC,YAAY,EAAE,UAAU,CAAC;IACzB,cAAc,EAAE,CAAC,MAAM,EAAE,UAAU,KAAK,IAAI,CAAC;IAC7C,SAAS,EAAE,MAAM,CAAC;IAClB,iBAAiB,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,IAAI,CAAC;CAChD;AAED,wBAAgB,cAAc,CAAC,EAC7B,YAAY,EACZ,cAAc,EACd,SAAS,EACT,iBAAiB,GAClB,EAAE,mBAAmB,+BA+BrB"}
@@ -0,0 +1,14 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ /**
4
+ * Interactive filter controls for the DevTools dashboard.
5
+ *
6
+ * This is the only part of the former `Filters.tsx` that needs the client
7
+ * boundary — the buttons hold `onClick` handlers. The option data and the
8
+ * `filterEvents` predicate live in `../lib/filterOptions.ts`, which is
9
+ * server-safe.
10
+ */
11
+ import { FILTER_OPTIONS } from "../lib/filterOptions.js";
12
+ export function FilterControls({ activeFilter, onFilterChange, threshold, onThresholdChange, }) {
13
+ return (_jsxs("div", { className: "nrpd-filters", children: [_jsx("div", { className: "nrpd-filter-group", children: FILTER_OPTIONS.map((opt) => (_jsx("button", { className: `nrpd-filter-btn ${activeFilter === opt.id ? "active" : ""}`, onClick: () => onFilterChange(opt.id), children: opt.label }, opt.id))) }), _jsx("div", { className: "nrpd-threshold", children: _jsxs("label", { children: ["Slow threshold:", _jsx("input", { type: "number", value: threshold, onChange: (e) => onThresholdChange(Number.parseInt(e.target.value, 10) || 500), min: 0, step: 50 }), _jsx("span", { children: "ms" })] }) })] }));
14
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Panel-level fault isolation.
3
+ *
4
+ * A devtools panel is fed by whatever the application under observation
5
+ * happens to emit, so a single malformed event must not white-screen the whole
6
+ * dashboard. Each panel is wrapped in its own error boundary plus a Suspense
7
+ * boundary:
8
+ *
9
+ * - a render error is caught and shown inline, leaving sibling panels alive
10
+ * - a suspended child (a future `use()`, or lazy-loaded panel code) shows a
11
+ * skeleton instead of blocking the rest of the dashboard
12
+ *
13
+ * Error boundaries must be class components; React provides no hook equivalent.
14
+ */
15
+ import { type ErrorInfo, type ReactNode } from "react";
16
+ export interface PanelBoundaryProps {
17
+ children: ReactNode;
18
+ /** Human-readable panel name, shown in the error and skeleton states. */
19
+ label: string;
20
+ /**
21
+ * Changing this value clears a caught error and re-renders the children.
22
+ * Error boundaries do not reset on their own, so without this a panel that
23
+ * failed once would stay broken even after the offending data changed. Pass
24
+ * whatever inputs the panel depends on.
25
+ */
26
+ resetKey?: string;
27
+ /** Escape hatch for logging/reporting; the error is always rendered inline. */
28
+ onError?: (error: Error, info: ErrorInfo) => void;
29
+ }
30
+ /**
31
+ * Composes the error boundary *outside* the Suspense boundary so a suspended
32
+ * child shows the skeleton while a thrown error shows the error state.
33
+ */
34
+ export declare function PanelBoundary({ children, label, resetKey, onError, }: PanelBoundaryProps): import("react").JSX.Element;
35
+ //# sourceMappingURL=PanelBoundary.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"PanelBoundary.d.ts","sourceRoot":"","sources":["../../src/components/PanelBoundary.tsx"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;GAaG;AAEH,OAAO,EAAuB,KAAK,SAAS,EAAE,KAAK,SAAS,EAAE,MAAM,OAAO,CAAC;AAE5E,MAAM,WAAW,kBAAkB;IACjC,QAAQ,EAAE,SAAS,CAAC;IACpB,yEAAyE;IACzE,KAAK,EAAE,MAAM,CAAC;IACd;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,+EAA+E;IAC/E,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,KAAK,EAAE,IAAI,EAAE,SAAS,KAAK,IAAI,CAAC;CACnD;AAuED;;;GAGG;AACH,wBAAgB,aAAa,CAAC,EAC5B,QAAQ,EACR,KAAK,EACL,QAAQ,EACR,OAAO,GACR,EAAE,kBAAkB,+BAMpB"}
@@ -0,0 +1,56 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ /**
4
+ * Panel-level fault isolation.
5
+ *
6
+ * A devtools panel is fed by whatever the application under observation
7
+ * happens to emit, so a single malformed event must not white-screen the whole
8
+ * dashboard. Each panel is wrapped in its own error boundary plus a Suspense
9
+ * boundary:
10
+ *
11
+ * - a render error is caught and shown inline, leaving sibling panels alive
12
+ * - a suspended child (a future `use()`, or lazy-loaded panel code) shows a
13
+ * skeleton instead of blocking the rest of the dashboard
14
+ *
15
+ * Error boundaries must be class components; React provides no hook equivalent.
16
+ */
17
+ import { Component, Suspense } from "react";
18
+ class PanelErrorBoundary extends Component {
19
+ state = { error: null, resetKey: undefined };
20
+ /**
21
+ * Where the caught error is actually stored. This runs ahead of the re-render
22
+ * that replaces the children, which is why it is preferred over setting state
23
+ * inside `componentDidCatch`.
24
+ */
25
+ static getDerivedStateFromError(error) {
26
+ return { error };
27
+ }
28
+ static getDerivedStateFromProps(props, state) {
29
+ if (props.resetKey !== state.resetKey) {
30
+ return { error: null, resetKey: props.resetKey };
31
+ }
32
+ return null;
33
+ }
34
+ componentDidCatch(error, info) {
35
+ this.props.onError?.(error, info);
36
+ }
37
+ render() {
38
+ if (this.state.error) {
39
+ return (_jsx(PanelErrorState, { label: this.props.label, error: this.state.error }));
40
+ }
41
+ return this.props.children;
42
+ }
43
+ }
44
+ function PanelErrorState({ label, error }) {
45
+ return (_jsxs("div", { className: "nrpd-panel-error", role: "alert", children: [_jsxs("strong", { children: [label, " failed to render."] }), _jsx("span", { className: "nrpd-panel-error-message", children: error.message })] }));
46
+ }
47
+ function PanelSkeleton({ label }) {
48
+ return (_jsxs("div", { className: "nrpd-skeleton", "aria-busy": "true", "aria-label": `Loading ${label}`, children: [_jsx("div", { className: "nrpd-skeleton-bar" }), _jsx("div", { className: "nrpd-skeleton-bar" }), _jsx("div", { className: "nrpd-skeleton-bar" })] }));
49
+ }
50
+ /**
51
+ * Composes the error boundary *outside* the Suspense boundary so a suspended
52
+ * child shows the skeleton while a thrown error shows the error state.
53
+ */
54
+ export function PanelBoundary({ children, label, resetKey, onError, }) {
55
+ return (_jsx(PanelErrorBoundary, { label: label, resetKey: resetKey, onError: onError, children: _jsx(Suspense, { fallback: _jsx(PanelSkeleton, { label: label }), children: children }) }));
56
+ }
@@ -1,7 +1,20 @@
1
- import type { DebugEvent } from "@next-rsc-debug/core";
1
+ /**
2
+ * Summary cards for the DevTools dashboard.
3
+ *
4
+ * No `"use client"` directive: this component only reads serializable props
5
+ * and uses `useMemo` as a render-time optimization, so it can be rendered by a
6
+ * Server Component. `DevTools` (a client module) imports it just fine.
7
+ */
8
+ import type { DebugEvent, Warning } from "@next-rsc-debug/core";
2
9
  export interface SummaryProps {
3
10
  events: DebugEvent[];
4
11
  threshold: number;
12
+ /**
13
+ * Pre-computed warnings from `analyzeEvents`. Supply this when several
14
+ * panels share one analysis so the (O(n), URL-normalizing) pass is not
15
+ * repeated. When omitted the component computes its own.
16
+ */
17
+ warnings?: Warning[];
5
18
  }
6
- export declare function Summary({ events, threshold }: SummaryProps): import("react").JSX.Element;
19
+ export declare function Summary({ events, threshold, warnings }: SummaryProps): import("react").JSX.Element;
7
20
  //# sourceMappingURL=Summary.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"Summary.d.ts","sourceRoot":"","sources":["../../src/components/Summary.tsx"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,sBAAsB,CAAC;AAGvD,MAAM,WAAW,YAAY;IAC3B,MAAM,EAAE,UAAU,EAAE,CAAC;IACrB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,wBAAgB,OAAO,CAAC,EAAE,MAAM,EAAE,SAAS,EAAE,EAAE,YAAY,+BAsC1D"}
1
+ {"version":3,"file":"Summary.d.ts","sourceRoot":"","sources":["../../src/components/Summary.tsx"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAGH,OAAO,KAAK,EAAE,UAAU,EAAE,OAAO,EAAE,MAAM,sBAAsB,CAAC;AAGhE,MAAM,WAAW,YAAY;IAC3B,MAAM,EAAE,UAAU,EAAE,CAAC;IACrB,SAAS,EAAE,MAAM,CAAC;IAClB;;;;OAIG;IACH,QAAQ,CAAC,EAAE,OAAO,EAAE,CAAC;CACtB;AAED,wBAAgB,OAAO,CAAC,EAAE,MAAM,EAAE,SAAS,EAAE,QAAQ,EAAE,EAAE,YAAY,+BA2CpE"}
@@ -1,19 +1,22 @@
1
- "use client";
2
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
2
  /**
4
3
  * Summary cards for the DevTools dashboard.
4
+ *
5
+ * No `"use client"` directive: this component only reads serializable props
6
+ * and uses `useMemo` as a render-time optimization, so it can be rendered by a
7
+ * Server Component. `DevTools` (a client module) imports it just fine.
5
8
  */
6
9
  import { useMemo } from "react";
7
10
  import { analyzeEvents } from "@next-rsc-debug/core";
8
- export function Summary({ events, threshold }) {
9
- const { warnings } = useMemo(() => analyzeEvents(events, { slowThreshold: threshold }), [events, threshold]);
11
+ export function Summary({ events, threshold, warnings }) {
12
+ const resolvedWarnings = useMemo(() => warnings ?? analyzeEvents(events, { slowThreshold: threshold }).warnings, [warnings, events, threshold]);
10
13
  const stats = useMemo(() => {
11
14
  const fetches = events.filter((e) => e.type === "fetch:start" || e.type === "fetch:end").length;
12
- const slow = warnings.filter((w) => w.type === "slow-request").length;
13
- const dupes = warnings.filter((w) => w.type === "duplicate-request").length;
15
+ const slow = resolvedWarnings.filter((w) => w.type === "slow-request").length;
16
+ const dupes = resolvedWarnings.filter((w) => w.type === "duplicate-request").length;
14
17
  const errors = events.filter((e) => e.type === "error").length;
15
18
  return { total: events.length, fetches, slow, dupes, errors };
16
- }, [events, warnings]);
19
+ }, [events, resolvedWarnings]);
17
20
  const cards = [
18
21
  { label: "Events", value: stats.total, color: "#60a5fa" },
19
22
  { label: "Fetches", value: stats.fetches, color: "#34d399" },
@@ -1,8 +1,21 @@
1
+ /**
2
+ * Event timeline component.
3
+ *
4
+ * No `"use client"` directive: it renders from serializable props. `onSelect`
5
+ * is optional, so a Server Component may render it read-only; `DevTools`
6
+ * supplies the handler from the client.
7
+ */
1
8
  import type { DebugEvent } from "@next-rsc-debug/core";
2
9
  export interface TimelineProps {
3
10
  events: DebugEvent[];
4
11
  selectedId?: string;
5
12
  onSelect?: (event: DebugEvent) => void;
13
+ /**
14
+ * Maximum rows rendered at once. The event array is bounded (see `useSse`),
15
+ * but the DOM is not: a few thousand rows is enough to make the panel
16
+ * unusable, so only the most recent are drawn and the rest are summarised.
17
+ */
18
+ maxItems?: number;
6
19
  }
7
- export declare function Timeline({ events, selectedId, onSelect }: TimelineProps): import("react").JSX.Element;
20
+ export declare function Timeline({ events, selectedId, onSelect, maxItems, }: TimelineProps): import("react").JSX.Element;
8
21
  //# sourceMappingURL=Timeline.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"Timeline.d.ts","sourceRoot":"","sources":["../../src/components/Timeline.tsx"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,sBAAsB,CAAC;AAUvD,MAAM,WAAW,aAAa;IAC5B,MAAM,EAAE,UAAU,EAAE,CAAC;IACrB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,UAAU,KAAK,IAAI,CAAC;CACxC;AA2CD,wBAAgB,QAAQ,CAAC,EAAE,MAAM,EAAE,UAAU,EAAE,QAAQ,EAAE,EAAE,aAAa,+BAyCvE"}
1
+ {"version":3,"file":"Timeline.d.ts","sourceRoot":"","sources":["../../src/components/Timeline.tsx"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAGH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,sBAAsB,CAAC;AAUvD,MAAM,WAAW,aAAa;IAC5B,MAAM,EAAE,UAAU,EAAE,CAAC;IACrB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,UAAU,KAAK,IAAI,CAAC;IACvC;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AA6CD,wBAAgB,QAAQ,CAAC,EACvB,MAAM,EACN,UAAU,EACV,QAAQ,EACR,QAA4B,GAC7B,EAAE,aAAa,+BAoDf"}
@@ -1,10 +1,14 @@
1
- "use client";
2
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
2
  /**
4
3
  * Event timeline component.
4
+ *
5
+ * No `"use client"` directive: it renders from serializable props. `onSelect`
6
+ * is optional, so a Server Component may render it read-only; `DevTools`
7
+ * supplies the handler from the client.
5
8
  */
6
9
  import { useMemo } from "react";
7
10
  import { isNavigationEventType, isRscEventType, isFetchEventType, isCacheEventType, isActionEventType, isErrorEventType, } from "@next-rsc-debug/core";
11
+ const DEFAULT_MAX_ITEMS = 200;
8
12
  function getEventIcon(type) {
9
13
  if (isNavigationEventType(type))
10
14
  return "🧭";
@@ -50,14 +54,18 @@ function getEventLabel(event) {
50
54
  return event.type;
51
55
  }
52
56
  }
53
- export function Timeline({ events, selectedId, onSelect }) {
57
+ export function Timeline({ events, selectedId, onSelect, maxItems = DEFAULT_MAX_ITEMS, }) {
54
58
  const sorted = useMemo(() => [...events].sort((a, b) => a.timestamp - b.timestamp), [events]);
55
59
  if (sorted.length === 0) {
56
60
  return (_jsx("div", { className: "nrpd-empty", children: _jsx("span", { children: "No events yet." }) }));
57
61
  }
58
- return (_jsx("div", { className: "nrpd-timeline", children: sorted.map((event) => {
59
- const isSelected = event.id === selectedId;
60
- const duration = event.duration !== undefined ? ` (${event.duration}ms)` : "";
61
- return (_jsxs("div", { className: `nrpd-timeline-item ${isSelected ? "selected" : ""}`, onClick: () => onSelect?.(event), children: [_jsx("span", { className: "nrpd-timeline-icon", children: getEventIcon(event.type) }), _jsxs("span", { className: "nrpd-timeline-label", children: [getEventLabel(event), duration] }), _jsx("span", { className: "nrpd-timestamp", children: new Date(event.timestamp).toLocaleTimeString() })] }, event.id));
62
- }) }));
62
+ // Keep the newest rows: this is a live tail, and the most recent activity is
63
+ // what a developer is looking at.
64
+ const visible = sorted.slice(-maxItems);
65
+ const hiddenCount = sorted.length - visible.length;
66
+ return (_jsxs("div", { className: "nrpd-timeline", children: [hiddenCount > 0 && (_jsxs("div", { className: "nrpd-timeline-truncated", children: ["Showing the ", visible.length, " most recent of ", sorted.length, " events (", hiddenCount, " older not rendered)."] })), visible.map((event) => {
67
+ const isSelected = event.id === selectedId;
68
+ const duration = event.duration !== undefined ? ` (${event.duration}ms)` : "";
69
+ return (_jsxs("div", { className: `nrpd-timeline-item ${isSelected ? "selected" : ""}`, onClick: () => onSelect?.(event), children: [_jsx("span", { className: "nrpd-timeline-icon", children: getEventIcon(event.type) }), _jsxs("span", { className: "nrpd-timeline-label", children: [getEventLabel(event), duration] }), _jsx("span", { className: "nrpd-timestamp", children: new Date(event.timestamp).toLocaleTimeString() })] }, event.id));
70
+ })] }));
63
71
  }
@@ -1,7 +1,17 @@
1
- import type { DebugEvent } from "@next-rsc-debug/core";
1
+ /**
2
+ * Warnings panel.
3
+ *
4
+ * No `"use client"` directive — see the note in `Summary.tsx`.
5
+ */
6
+ import type { DebugEvent, Warning } from "@next-rsc-debug/core";
2
7
  export interface WarningsProps {
3
8
  events: DebugEvent[];
4
9
  threshold?: number;
10
+ /**
11
+ * Pre-computed warnings from `analyzeEvents`. Supply this to share one
12
+ * analysis across panels; when omitted the component computes its own.
13
+ */
14
+ warnings?: Warning[];
5
15
  }
6
- export declare function Warnings({ events, threshold }: WarningsProps): import("react").JSX.Element;
16
+ export declare function Warnings({ events, threshold, warnings }: WarningsProps): import("react").JSX.Element;
7
17
  //# sourceMappingURL=Warnings.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"Warnings.d.ts","sourceRoot":"","sources":["../../src/components/Warnings.tsx"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,UAAU,EAAW,MAAM,sBAAsB,CAAC;AAGhE,MAAM,WAAW,aAAa;IAC5B,MAAM,EAAE,UAAU,EAAE,CAAC;IACrB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,wBAAgB,QAAQ,CAAC,EAAE,MAAM,EAAE,SAAS,EAAE,EAAE,aAAa,+BA8B5D"}
1
+ {"version":3,"file":"Warnings.d.ts","sourceRoot":"","sources":["../../src/components/Warnings.tsx"],"names":[],"mappings":"AAAA;;;;GAIG;AAGH,OAAO,KAAK,EAAE,UAAU,EAAE,OAAO,EAAE,MAAM,sBAAsB,CAAC;AAGhE,MAAM,WAAW,aAAa;IAC5B,MAAM,EAAE,UAAU,EAAE,CAAC;IACrB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;OAGG;IACH,QAAQ,CAAC,EAAE,OAAO,EAAE,CAAC;CACtB;AAED,wBAAgB,QAAQ,CAAC,EAAE,MAAM,EAAE,SAAS,EAAE,QAAQ,EAAE,EAAE,aAAa,+BA+BtE"}
@@ -1,14 +1,15 @@
1
- "use client";
2
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
2
  /**
4
3
  * Warnings panel.
4
+ *
5
+ * No `"use client"` directive — see the note in `Summary.tsx`.
5
6
  */
6
7
  import { useMemo } from "react";
7
8
  import { analyzeEvents } from "@next-rsc-debug/core";
8
- export function Warnings({ events, threshold }) {
9
- const { warnings } = useMemo(() => analyzeEvents(events, { slowThreshold: threshold }), [events, threshold]);
10
- if (warnings.length === 0) {
9
+ export function Warnings({ events, threshold, warnings }) {
10
+ const resolvedWarnings = useMemo(() => warnings ?? analyzeEvents(events, { slowThreshold: threshold }).warnings, [warnings, events, threshold]);
11
+ if (resolvedWarnings.length === 0) {
11
12
  return (_jsxs("div", { className: "nrpd-panel-body", children: [_jsx("h2", { children: "Warnings" }), _jsx("div", { className: "nrpd-empty", children: _jsx("span", { children: "No warnings detected." }) })] }));
12
13
  }
13
- return (_jsxs("div", { className: "nrpd-panel-body", children: [_jsxs("h2", { children: ["Warnings (", warnings.length, ")"] }), _jsx("ul", { className: "nrpd-warning-list", children: warnings.map((w) => (_jsxs("li", { className: `nrpd-warning nrpd-warning-${w.type}`, children: [_jsx("span", { className: "nrpd-warning-type", children: w.type }), _jsx("span", { className: "nrpd-warning-message", children: w.message })] }, w.id))) })] }));
14
+ return (_jsxs("div", { className: "nrpd-panel-body", children: [_jsxs("h2", { children: ["Warnings (", resolvedWarnings.length, ")"] }), _jsx("ul", { className: "nrpd-warning-list", children: resolvedWarnings.map((w) => (_jsxs("li", { className: `nrpd-warning nrpd-warning-${w.type}`, children: [_jsx("span", { className: "nrpd-warning-type", children: w.type }), _jsx("span", { className: "nrpd-warning-message", children: w.message })] }, w.id))) })] }));
14
15
  }
@@ -4,5 +4,14 @@ export interface SseState {
4
4
  events: DebugEvent[];
5
5
  error: string | null;
6
6
  }
7
- export declare function useSse(url: string): SseState;
7
+ export interface UseSseOptions {
8
+ /**
9
+ * Upper bound on events retained in the browser. The server already evicts
10
+ * beyond its own ring buffer, but the client would otherwise accumulate every
11
+ * streamed event for the lifetime of the page. Defaults to the same limit so
12
+ * a snapshot is never truncated on arrival.
13
+ */
14
+ maxEvents?: number;
15
+ }
16
+ export declare function useSse(url: string, options?: UseSseOptions): SseState;
8
17
  //# sourceMappingURL=useSse.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"useSse.d.ts","sourceRoot":"","sources":["../../src/hooks/useSse.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,sBAAsB,CAAC;AAEvD,MAAM,WAAW,QAAQ;IACvB,SAAS,EAAE,OAAO,CAAC;IACnB,MAAM,EAAE,UAAU,EAAE,CAAC;IACrB,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;CACtB;AAED,wBAAgB,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,QAAQ,CAiE5C"}
1
+ {"version":3,"file":"useSse.d.ts","sourceRoot":"","sources":["../../src/hooks/useSse.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,sBAAsB,CAAC;AAGvD,MAAM,WAAW,QAAQ;IACvB,SAAS,EAAE,OAAO,CAAC;IACnB,MAAM,EAAE,UAAU,EAAE,CAAC;IACrB,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;CACtB;AAED,MAAM,WAAW,aAAa;IAC5B;;;;;OAKG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,wBAAgB,MAAM,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,GAAE,aAAkB,GAAG,QAAQ,CAwHzE"}
@@ -2,13 +2,60 @@
2
2
  /**
3
3
  * SSE client hook for live event streaming.
4
4
  */
5
- import { useState, useEffect, useRef } from "react";
6
- export function useSse(url) {
5
+ import { useState, useEffect, useRef, useCallback } from "react";
6
+ import { DEFAULT_MAX_EVENTS } from "@next-rsc-debug/core";
7
+ export function useSse(url, options = {}) {
8
+ const maxEvents = options.maxEvents ?? DEFAULT_MAX_EVENTS;
7
9
  const [connected, setConnected] = useState(false);
8
10
  const [events, setEvents] = useState([]);
9
11
  const [error, setError] = useState(null);
10
- const esRef = useRef(null);
11
12
  const eventsRef = useRef([]);
13
+ const pendingRef = useRef([]);
14
+ const cancelRef = useRef(null);
15
+ // Keep the latest limit in a ref so changing it does not tear down and
16
+ // re-establish the EventSource connection.
17
+ const maxEventsRef = useRef(maxEvents);
18
+ maxEventsRef.current = maxEvents;
19
+ /**
20
+ * SSE delivers events one message at a time. Committing each one to state
21
+ * immediately gives every message a fresh array identity, which invalidates
22
+ * every downstream `useMemo` and re-renders the whole dashboard. Buffering
23
+ * pending events and flushing once per frame collapses a burst into a single
24
+ * render, keeping the analysis cost linear in the number of events rather
25
+ * than quadratic.
26
+ */
27
+ const flush = useCallback(() => {
28
+ cancelRef.current = null;
29
+ if (pendingRef.current.length === 0) {
30
+ return;
31
+ }
32
+ const merged = [...eventsRef.current, ...pendingRef.current];
33
+ // Drop the oldest rather than refusing new events: for a live tail, the
34
+ // most recent activity is the useful part.
35
+ eventsRef.current =
36
+ merged.length > maxEventsRef.current
37
+ ? merged.slice(merged.length - maxEventsRef.current)
38
+ : merged;
39
+ pendingRef.current = [];
40
+ setEvents(eventsRef.current);
41
+ }, []);
42
+ const scheduleFlush = useCallback(() => {
43
+ if (cancelRef.current !== null) {
44
+ return;
45
+ }
46
+ if (typeof requestAnimationFrame === "function") {
47
+ const handle = requestAnimationFrame(flush);
48
+ cancelRef.current = () => cancelAnimationFrame(handle);
49
+ }
50
+ else {
51
+ const handle = setTimeout(flush, 0);
52
+ cancelRef.current = () => clearTimeout(handle);
53
+ }
54
+ }, [flush]);
55
+ const cancelFlush = useCallback(() => {
56
+ cancelRef.current?.();
57
+ cancelRef.current = null;
58
+ }, []);
12
59
  useEffect(() => {
13
60
  if (typeof window === "undefined") {
14
61
  return;
@@ -21,7 +68,6 @@ export function useSse(url) {
21
68
  setError(err instanceof Error ? err.message : "Failed to connect");
22
69
  return;
23
70
  }
24
- esRef.current = es;
25
71
  es.onopen = () => {
26
72
  setConnected(true);
27
73
  setError(null);
@@ -34,8 +80,12 @@ export function useSse(url) {
34
80
  try {
35
81
  const data = JSON.parse(e.data);
36
82
  if (data.events) {
37
- eventsRef.current = data.events;
38
- setEvents(data.events);
83
+ // A snapshot is authoritative and replaces buffered history, so any
84
+ // queued events are dropped rather than appended out of order.
85
+ cancelFlush();
86
+ pendingRef.current = [];
87
+ eventsRef.current = data.events.slice(-maxEventsRef.current);
88
+ setEvents(eventsRef.current);
39
89
  }
40
90
  }
41
91
  catch {
@@ -45,8 +95,8 @@ export function useSse(url) {
45
95
  es.addEventListener("event", (e) => {
46
96
  try {
47
97
  const event = JSON.parse(e.data);
48
- eventsRef.current = [...eventsRef.current, event];
49
- setEvents(eventsRef.current);
98
+ pendingRef.current = [...pendingRef.current, event];
99
+ scheduleFlush();
50
100
  }
51
101
  catch {
52
102
  // Ignore parse errors.
@@ -57,8 +107,12 @@ export function useSse(url) {
57
107
  });
58
108
  return () => {
59
109
  es.close();
110
+ // Drain anything still buffered so a url change (or unmount) never
111
+ // silently drops events that already arrived.
112
+ cancelFlush();
113
+ flush();
60
114
  setConnected(false);
61
115
  };
62
- }, [url]);
116
+ }, [url, flush, scheduleFlush, cancelFlush]);
63
117
  return { connected, events, error };
64
118
  }
package/dist/index.d.ts CHANGED
@@ -1,11 +1,25 @@
1
1
  /**
2
2
  * Public API for @next-rsc-debug/devtools.
3
+ *
4
+ * This barrel deliberately carries NO `"use client"` directive. Previously it
5
+ * did, which meant every non-component export below (`FILTER_OPTIONS`,
6
+ * `filterEvents`) reached a Server Component as a *client reference* rather
7
+ * than a real value — `FILTER_OPTIONS.map(...)` would have thrown.
8
+ *
9
+ * The boundary now lives on the individual modules that need it:
10
+ * - `DevTools`, `FilterControls`, `useSse`, `PanelBoundary` → "use client"
11
+ * - `Summary`, `Timeline`, `Warnings`, `FetchInspector` → server-renderable
12
+ *
13
+ * Re-exporting a client component from this server-safe module is the normal
14
+ * pattern, so the single import path still works for both runtimes.
3
15
  */
4
16
  export { DevTools, type DevToolsProps } from "./components/DevTools.js";
5
17
  export { Timeline, type TimelineProps } from "./components/Timeline.js";
6
18
  export { FetchInspector, type FetchInspectorProps, } from "./components/FetchInspector.js";
7
19
  export { Warnings, type WarningsProps } from "./components/Warnings.js";
8
20
  export { Summary, type SummaryProps } from "./components/Summary.js";
9
- export { FilterControls, filterEvents, FILTER_OPTIONS, type FilterType, type FilterControlsProps, } from "./components/Filters.js";
21
+ export { FilterControls, type FilterControlsProps, } from "./components/FilterControls.js";
22
+ export { PanelBoundary, type PanelBoundaryProps, } from "./components/PanelBoundary.js";
23
+ export { filterEvents, FILTER_OPTIONS, type FilterType, type FilterOption, } from "./lib/filterOptions.js";
10
24
  export { useSse, type SseState } from "./hooks/useSse.js";
11
25
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA;;GAEG;AAEH,OAAO,EAAE,QAAQ,EAAE,KAAK,aAAa,EAAE,MAAM,0BAA0B,CAAC;AACxE,OAAO,EAAE,QAAQ,EAAE,KAAK,aAAa,EAAE,MAAM,0BAA0B,CAAC;AACxE,OAAO,EACL,cAAc,EACd,KAAK,mBAAmB,GACzB,MAAM,gCAAgC,CAAC;AACxC,OAAO,EAAE,QAAQ,EAAE,KAAK,aAAa,EAAE,MAAM,0BAA0B,CAAC;AACxE,OAAO,EAAE,OAAO,EAAE,KAAK,YAAY,EAAE,MAAM,yBAAyB,CAAC;AACrE,OAAO,EACL,cAAc,EACd,YAAY,EACZ,cAAc,EACd,KAAK,UAAU,EACf,KAAK,mBAAmB,GACzB,MAAM,yBAAyB,CAAC;AACjC,OAAO,EAAE,MAAM,EAAE,KAAK,QAAQ,EAAE,MAAM,mBAAmB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,EAAE,QAAQ,EAAE,KAAK,aAAa,EAAE,MAAM,0BAA0B,CAAC;AACxE,OAAO,EAAE,QAAQ,EAAE,KAAK,aAAa,EAAE,MAAM,0BAA0B,CAAC;AACxE,OAAO,EACL,cAAc,EACd,KAAK,mBAAmB,GACzB,MAAM,gCAAgC,CAAC;AACxC,OAAO,EAAE,QAAQ,EAAE,KAAK,aAAa,EAAE,MAAM,0BAA0B,CAAC;AACxE,OAAO,EAAE,OAAO,EAAE,KAAK,YAAY,EAAE,MAAM,yBAAyB,CAAC;AACrE,OAAO,EACL,cAAc,EACd,KAAK,mBAAmB,GACzB,MAAM,gCAAgC,CAAC;AACxC,OAAO,EACL,aAAa,EACb,KAAK,kBAAkB,GACxB,MAAM,+BAA+B,CAAC;AACvC,OAAO,EACL,YAAY,EACZ,cAAc,EACd,KAAK,UAAU,EACf,KAAK,YAAY,GAClB,MAAM,wBAAwB,CAAC;AAChC,OAAO,EAAE,MAAM,EAAE,KAAK,QAAQ,EAAE,MAAM,mBAAmB,CAAC"}
package/dist/index.js CHANGED
@@ -1,11 +1,24 @@
1
- "use client";
2
1
  /**
3
2
  * Public API for @next-rsc-debug/devtools.
3
+ *
4
+ * This barrel deliberately carries NO `"use client"` directive. Previously it
5
+ * did, which meant every non-component export below (`FILTER_OPTIONS`,
6
+ * `filterEvents`) reached a Server Component as a *client reference* rather
7
+ * than a real value — `FILTER_OPTIONS.map(...)` would have thrown.
8
+ *
9
+ * The boundary now lives on the individual modules that need it:
10
+ * - `DevTools`, `FilterControls`, `useSse`, `PanelBoundary` → "use client"
11
+ * - `Summary`, `Timeline`, `Warnings`, `FetchInspector` → server-renderable
12
+ *
13
+ * Re-exporting a client component from this server-safe module is the normal
14
+ * pattern, so the single import path still works for both runtimes.
4
15
  */
5
16
  export { DevTools } from "./components/DevTools.js";
6
17
  export { Timeline } from "./components/Timeline.js";
7
18
  export { FetchInspector, } from "./components/FetchInspector.js";
8
19
  export { Warnings } from "./components/Warnings.js";
9
20
  export { Summary } from "./components/Summary.js";
10
- export { FilterControls, filterEvents, FILTER_OPTIONS, } from "./components/Filters.js";
21
+ export { FilterControls, } from "./components/FilterControls.js";
22
+ export { PanelBoundary, } from "./components/PanelBoundary.js";
23
+ export { filterEvents, FILTER_OPTIONS, } from "./lib/filterOptions.js";
11
24
  export { useSse } from "./hooks/useSse.js";
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Filter definitions and the pure event-filtering predicate.
3
+ *
4
+ * Deliberately NOT a `"use client"` module: these are plain data and a pure
5
+ * function, so a Server Component can import and call them directly. Keeping
6
+ * them out of the client boundary is what lets `index.ts` re-export them as
7
+ * real values instead of client references.
8
+ */
9
+ import type { DebugEvent } from "@next-rsc-debug/core";
10
+ export type FilterType = "all" | "navigation" | "rsc" | "fetch" | "cache" | "action" | "error" | "slow" | "errors";
11
+ export interface FilterOption {
12
+ id: FilterType;
13
+ label: string;
14
+ }
15
+ export declare const FILTER_OPTIONS: FilterOption[];
16
+ export declare function filterEvents(events: DebugEvent[], filter: FilterType, threshold: number): DebugEvent[];
17
+ //# sourceMappingURL=filterOptions.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"filterOptions.d.ts","sourceRoot":"","sources":["../../src/lib/filterOptions.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,sBAAsB,CAAC;AAEvD,MAAM,MAAM,UAAU,GAClB,KAAK,GACL,YAAY,GACZ,KAAK,GACL,OAAO,GACP,OAAO,GACP,QAAQ,GACR,OAAO,GACP,MAAM,GACN,QAAQ,CAAC;AAEb,MAAM,WAAW,YAAY;IAC3B,EAAE,EAAE,UAAU,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;CACf;AAED,eAAO,MAAM,cAAc,EAAE,YAAY,EAUxC,CAAC;AAEF,wBAAgB,YAAY,CAC1B,MAAM,EAAE,UAAU,EAAE,EACpB,MAAM,EAAE,UAAU,EAClB,SAAS,EAAE,MAAM,GAChB,UAAU,EAAE,CAiCd"}
@@ -1,5 +1,11 @@
1
- "use client";
2
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ /**
2
+ * Filter definitions and the pure event-filtering predicate.
3
+ *
4
+ * Deliberately NOT a `"use client"` module: these are plain data and a pure
5
+ * function, so a Server Component can import and call them directly. Keeping
6
+ * them out of the client boundary is what lets `index.ts` re-export them as
7
+ * real values instead of client references.
8
+ */
3
9
  export const FILTER_OPTIONS = [
4
10
  { id: "all", label: "All" },
5
11
  { id: "navigation", label: "Navigation" },
@@ -40,6 +46,3 @@ export function filterEvents(events, filter, threshold) {
40
46
  }
41
47
  });
42
48
  }
43
- export function FilterControls({ activeFilter, onFilterChange, threshold, onThresholdChange, }) {
44
- return (_jsxs("div", { className: "nrpd-filters", children: [_jsx("div", { className: "nrpd-filter-group", children: FILTER_OPTIONS.map((opt) => (_jsx("button", { className: `nrpd-filter-btn ${activeFilter === opt.id ? "active" : ""}`, onClick: () => onFilterChange(opt.id), children: opt.label }, opt.id))) }), _jsx("div", { className: "nrpd-threshold", children: _jsxs("label", { children: ["Slow threshold:", _jsx("input", { type: "number", value: threshold, onChange: (e) => onThresholdChange(Number.parseInt(e.target.value, 10) || 500), min: 0, step: 50 }), _jsx("span", { children: "ms" })] }) })] }));
45
- }
package/dist/styles.css CHANGED
@@ -187,6 +187,15 @@
187
187
  background: #1a1f29;
188
188
  }
189
189
 
190
+ .nrpd-timeline-truncated {
191
+ padding: 6px 8px;
192
+ margin-bottom: 6px;
193
+ border-radius: 4px;
194
+ background: #1a1f29;
195
+ color: #6e7681;
196
+ font-size: 11px;
197
+ }
198
+
190
199
  .nrpd-timeline-item.selected {
191
200
  background: #1e3a5f;
192
201
  }
@@ -258,6 +267,65 @@
258
267
  flex: 1;
259
268
  }
260
269
 
270
+ /* Panel fault isolation: shown in place of a panel that threw. */
271
+ .nrpd-panel-error {
272
+ display: flex;
273
+ flex-direction: column;
274
+ gap: 4px;
275
+ padding: 12px;
276
+ border: 1px solid #7f1d1d;
277
+ border-radius: 6px;
278
+ background: #2d1416;
279
+ color: #f87171;
280
+ font-size: 12px;
281
+ }
282
+
283
+ .nrpd-panel-error-message {
284
+ color: #fca5a5;
285
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
286
+ font-size: 11px;
287
+ word-break: break-word;
288
+ }
289
+
290
+ /* Shown in place of a panel that suspended. */
291
+ .nrpd-skeleton {
292
+ display: flex;
293
+ flex-direction: column;
294
+ gap: 8px;
295
+ padding: 4px 0;
296
+ }
297
+
298
+ .nrpd-skeleton-bar {
299
+ height: 14px;
300
+ border-radius: 4px;
301
+ background: linear-gradient(90deg, #1a1f29 25%, #232b36 50%, #1a1f29 75%);
302
+ background-size: 200% 100%;
303
+ animation: nrpd-skeleton-pulse 1.4s ease-in-out infinite;
304
+ }
305
+
306
+ .nrpd-skeleton-bar:nth-child(2) {
307
+ width: 85%;
308
+ }
309
+
310
+ .nrpd-skeleton-bar:nth-child(3) {
311
+ width: 65%;
312
+ }
313
+
314
+ @keyframes nrpd-skeleton-pulse {
315
+ 0% {
316
+ background-position: 200% 0;
317
+ }
318
+ 100% {
319
+ background-position: -200% 0;
320
+ }
321
+ }
322
+
323
+ @media (prefers-reduced-motion: reduce) {
324
+ .nrpd-skeleton-bar {
325
+ animation: none;
326
+ }
327
+ }
328
+
261
329
  @media (max-width: 768px) {
262
330
  .nrpd-main {
263
331
  grid-template-columns: 1fr;
package/package.json CHANGED
@@ -1,10 +1,13 @@
1
1
  {
2
2
  "name": "@next-rsc-debug/devtools",
3
- "version": "0.1.9",
3
+ "version": "0.2.0",
4
4
  "description": "React-based DevTools UI for Next RSC Debug",
5
5
  "author": "Samith Mendis",
6
6
  "license": "MIT",
7
7
  "type": "module",
8
+ "sideEffects": [
9
+ "**/*.css"
10
+ ],
8
11
  "main": "./dist/index.js",
9
12
  "types": "./dist/index.d.ts",
10
13
  "exports": {
@@ -20,10 +23,11 @@
20
23
  "scripts": {
21
24
  "build": "tsc -p tsconfig.json && node ./scripts/copy-css.mjs",
22
25
  "typecheck": "tsc --noEmit -p tsconfig.json",
23
- "test": "vitest run --config vitest.config.ts"
26
+ "test": "vitest run --config vitest.config.ts",
27
+ "clean": "rm -rf dist tsconfig.tsbuildinfo"
24
28
  },
25
29
  "dependencies": {
26
- "@next-rsc-debug/core": "0.1.9"
30
+ "@next-rsc-debug/core": "0.2.0"
27
31
  },
28
32
  "publishConfig": {
29
33
  "access": "public",
@@ -1,19 +0,0 @@
1
- /**
2
- * Filter controls for the DevTools dashboard.
3
- */
4
- import type { DebugEvent } from "@next-rsc-debug/core";
5
- export type FilterType = "all" | "navigation" | "rsc" | "fetch" | "cache" | "action" | "error" | "slow" | "errors";
6
- export interface FilterOption {
7
- id: FilterType;
8
- label: string;
9
- }
10
- export declare const FILTER_OPTIONS: FilterOption[];
11
- export declare function filterEvents(events: DebugEvent[], filter: FilterType, threshold: number): DebugEvent[];
12
- export interface FilterControlsProps {
13
- activeFilter: FilterType;
14
- onFilterChange: (filter: FilterType) => void;
15
- threshold: number;
16
- onThresholdChange: (threshold: number) => void;
17
- }
18
- export declare function FilterControls({ activeFilter, onFilterChange, threshold, onThresholdChange, }: FilterControlsProps): import("react").JSX.Element;
19
- //# sourceMappingURL=Filters.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"Filters.d.ts","sourceRoot":"","sources":["../../src/components/Filters.tsx"],"names":[],"mappings":"AAEA;;GAEG;AAEH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,sBAAsB,CAAC;AAEvD,MAAM,MAAM,UAAU,GAClB,KAAK,GACL,YAAY,GACZ,KAAK,GACL,OAAO,GACP,OAAO,GACP,QAAQ,GACR,OAAO,GACP,MAAM,GACN,QAAQ,CAAC;AAEb,MAAM,WAAW,YAAY;IAC3B,EAAE,EAAE,UAAU,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;CACf;AAED,eAAO,MAAM,cAAc,EAAE,YAAY,EAUxC,CAAC;AAEF,wBAAgB,YAAY,CAC1B,MAAM,EAAE,UAAU,EAAE,EACpB,MAAM,EAAE,UAAU,EAClB,SAAS,EAAE,MAAM,GAChB,UAAU,EAAE,CAiCd;AAED,MAAM,WAAW,mBAAmB;IAClC,YAAY,EAAE,UAAU,CAAC;IACzB,cAAc,EAAE,CAAC,MAAM,EAAE,UAAU,KAAK,IAAI,CAAC;IAC7C,SAAS,EAAE,MAAM,CAAC;IAClB,iBAAiB,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,IAAI,CAAC;CAChD;AAED,wBAAgB,cAAc,CAAC,EAC7B,YAAY,EACZ,cAAc,EACd,SAAS,EACT,iBAAiB,GAClB,EAAE,mBAAmB,+BA+BrB"}