@preact/signals-devtools-ui 0.1.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.
@@ -0,0 +1,75 @@
1
+ import type { SignalUpdate } from "../context";
2
+
3
+ interface UpdateItemProps {
4
+ update: SignalUpdate;
5
+ firstUpdate?: SignalUpdate;
6
+ count?: number;
7
+ }
8
+
9
+ export function UpdateItem({ update, count, firstUpdate }: UpdateItemProps) {
10
+ const time = new Date(
11
+ update.timestamp || update.receivedAt
12
+ ).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
+ };
28
+ const countLabel = count && (
29
+ <span class="update-count" title="Number of grouped identical updates">
30
+ x{count}
31
+ </span>
32
+ );
33
+
34
+ if (update.type === "effect") {
35
+ return (
36
+ <div className={`update-item ${update.type}`}>
37
+ <div className="update-header">
38
+ <span className="signal-name">
39
+ ↪️ {update.signalName}
40
+ {countLabel}
41
+ </span>
42
+ <span className="update-time">{time}</span>
43
+ </div>
44
+ </div>
45
+ );
46
+ }
47
+
48
+ const prevValue = formatValue(update.prevValue);
49
+ const newValue = formatValue(update.newValue);
50
+ const firstValue =
51
+ firstUpdate !== undefined ? formatValue(firstUpdate.prevValue) : undefined;
52
+
53
+ return (
54
+ <div class={`update-item ${update.type}`}>
55
+ <div class="update-header">
56
+ <span class="signal-name">
57
+ {update.depth === 0 ? "🎯" : "↪️"} {update.signalName}
58
+ {countLabel}
59
+ </span>
60
+ <span class="update-time">{time}</span>
61
+ </div>
62
+ <div class="value-change">
63
+ {firstValue && firstValue !== prevValue && (
64
+ <>
65
+ <span class="value-prev">{firstValue}</span>
66
+ <span class="value-arrow">...</span>
67
+ </>
68
+ )}
69
+ <span class="value-prev">{prevValue}</span>
70
+ <span class="value-arrow">→</span>
71
+ <span class="value-new">{newValue}</span>
72
+ </div>
73
+ </div>
74
+ );
75
+ }
@@ -0,0 +1,69 @@
1
+ import { useSignal } from "@preact/signals";
2
+ import type { UpdateTreeNode } from "../context";
3
+ import { UpdateItem } from "./UpdateItem";
4
+
5
+ interface UpdateTreeNodeProps {
6
+ node: UpdateTreeNode;
7
+ }
8
+
9
+ export function UpdateTreeNodeComponent({ node }: UpdateTreeNodeProps) {
10
+ const isCollapsed = useSignal(false);
11
+
12
+ const toggleCollapse = () => {
13
+ isCollapsed.value = !isCollapsed.value;
14
+ };
15
+
16
+ const hasChildren = node.children.length > 0;
17
+ const nodeCount = node.type === "group" ? node.count : undefined;
18
+ const firstUpdate = node.type === "group" ? node.firstUpdate : undefined;
19
+
20
+ let childrenToRender: UpdateTreeNode[];
21
+
22
+ if (node.type === "group" && node.firstChildren) {
23
+ childrenToRender = node.children.map((lastChild, index) => {
24
+ const firstChild = node.firstChildren[index];
25
+ const mergedChild: UpdateTreeNode = {
26
+ ...lastChild,
27
+ type: "group",
28
+ firstUpdate: firstChild.update,
29
+ firstChildren: firstChild.children,
30
+ count: node.count,
31
+ };
32
+ return mergedChild;
33
+ });
34
+ } else {
35
+ childrenToRender = node.children;
36
+ }
37
+
38
+ return (
39
+ <div className="tree-node">
40
+ <div className="tree-node-content">
41
+ {hasChildren && (
42
+ <button
43
+ className="collapse-button"
44
+ onClick={toggleCollapse}
45
+ aria-label={isCollapsed.value ? "Expand" : "Collapse"}
46
+ >
47
+ {isCollapsed.value ? "▶" : "▼"}
48
+ </button>
49
+ )}
50
+ {!hasChildren && <div className="collapse-spacer" />}
51
+ <div className="update-content">
52
+ <UpdateItem
53
+ update={node.update}
54
+ count={nodeCount}
55
+ firstUpdate={firstUpdate}
56
+ />
57
+ </div>
58
+ </div>
59
+
60
+ {hasChildren && !isCollapsed.value && (
61
+ <div className="tree-children">
62
+ {childrenToRender.map(child => (
63
+ <UpdateTreeNodeComponent key={child.id} node={child} />
64
+ ))}
65
+ </div>
66
+ )}
67
+ </div>
68
+ );
69
+ }
@@ -0,0 +1,41 @@
1
+ import { useRef } from "preact/hooks";
2
+ import { useSignalEffect } from "@preact/signals";
3
+ import { UpdateTreeNodeComponent } from "./UpdateTreeNode";
4
+ import { getContext } from "../context";
5
+
6
+ export function UpdatesContainer() {
7
+ const { updatesStore } = getContext();
8
+ const updatesListRef = useRef<HTMLDivElement>(null);
9
+ const updateTree = updatesStore.collapsedUpdateTree.value;
10
+
11
+ useSignalEffect(() => {
12
+ // Register scroll restoration
13
+ // When a new update is added we scroll to top
14
+ // Access the signal to subscribe to updates
15
+ updatesStore.updateTree.value;
16
+ if (updatesListRef.current) {
17
+ updatesListRef.current.scrollTop = 0;
18
+ }
19
+ });
20
+
21
+ return (
22
+ <div className="updates-container">
23
+ <div className="updates-header">
24
+ <div className="updates-stats">
25
+ <span>
26
+ Updates: <strong>{updatesStore.totalUpdates.value}</strong>
27
+ </span>
28
+ <span>
29
+ Signals: <strong>{updatesStore.signalCounts.value.size}</strong>
30
+ </span>
31
+ </div>
32
+ </div>
33
+
34
+ <div className="updates-list" ref={updatesListRef}>
35
+ {updateTree.map(node => (
36
+ <UpdateTreeNodeComponent key={node.id} node={node} />
37
+ ))}
38
+ </div>
39
+ </div>
40
+ );
41
+ }
@@ -0,0 +1,9 @@
1
+ export { Button } from "./Button";
2
+ export { EmptyState } from "./EmptyState";
3
+ export { GraphVisualization } from "./Graph";
4
+ export { Header } from "./Header";
5
+ export { SettingsPanel } from "./SettingsPanel";
6
+ export { StatusIndicator } from "./StatusIndicator";
7
+ export { UpdateItem } from "./UpdateItem";
8
+ export { UpdateTreeNodeComponent } from "./UpdateTreeNode";
9
+ export { UpdatesContainer } from "./UpdatesContainer";
package/src/context.ts ADDED
@@ -0,0 +1,359 @@
1
+ import { signal, computed } from "@preact/signals";
2
+ import type {
3
+ DevToolsAdapter,
4
+ ConnectionStatus,
5
+ ConnectionStatusType,
6
+ Settings,
7
+ SignalDisposed,
8
+ } from "@preact/signals-devtools-adapter";
9
+
10
+ export interface DevToolsContext {
11
+ adapter: DevToolsAdapter;
12
+ connectionStore: ReturnType<typeof createConnectionStore>;
13
+ updatesStore: ReturnType<typeof createUpdatesStore>;
14
+ settingsStore: ReturnType<typeof createSettingsStore>;
15
+ }
16
+
17
+ let currentContext: DevToolsContext | null = null;
18
+
19
+ export function getContext(): DevToolsContext {
20
+ if (!currentContext) {
21
+ throw new Error(
22
+ "DevTools context not initialized. Call initDevTools() first."
23
+ );
24
+ }
25
+ return currentContext;
26
+ }
27
+
28
+ export function createConnectionStore(adapter: DevToolsAdapter) {
29
+ const status = signal<ConnectionStatusType>("connecting");
30
+ const message = signal<string>("Connecting...");
31
+ const isConnected = signal(false);
32
+
33
+ // Listen to adapter events
34
+ adapter.on(
35
+ "connectionStatusChanged",
36
+ (connectionStatus: ConnectionStatus) => {
37
+ status.value = connectionStatus.status;
38
+ message.value = connectionStatus.message;
39
+ }
40
+ );
41
+
42
+ adapter.on("signalsAvailable", (available: boolean) => {
43
+ isConnected.value = available;
44
+ });
45
+
46
+ const refreshConnection = () => {
47
+ status.value = "connecting";
48
+ message.value = "Connecting...";
49
+ adapter.requestState();
50
+ };
51
+
52
+ return {
53
+ get status() {
54
+ return status.value;
55
+ },
56
+ get message() {
57
+ return message.value;
58
+ },
59
+ get isConnected() {
60
+ return isConnected.value;
61
+ },
62
+ refreshConnection,
63
+ };
64
+ }
65
+
66
+ export interface SignalUpdate {
67
+ type: "update" | "effect";
68
+ signalType: "signal" | "computed" | "effect";
69
+ signalName: string;
70
+ signalId?: string;
71
+ prevValue?: any;
72
+ newValue?: any;
73
+ timestamp?: number;
74
+ receivedAt: number;
75
+ depth?: number;
76
+ subscribedTo?: string;
77
+ }
78
+
79
+ export type Divider = { type: "divider" };
80
+
81
+ export interface UpdateTreeNodeBase {
82
+ id: string;
83
+ update: SignalUpdate;
84
+ children: UpdateTreeNode[];
85
+ depth: number;
86
+ hasChildren: boolean;
87
+ }
88
+
89
+ export interface UpdateTreeNodeSingle extends UpdateTreeNodeBase {
90
+ type: "single";
91
+ }
92
+
93
+ export interface UpdateTreeNodeGroup extends UpdateTreeNodeBase {
94
+ type: "group";
95
+ count: number;
96
+ firstUpdate: SignalUpdate;
97
+ firstChildren: UpdateTreeNode[];
98
+ }
99
+
100
+ export type UpdateTreeNode = UpdateTreeNodeGroup | UpdateTreeNodeSingle;
101
+
102
+ const nodesAreEqual = (a: UpdateTreeNode, b: UpdateTreeNode): boolean => {
103
+ return (
104
+ a.update.signalId === b.update.signalId &&
105
+ a.children.length === b.children.length &&
106
+ a.children.every((child, index) => nodesAreEqual(child, b.children[index]))
107
+ );
108
+ };
109
+
110
+ const collapseTree = (nodes: UpdateTreeNodeSingle[]): UpdateTreeNode[] => {
111
+ const tree: UpdateTreeNode[] = [];
112
+ let lastNode: UpdateTreeNode | undefined;
113
+
114
+ for (const node of nodes) {
115
+ if (lastNode && nodesAreEqual(lastNode, node)) {
116
+ if (lastNode.type !== "group") {
117
+ tree.pop();
118
+ lastNode = {
119
+ ...lastNode,
120
+ type: "group",
121
+ count: 2,
122
+ firstUpdate: node.update,
123
+ firstChildren: node.children,
124
+ };
125
+ tree.push(lastNode);
126
+ } else {
127
+ lastNode.count++;
128
+ lastNode.firstUpdate = node.update;
129
+ lastNode.firstChildren = node.children;
130
+ }
131
+ continue;
132
+ }
133
+ tree.push(node);
134
+ lastNode = node;
135
+ }
136
+
137
+ return tree;
138
+ };
139
+
140
+ export function createUpdatesStore(
141
+ adapter: DevToolsAdapter,
142
+ settingsStore: ReturnType<typeof createSettingsStore>
143
+ ) {
144
+ const updates = signal<(SignalUpdate | Divider)[]>([]);
145
+ const isPaused = signal<boolean>(false);
146
+ const disposedSignalIds = signal<Set<string>>(new Set());
147
+
148
+ const addUpdate = (
149
+ update: SignalUpdate | Divider | Array<SignalUpdate | Divider>
150
+ ) => {
151
+ if (Array.isArray(update)) {
152
+ update.forEach(item => {
153
+ if (item.type !== "divider") item.receivedAt = Date.now();
154
+ });
155
+ } else if (update.type === "update") {
156
+ update.receivedAt = Date.now();
157
+ }
158
+ updates.value = [
159
+ ...updates.value,
160
+ ...(Array.isArray(update) ? update : [update]),
161
+ ];
162
+ };
163
+
164
+ const addDisposal = (disposal: SignalDisposed | SignalDisposed[]) => {
165
+ const disposals = Array.isArray(disposal) ? disposal : [disposal];
166
+ const newDisposed = new Set(disposedSignalIds.value);
167
+ for (const d of disposals) {
168
+ if (d.signalId) {
169
+ newDisposed.add(d.signalId);
170
+ }
171
+ }
172
+ disposedSignalIds.value = newDisposed;
173
+ };
174
+
175
+ const hasUpdates = computed(() => updates.value.length > 0);
176
+
177
+ const signalCounts = computed(() => {
178
+ const counts = new Map<string, number>();
179
+ updates.value.forEach(update => {
180
+ if (update.type === "divider") return;
181
+ const signalName = update.signalName || "Unknown";
182
+ counts.set(signalName, (counts.get(signalName) || 0) + 1);
183
+ });
184
+ return counts;
185
+ });
186
+
187
+ const updateTree = computed(() => {
188
+ const buildTree = (
189
+ updates: (SignalUpdate | Divider)[]
190
+ ): UpdateTreeNodeSingle[] => {
191
+ const tree: UpdateTreeNodeSingle[] = [];
192
+ const stack: UpdateTreeNodeSingle[] = [];
193
+
194
+ const recentUpdates = updates.slice(-100).reverse();
195
+
196
+ for (let i = 0; i < recentUpdates.length; i++) {
197
+ const item = recentUpdates[i];
198
+
199
+ if (item.type === "divider") {
200
+ continue;
201
+ }
202
+
203
+ const update = item as SignalUpdate;
204
+ const depth = update.depth || 0;
205
+
206
+ const nodeId = `${update.signalName}-${update.receivedAt}-${i}`;
207
+
208
+ const node: UpdateTreeNodeSingle = {
209
+ type: "single",
210
+ id: nodeId,
211
+ update,
212
+ children: [],
213
+ depth,
214
+ hasChildren: false,
215
+ };
216
+
217
+ while (stack.length > 0 && stack[stack.length - 1].depth >= depth) {
218
+ stack.pop();
219
+ }
220
+
221
+ if (stack.length === 0) {
222
+ tree.push(node);
223
+ } else {
224
+ const parent = stack[stack.length - 1];
225
+ parent.children.push(node);
226
+ parent.hasChildren = true;
227
+ }
228
+
229
+ stack.push(node);
230
+ }
231
+
232
+ return tree;
233
+ };
234
+
235
+ return buildTree(updates.value);
236
+ });
237
+
238
+ const clearUpdates = () => {
239
+ updates.value = [];
240
+ disposedSignalIds.value = new Set();
241
+ };
242
+
243
+ // Listen to adapter events
244
+ adapter.on("signalUpdate", (signalUpdates: SignalUpdate[]) => {
245
+ if (isPaused.value) return;
246
+
247
+ const updatesArray: Array<SignalUpdate | Divider> = [
248
+ ...signalUpdates,
249
+ ].reverse();
250
+ updatesArray.push({ type: "divider" });
251
+
252
+ addUpdate(updatesArray);
253
+ });
254
+
255
+ // Listen to disposal events
256
+ adapter.on("signalDisposed", (disposals: SignalDisposed[]) => {
257
+ if (isPaused.value) return;
258
+ addDisposal(disposals);
259
+ });
260
+
261
+ const collapsedUpdateTree = computed(() => {
262
+ const updateTreeValue = updateTree.value;
263
+ if (settingsStore.settings.grouped) {
264
+ return collapseTree(updateTreeValue);
265
+ }
266
+ return updateTreeValue;
267
+ });
268
+
269
+ return {
270
+ updates,
271
+ updateTree,
272
+ collapsedUpdateTree,
273
+ totalUpdates: computed(() => Object.keys(updateTree.value).length),
274
+ signalCounts,
275
+ disposedSignalIds,
276
+ addUpdate,
277
+ clearUpdates,
278
+ hasUpdates,
279
+ isPaused,
280
+ };
281
+ }
282
+
283
+ export function createSettingsStore(adapter: DevToolsAdapter) {
284
+ const settings = signal<Settings>({
285
+ enabled: true,
286
+ grouped: true,
287
+ maxUpdatesPerSecond: 60,
288
+ filterPatterns: [],
289
+ });
290
+
291
+ const showSettings = signal<boolean>(false);
292
+ const showDisposedSignals = signal<boolean>(false);
293
+
294
+ const applySettings = (newSettings: Settings) => {
295
+ settings.value = newSettings;
296
+ adapter.sendConfig(newSettings);
297
+ showSettings.value = false;
298
+ };
299
+
300
+ const toggleSettings = () => {
301
+ showSettings.value = !showSettings.value;
302
+ };
303
+
304
+ const hideSettings = () => {
305
+ showSettings.value = false;
306
+ };
307
+
308
+ const toggleShowDisposedSignals = () => {
309
+ showDisposedSignals.value = !showDisposedSignals.value;
310
+ };
311
+
312
+ // Listen to adapter events
313
+ adapter.on("configReceived", (config: { settings?: Settings }) => {
314
+ if (config.settings) {
315
+ settings.value = config.settings;
316
+ }
317
+ });
318
+
319
+ return {
320
+ get settings() {
321
+ return settings.value;
322
+ },
323
+ get showSettings() {
324
+ return showSettings.value;
325
+ },
326
+ get showDisposedSignals() {
327
+ return showDisposedSignals.value;
328
+ },
329
+ set settings(newSettings: Settings) {
330
+ settings.value = newSettings;
331
+ },
332
+ applySettings,
333
+ toggleSettings,
334
+ hideSettings,
335
+ toggleShowDisposedSignals,
336
+ };
337
+ }
338
+
339
+ export function initDevTools(adapter: DevToolsAdapter): DevToolsContext {
340
+ const settingsStore = createSettingsStore(adapter);
341
+ const updatesStore = createUpdatesStore(adapter, settingsStore);
342
+ const connectionStore = createConnectionStore(adapter);
343
+
344
+ currentContext = {
345
+ adapter,
346
+ connectionStore,
347
+ updatesStore,
348
+ settingsStore,
349
+ };
350
+
351
+ return currentContext;
352
+ }
353
+
354
+ export function destroyDevTools(): void {
355
+ if (currentContext) {
356
+ currentContext.adapter.disconnect();
357
+ currentContext = null;
358
+ }
359
+ }
package/src/index.ts ADDED
@@ -0,0 +1,47 @@
1
+ // Main component and mount function
2
+ export {
3
+ DevToolsPanel,
4
+ mount,
5
+ type MountOptions,
6
+ type DevToolsPanelProps,
7
+ } from "./DevToolsPanel";
8
+
9
+ // Context and stores
10
+ export {
11
+ initDevTools,
12
+ destroyDevTools,
13
+ getContext,
14
+ createConnectionStore,
15
+ createUpdatesStore,
16
+ createSettingsStore,
17
+ type DevToolsContext,
18
+ type SignalUpdate,
19
+ type UpdateTreeNode,
20
+ type UpdateTreeNodeSingle,
21
+ type UpdateTreeNodeGroup,
22
+ type Divider,
23
+ } from "./context";
24
+
25
+ // Types
26
+ export type { GraphNode, GraphLink, GraphData } from "./types";
27
+
28
+ // Components for custom compositions
29
+ export {
30
+ Button,
31
+ EmptyState,
32
+ GraphVisualization,
33
+ Header,
34
+ SettingsPanel,
35
+ StatusIndicator,
36
+ UpdateItem,
37
+ UpdateTreeNodeComponent,
38
+ UpdatesContainer,
39
+ } from "./components";
40
+
41
+ // Re-export adapter types for convenience
42
+ export type {
43
+ DevToolsAdapter,
44
+ Settings,
45
+ ConnectionStatus,
46
+ ConnectionStatusType,
47
+ } from "@preact/signals-devtools-adapter";