@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.
- package/README.md +94 -3
- package/dist/components/DevTools.d.ts.map +1 -1
- package/dist/components/DevTools.js +38 -4
- package/dist/components/FetchInspector.d.ts +11 -1
- package/dist/components/FetchInspector.d.ts.map +1 -1
- package/dist/components/FetchInspector.js +10 -4
- package/dist/components/FilterControls.d.ts +17 -0
- package/dist/components/FilterControls.d.ts.map +1 -0
- package/dist/components/FilterControls.js +14 -0
- package/dist/components/PanelBoundary.d.ts +35 -0
- package/dist/components/PanelBoundary.d.ts.map +1 -0
- package/dist/components/PanelBoundary.js +56 -0
- package/dist/components/Summary.d.ts +15 -2
- package/dist/components/Summary.d.ts.map +1 -1
- package/dist/components/Summary.js +9 -6
- package/dist/components/Timeline.d.ts +14 -1
- package/dist/components/Timeline.d.ts.map +1 -1
- package/dist/components/Timeline.js +15 -7
- package/dist/components/Warnings.d.ts +12 -2
- package/dist/components/Warnings.d.ts.map +1 -1
- package/dist/components/Warnings.js +6 -5
- package/dist/hooks/useSse.d.ts +10 -1
- package/dist/hooks/useSse.d.ts.map +1 -1
- package/dist/hooks/useSse.js +63 -9
- package/dist/index.d.ts +15 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +15 -2
- package/dist/lib/filterOptions.d.ts +17 -0
- package/dist/lib/filterOptions.d.ts.map +1 -0
- package/dist/{components/Filters.js → lib/filterOptions.js} +8 -5
- package/dist/styles.css +68 -0
- package/package.json +7 -3
- package/dist/components/Filters.d.ts +0 -19
- 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
|
-
|
|
25
|
-
an App Router Server Component page. Do **not** wrap it in
|
|
26
|
-
`ssr: false` — that is not permitted inside a Server
|
|
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":"
|
|
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
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
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":"
|
|
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
|
-
|
|
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
|
-
|
|
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":"
|
|
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
|
|
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 =
|
|
13
|
-
const dupes =
|
|
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,
|
|
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":"
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
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":"
|
|
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
|
|
10
|
-
if (
|
|
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 (",
|
|
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
|
}
|
package/dist/hooks/useSse.d.ts
CHANGED
|
@@ -4,5 +4,14 @@ export interface SseState {
|
|
|
4
4
|
events: DebugEvent[];
|
|
5
5
|
error: string | null;
|
|
6
6
|
}
|
|
7
|
-
export
|
|
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;
|
|
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"}
|
package/dist/hooks/useSse.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
38
|
-
|
|
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
|
-
|
|
49
|
-
|
|
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,
|
|
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
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"
|
|
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,
|
|
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
|
-
|
|
2
|
-
|
|
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.
|
|
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.
|
|
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"}
|