Skip to content

Commit a4ced54

Browse files
authored
Add Signals update timeline (#963)
* Add Signals update timeline Assisted-By: devx/633e51ab-4160-4b2c-a196-017418800531 * Align timeline signal IDs Assisted-By: devx/633e51ab-4160-4b2c-a196-017418800531
1 parent b38cacf commit a4ced54

11 files changed

Lines changed: 864 additions & 56 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@preact/signals-devtools-ui": patch
3+
---
4+
5+
Add a chronological Timeline tab that groups runtime updates into inspectable cascades, preserves signal IDs, and supports signal focus and filtering.

packages/devtools-ui/README.md

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ import {
7878
Header,
7979
UpdatesContainer,
8080
PerformanceInsights,
81+
Timeline,
8182
GraphVisualization,
8283
} from "@preact/signals-devtools-ui";
8384
import { createDirectAdapter } from "@preact/signals-devtools-adapter";
@@ -93,6 +94,7 @@ function MyCustomDevTools() {
9394
<div className="my-layout">
9495
<UpdatesContainer />
9596
<PerformanceInsights />
97+
<Timeline />
9698
<GraphVisualization />
9799
</div>
98100
</div>
@@ -104,19 +106,19 @@ function MyCustomDevTools() {
104106

105107
### `mount(options)`
106108

107-
| Option | Type | Required | Description |
108-
| ------------ | --------------------------------------- | -------- | -------------------------------- |
109-
| `adapter` | `DevToolsAdapter` | Yes | The communication adapter to use |
110-
| `container` | `HTMLElement` | Yes | The DOM element to render into |
111-
| `hideHeader` | `boolean` | No | Hide the header bar |
112-
| `initialTab` | `"updates" \| "performance" \| "graph"` | No | Which tab to show initially |
109+
| Option | Type | Required | Description |
110+
| ------------ | ------------------------------------------------- | -------- | -------------------------------- |
111+
| `adapter` | `DevToolsAdapter` | Yes | The communication adapter to use |
112+
| `container` | `HTMLElement` | Yes | The DOM element to render into |
113+
| `hideHeader` | `boolean` | No | Hide the header bar |
114+
| `initialTab` | `"updates" \| "performance" \| "timeline" \| "graph"` | No | Which tab to show initially |
113115

114116
### `DevToolsPanel`
115117

116-
| Prop | Type | Default | Description |
117-
| ------------ | --------------------------------------- | ----------- | ---------------------- |
118-
| `hideHeader` | `boolean` | `false` | Hide the header bar |
119-
| `initialTab` | `"updates" \| "performance" \| "graph"` | `"updates"` | Initial tab to display |
118+
| Prop | Type | Default | Description |
119+
| ------------ | ------------------------------------------------- | ----------- | ---------------------- |
120+
| `hideHeader` | `boolean` | `false` | Hide the header bar |
121+
| `initialTab` | `"updates" \| "performance" \| "timeline" \| "graph"` | `"updates"` | Initial tab to display |
120122

121123
## Styling
122124

@@ -135,6 +137,7 @@ Or include the CSS file from the dist folder manually.
135137
- `SettingsPanel` - Debug settings configuration
136138
- `UpdatesContainer` - Signal updates list
137139
- `PerformanceInsights` - Instance-level update hotspots and no-output-change computed recomputations
140+
- `Timeline` - Chronological runtime update cascades with signal focus and filtering
138141
- `GraphVisualization` - Dependency graph
139142
- `EmptyState` - Empty state placeholder
140143
- `StatusIndicator` - Connection status indicator

packages/devtools-ui/src/DevToolsPanel.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,18 +7,20 @@ import { PerformanceInsights } from "./components/PerformanceInsights";
77
import { SettingsPanel } from "./components/SettingsPanel";
88
import { GraphVisualization } from "./components/Graph";
99
import { UpdatesContainer } from "./components/UpdatesContainer";
10+
import { Timeline } from "./components/Timeline";
1011
import {
1112
createDevToolsContext,
1213
destroyDevToolsContext,
1314
getContext,
1415
setCurrentDevToolsContext,
1516
} from "./context";
1617

17-
type PanelTab = "updates" | "performance" | "graph";
18+
type PanelTab = "updates" | "performance" | "timeline" | "graph";
1819

1920
const PANEL_TABS: Array<{ id: PanelTab; label: string }> = [
2021
{ id: "updates", label: "Updates" },
2122
{ id: "performance", label: "Performance" },
23+
{ id: "timeline", label: "Timeline" },
2224
{ id: "graph", label: "Dependency Graph" },
2325
];
2426

@@ -71,6 +73,7 @@ export function DevToolsPanel({
7173
<>
7274
{activeTab.value === "updates" && <UpdatesContainer />}
7375
{activeTab.value === "performance" && <PerformanceInsights />}
76+
{activeTab.value === "timeline" && <Timeline />}
7477
{activeTab.value === "graph" && <GraphVisualization />}
7578
</>
7679
)}
Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
import { useSignal } from "@preact/signals";
2+
import type { TimelineBatch, TimelineUpdate } from "../context";
3+
import { getContext } from "../context";
4+
import { formatUpdateValue } from "./UpdateItem";
5+
6+
/** Maximum number of cascade batches rendered at once. The memory cap
7+
* (MAX_TIMELINE_BATCHES) is larger so filtering/focus retain history. */
8+
export const MAX_VISIBLE_BATCHES = 100;
9+
10+
interface SignalIdentity {
11+
signalId: string;
12+
name: string;
13+
type: TimelineUpdate["signalType"];
14+
count: number;
15+
}
16+
17+
const getOccurrenceKey = (update: TimelineUpdate) =>
18+
update.signalId ?? update.timelineId;
19+
20+
const formatTime = (timestamp: number) => {
21+
const date = new Date(timestamp);
22+
return `${date.toLocaleTimeString([], {
23+
hour: "2-digit",
24+
minute: "2-digit",
25+
second: "2-digit",
26+
})}.${String(date.getMilliseconds()).padStart(3, "0")}`;
27+
};
28+
29+
const getBatchTimestamp = (batch: TimelineBatch) =>
30+
batch.updates[0]?.timestamp ?? batch.receivedAt;
31+
32+
function TimelineBatchCard({
33+
batch,
34+
query,
35+
focusedSignal,
36+
onFocusSignal,
37+
}: {
38+
batch: TimelineBatch;
39+
query: string;
40+
focusedSignal: string;
41+
onFocusSignal: (signal: string) => void;
42+
}) {
43+
const isCollapsed = useSignal(batch.updates.length > 12);
44+
const normalizedQuery = query.trim().toLowerCase();
45+
const updateMatches = (update: TimelineUpdate) => {
46+
const matchesFocus = !focusedSignal || update.signalId === focusedSignal;
47+
const matchesQuery =
48+
!normalizedQuery ||
49+
`${update.signalName} ${update.signalId ?? ""} ${update.signalType}`
50+
.toLowerCase()
51+
.includes(normalizedQuery);
52+
return matchesFocus && matchesQuery;
53+
};
54+
const rootUpdates = batch.updates.filter(update => (update.depth ?? 0) === 0);
55+
const signalCount = new Set(batch.updates.map(getOccurrenceKey)).size;
56+
const hasMatch = batch.updates.some(updateMatches);
57+
58+
return (
59+
<article
60+
className={`timeline-batch ${hasMatch ? "has-match" : ""}`}
61+
data-cascade-id={batch.id}
62+
>
63+
<div className="timeline-rail" aria-hidden="true" />
64+
<div className="timeline-batch-card">
65+
<button
66+
className="timeline-batch-summary"
67+
onClick={() => (isCollapsed.value = !isCollapsed.value)}
68+
aria-expanded={!isCollapsed.value}
69+
>
70+
<span className="timeline-disclosure" aria-hidden="true">
71+
{isCollapsed.value ? "▶" : "▼"}
72+
</span>
73+
<span className="timeline-batch-title">
74+
Cascade {batch.id.replace("cascade-", "")}
75+
</span>
76+
<span className="timeline-batch-meta">
77+
{batch.updates.length} events · {signalCount} signals ·{" "}
78+
{rootUpdates.length} root
79+
{rootUpdates.length === 1 ? "" : "s"}
80+
</span>
81+
<time
82+
className="timeline-time"
83+
dateTime={new Date(getBatchTimestamp(batch)).toISOString()}
84+
>
85+
{formatTime(getBatchTimestamp(batch))}
86+
</time>
87+
</button>
88+
89+
{!isCollapsed.value && (
90+
<div className="timeline-events" role="list">
91+
{batch.updates.map(update => {
92+
const isMatch = updateMatches(update);
93+
const depth = update.depth ?? 0;
94+
const isValueUpdate = update.type === "update";
95+
return (
96+
<div
97+
className={`timeline-event ${isMatch ? "is-match" : ""}`}
98+
data-signal-id={update.signalId}
99+
key={update.timelineId}
100+
role="listitem"
101+
style={{ "--timeline-depth": depth }}
102+
>
103+
<span
104+
className={`timeline-event-kind ${depth === 0 ? "root" : "derived"}`}
105+
>
106+
{depth === 0 ? "Root" : `Depth ${depth}`}
107+
</span>
108+
{update.signalId ? (
109+
<button
110+
className="timeline-signal"
111+
onClick={() =>
112+
update.signalId && onFocusSignal(update.signalId)
113+
}
114+
title={`Focus ${update.signalId}`}
115+
>
116+
{update.signalName}
117+
</button>
118+
) : (
119+
<span className="timeline-signal">{update.signalName}</span>
120+
)}
121+
<span className={`timeline-type ${update.signalType}`}>
122+
{update.signalType}
123+
</span>
124+
{isValueUpdate && (
125+
<span className="timeline-value-change">
126+
<span>{formatUpdateValue(update.prevValue)}</span>
127+
<span aria-hidden="true"></span>
128+
<span>{formatUpdateValue(update.newValue)}</span>
129+
</span>
130+
)}
131+
<code className="timeline-signal-id">
132+
{update.signalId ?? "runtime ID unavailable"}
133+
</code>
134+
</div>
135+
);
136+
})}
137+
</div>
138+
)}
139+
</div>
140+
</article>
141+
);
142+
}
143+
144+
export function Timeline() {
145+
const { updatesStore } = getContext();
146+
const query = useSignal("");
147+
const focusedSignal = useSignal("");
148+
const batches = updatesStore.timelineBatches.value;
149+
const identities = new Map<string, SignalIdentity>();
150+
151+
for (const batch of batches) {
152+
for (const update of batch.updates) {
153+
if (!update.signalId) continue;
154+
155+
const existing = identities.get(update.signalId);
156+
if (existing) {
157+
existing.count++;
158+
} else {
159+
identities.set(update.signalId, {
160+
signalId: update.signalId,
161+
name: update.signalName,
162+
type: update.signalType,
163+
count: 1,
164+
});
165+
}
166+
}
167+
}
168+
169+
const normalizedQuery = query.value.trim().toLowerCase();
170+
const matchesBatch = (batch: TimelineBatch) =>
171+
batch.updates.some(update => {
172+
const matchesFocus =
173+
!focusedSignal.value || update.signalId === focusedSignal.value;
174+
const matchesQuery =
175+
!normalizedQuery ||
176+
`${update.signalName} ${update.signalId ?? ""} ${update.signalType}`
177+
.toLowerCase()
178+
.includes(normalizedQuery);
179+
return matchesFocus && matchesQuery;
180+
});
181+
const matchingBatches = batches.filter(matchesBatch);
182+
const visibleBatches = matchingBatches.slice(-MAX_VISIBLE_BATCHES);
183+
184+
return (
185+
<div className="timeline-container">
186+
<div className="timeline-toolbar">
187+
<label className="timeline-search-label">
188+
<span>Find cascades</span>
189+
<input
190+
aria-label="Find cascades by signal name or ID"
191+
className="timeline-search"
192+
placeholder="Signal name, ID, or type"
193+
value={query.value}
194+
onInput={event => (query.value = event.currentTarget.value)}
195+
/>
196+
</label>
197+
<label className="timeline-focus-label">
198+
<span>Focus signal</span>
199+
<select
200+
aria-label="Focus a signal"
201+
value={focusedSignal.value}
202+
onChange={event =>
203+
(focusedSignal.value = event.currentTarget.value)
204+
}
205+
>
206+
<option value="">All signals</option>
207+
{Array.from(identities.values()).map(identity => (
208+
<option key={identity.signalId} value={identity.signalId}>
209+
{identity.name} · {identity.signalId} ({identity.count})
210+
</option>
211+
))}
212+
</select>
213+
</label>
214+
{focusedSignal.value && (
215+
<button
216+
className="timeline-clear-focus"
217+
onClick={() => (focusedSignal.value = "")}
218+
>
219+
Clear focus
220+
</button>
221+
)}
222+
<span className="timeline-results" aria-live="polite">
223+
{matchingBatches.length} of {batches.length} cascades
224+
{matchingBatches.length > MAX_VISIBLE_BATCHES
225+
? ` · latest ${MAX_VISIBLE_BATCHES} shown`
226+
: ""}
227+
</span>
228+
</div>
229+
230+
{visibleBatches.length === 0 ? (
231+
<div className="timeline-empty">
232+
{batches.length === 0
233+
? "Signal updates will appear here as chronological cascades."
234+
: "No cascades match the current filter."}
235+
</div>
236+
) : (
237+
<div className="timeline-list">
238+
{visibleBatches.map(batch => (
239+
<TimelineBatchCard
240+
batch={batch}
241+
focusedSignal={focusedSignal.value}
242+
key={batch.id}
243+
onFocusSignal={signal => (focusedSignal.value = signal)}
244+
query={query.value}
245+
/>
246+
))}
247+
</div>
248+
)}
249+
</div>
250+
);
251+
}

packages/devtools-ui/src/components/UpdateItem.tsx

Lines changed: 20 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -6,25 +6,25 @@ interface UpdateItemProps {
66
count?: number;
77
}
88

9+
export function formatUpdateValue(value: unknown): string {
10+
if (value === null) return "null";
11+
if (value === undefined) return "undefined";
12+
if (typeof value === "string") return `"${value}"`;
13+
if (typeof value === "function") return "function()";
14+
if (typeof value === "object") {
15+
try {
16+
return JSON.stringify(value, null, 0);
17+
} catch {
18+
return "[Object]";
19+
}
20+
}
21+
return String(value);
22+
}
23+
924
export function UpdateItem({ update, count, firstUpdate }: UpdateItemProps) {
1025
const time = new Date(
1126
update.timestamp || update.receivedAt
1227
).toLocaleTimeString();
13-
14-
const formatValue = (value: any): string => {
15-
if (value === null) return "null";
16-
if (value === undefined) return "undefined";
17-
if (typeof value === "string") return `"${value}"`;
18-
if (typeof value === "function") return "function()";
19-
if (typeof value === "object") {
20-
try {
21-
return JSON.stringify(value, null, 0);
22-
} catch {
23-
return "[Object]";
24-
}
25-
}
26-
return String(value);
27-
};
2828
const countLabel = count && (
2929
<span class="update-count" title="Number of grouped identical updates">
3030
x{count}
@@ -48,10 +48,12 @@ export function UpdateItem({ update, count, firstUpdate }: UpdateItemProps) {
4848
);
4949
}
5050

51-
const prevValue = formatValue(update.prevValue);
52-
const newValue = formatValue(update.newValue);
51+
const prevValue = formatUpdateValue(update.prevValue);
52+
const newValue = formatUpdateValue(update.newValue);
5353
const firstValue =
54-
firstUpdate !== undefined ? formatValue(firstUpdate.prevValue) : undefined;
54+
firstUpdate !== undefined
55+
? formatUpdateValue(firstUpdate.prevValue)
56+
: undefined;
5557

5658
return (
5759
<div class={`update-item ${update.type}`}>

0 commit comments

Comments
 (0)