@preact/signals-devtools-ui 0.4.1 → 0.4.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@preact/signals-devtools-ui",
3
- "version": "0.4.1",
3
+ "version": "0.4.2",
4
4
  "license": "MIT",
5
5
  "description": "DevTools UI components for @preact/signals",
6
6
  "keywords": [
@@ -54,7 +54,7 @@
54
54
  "typescript": "~5.8.3",
55
55
  "vite": "^7.0.0",
56
56
  "vitest": "^4.0.17",
57
- "@preact/signals": "2.8.0"
57
+ "@preact/signals": "2.8.1"
58
58
  },
59
59
  "publishConfig": {
60
60
  "access": "public",
@@ -44,7 +44,7 @@ export function DevToolsPanel({
44
44
  </button>
45
45
  </div>
46
46
  <div className="tab-content">
47
- {!connectionStore.isConnected ? (
47
+ {!connectionStore.isConnected.value ? (
48
48
  <EmptyState onRefresh={connectionStore.refreshConnection} />
49
49
  ) : (
50
50
  <>
@@ -197,7 +197,7 @@ export function GraphVisualization() {
197
197
  const graphData = useComputed<GraphData>(() => {
198
198
  const rawUpdates = updates.value;
199
199
  const disposed = disposedSignalIds.value;
200
- const showDisposed = settingsStore.showDisposedSignals;
200
+ const showDisposed = settingsStore.showDisposedSignals.value;
201
201
 
202
202
  if (!rawUpdates || rawUpdates.length === 0) return { nodes: [], links: [] };
203
203
 
@@ -31,17 +31,18 @@ export function Header() {
31
31
  <div className="header-title">
32
32
  <h1>Signals</h1>
33
33
  <StatusIndicator
34
- status={connectionStore.status}
35
- message={connectionStore.message}
34
+ status={connectionStore.status.value}
35
+ message={connectionStore.message.value}
36
36
  />
37
37
  </div>
38
38
  <div className="header-controls">
39
39
  <button
40
40
  className="theme-toggle"
41
41
  onClick={themeStore.toggleTheme}
42
- title={`Theme: ${themeLabels[themeStore.theme]}`}
42
+ title={`Theme: ${themeLabels[themeStore.theme.value]}`}
43
43
  >
44
- {themeIcons[themeStore.theme]} {themeLabels[themeStore.theme]}
44
+ {themeIcons[themeStore.theme.value]}{" "}
45
+ {themeLabels[themeStore.theme.value]}
45
46
  </button>
46
47
  {onClear && <Button onClick={onClear}>Clear</Button>}
47
48
  {onTogglePause && (
@@ -9,12 +9,11 @@ export function SettingsPanel() {
9
9
  const popover = useRef<HTMLDivElement>(null);
10
10
 
11
11
  const onApply = settingsStore.applySettings;
12
- const settings = settingsStore.settings;
13
12
 
14
- const localSettings = useSignal<Settings>(settings);
13
+ const localSettings = useSignal<Settings>(settingsStore.settings.value);
15
14
 
16
15
  useSignalEffect(() => {
17
- localSettings.value = settingsStore.settings;
16
+ localSettings.value = settingsStore.settings.value;
18
17
  });
19
18
 
20
19
  const handleApply = () => {
@@ -132,7 +131,7 @@ export function SettingsPanel() {
132
131
  <label>
133
132
  <input
134
133
  type="checkbox"
135
- checked={settingsStore.showDisposedSignals}
134
+ checked={settingsStore.showDisposedSignals.value}
136
135
  onChange={() => settingsStore.toggleShowDisposedSignals()}
137
136
  />
138
137
  Show disposed signals in graph
package/src/context.ts CHANGED
@@ -1,21 +1,25 @@
1
- import { signal, computed, effect } from "@preact/signals";
2
- import type {
3
- DevToolsAdapter,
4
- ConnectionStatus,
5
- ConnectionStatusType,
6
- Settings,
7
- SignalDisposed,
8
- DependencyInfo,
9
- } from "@preact/signals-devtools-adapter";
10
-
11
- export type ThemeMode = "auto" | "light" | "dark";
1
+ import type { DevToolsAdapter } from "@preact/signals-devtools-adapter";
2
+ import { ConnectionModel } from "./models/ConnectionModel";
3
+ import { SettingsModel } from "./models/SettingsModel";
4
+ import { ThemeModel } from "./models/ThemeModel";
5
+ import { UpdatesModel } from "./models/UpdatesModel";
6
+
7
+ export { ConnectionModel, SettingsModel, ThemeModel, UpdatesModel };
8
+ export type {
9
+ SignalUpdate,
10
+ Divider,
11
+ UpdateTreeNode,
12
+ UpdateTreeNodeSingle,
13
+ UpdateTreeNodeGroup,
14
+ } from "./models/UpdatesModel";
15
+ export type { ThemeMode } from "./models/ThemeModel";
12
16
 
13
17
  export interface DevToolsContext {
14
18
  adapter: DevToolsAdapter;
15
- connectionStore: ReturnType<typeof createConnectionStore>;
16
- updatesStore: ReturnType<typeof createUpdatesStore>;
17
- settingsStore: ReturnType<typeof createSettingsStore>;
18
- themeStore: ReturnType<typeof createThemeStore>;
19
+ connectionStore: InstanceType<typeof ConnectionModel>;
20
+ updatesStore: InstanceType<typeof UpdatesModel>;
21
+ settingsStore: InstanceType<typeof SettingsModel>;
22
+ themeStore: InstanceType<typeof ThemeModel>;
19
23
  }
20
24
 
21
25
  let currentContext: DevToolsContext | null = null;
@@ -29,377 +33,11 @@ export function getContext(): DevToolsContext {
29
33
  return currentContext;
30
34
  }
31
35
 
32
- export function createConnectionStore(adapter: DevToolsAdapter) {
33
- const status = signal<ConnectionStatusType>("connecting");
34
- const message = signal<string>("Connecting...");
35
- const isConnected = signal(false);
36
-
37
- // Listen to adapter events
38
- adapter.on(
39
- "connectionStatusChanged",
40
- (connectionStatus: ConnectionStatus) => {
41
- status.value = connectionStatus.status;
42
- message.value = connectionStatus.message;
43
- }
44
- );
45
-
46
- adapter.on("signalsAvailable", (available: boolean) => {
47
- isConnected.value = available;
48
- });
49
-
50
- const refreshConnection = () => {
51
- status.value = "connecting";
52
- message.value = "Connecting...";
53
- adapter.requestState();
54
- };
55
-
56
- return {
57
- get status() {
58
- return status.value;
59
- },
60
- get message() {
61
- return message.value;
62
- },
63
- get isConnected() {
64
- return isConnected.value;
65
- },
66
- refreshConnection,
67
- };
68
- }
69
-
70
- export interface SignalUpdate {
71
- type: "update" | "effect" | "component";
72
- signalType: "signal" | "computed" | "effect" | "component";
73
- signalName: string;
74
- signalId?: string;
75
- prevValue?: any;
76
- newValue?: any;
77
- timestamp?: number;
78
- receivedAt: number;
79
- depth?: number;
80
- subscribedTo?: string;
81
- /** All dependencies this computed/effect currently depends on (with rich info) */
82
- allDependencies?: DependencyInfo[];
83
- }
84
-
85
- export type Divider = { type: "divider" };
86
-
87
- export interface UpdateTreeNodeBase {
88
- id: string;
89
- update: SignalUpdate;
90
- children: UpdateTreeNode[];
91
- depth: number;
92
- hasChildren: boolean;
93
- }
94
-
95
- export interface UpdateTreeNodeSingle extends UpdateTreeNodeBase {
96
- type: "single";
97
- }
98
-
99
- export interface UpdateTreeNodeGroup extends UpdateTreeNodeBase {
100
- type: "group";
101
- count: number;
102
- firstUpdate: SignalUpdate;
103
- firstChildren: UpdateTreeNode[];
104
- }
105
-
106
- export type UpdateTreeNode = UpdateTreeNodeGroup | UpdateTreeNodeSingle;
107
-
108
- const nodesAreEqual = (a: UpdateTreeNode, b: UpdateTreeNode): boolean => {
109
- return (
110
- a.update.signalId === b.update.signalId &&
111
- a.children.length === b.children.length &&
112
- a.children.every((child, index) => nodesAreEqual(child, b.children[index]))
113
- );
114
- };
115
-
116
- const collapseTree = (nodes: UpdateTreeNodeSingle[]): UpdateTreeNode[] => {
117
- const tree: UpdateTreeNode[] = [];
118
- let lastNode: UpdateTreeNode | undefined;
119
-
120
- for (const node of nodes) {
121
- if (lastNode && nodesAreEqual(lastNode, node)) {
122
- if (lastNode.type !== "group") {
123
- tree.pop();
124
- lastNode = {
125
- ...lastNode,
126
- type: "group",
127
- count: 2,
128
- firstUpdate: node.update,
129
- firstChildren: node.children,
130
- };
131
- tree.push(lastNode);
132
- } else {
133
- lastNode.count++;
134
- lastNode.firstUpdate = node.update;
135
- lastNode.firstChildren = node.children;
136
- }
137
- continue;
138
- }
139
- tree.push(node);
140
- lastNode = node;
141
- }
142
-
143
- return tree;
144
- };
145
-
146
- export function createUpdatesStore(
147
- adapter: DevToolsAdapter,
148
- settingsStore: ReturnType<typeof createSettingsStore>
149
- ) {
150
- const updates = signal<(SignalUpdate | Divider)[]>([]);
151
- const isPaused = signal<boolean>(false);
152
- const disposedSignalIds = signal<Set<string>>(new Set());
153
-
154
- const addUpdate = (
155
- update: SignalUpdate | Divider | Array<SignalUpdate | Divider>
156
- ) => {
157
- if (Array.isArray(update)) {
158
- update.forEach(item => {
159
- if (item.type !== "divider") item.receivedAt = Date.now();
160
- });
161
- } else if (update.type === "update") {
162
- update.receivedAt = Date.now();
163
- }
164
- updates.value = [
165
- ...updates.value,
166
- ...(Array.isArray(update) ? update : [update]),
167
- ];
168
- };
169
-
170
- const addDisposal = (disposal: SignalDisposed | SignalDisposed[]) => {
171
- const disposals = Array.isArray(disposal) ? disposal : [disposal];
172
- const newDisposed = new Set(disposedSignalIds.value);
173
- for (const d of disposals) {
174
- if (d.signalId) {
175
- newDisposed.add(d.signalId);
176
- }
177
- }
178
- disposedSignalIds.value = newDisposed;
179
- };
180
-
181
- const hasUpdates = computed(() => updates.value.length > 0);
182
-
183
- const signalCounts = computed(() => {
184
- const counts = new Map<string, number>();
185
- updates.value.forEach(update => {
186
- if (update.type === "divider") return;
187
- const signalName = update.signalName || "Unknown";
188
- counts.set(signalName, (counts.get(signalName) || 0) + 1);
189
- });
190
- return counts;
191
- });
192
-
193
- const updateTree = computed(() => {
194
- const buildTree = (
195
- updates: (SignalUpdate | Divider)[]
196
- ): UpdateTreeNodeSingle[] => {
197
- const tree: UpdateTreeNodeSingle[] = [];
198
- const stack: UpdateTreeNodeSingle[] = [];
199
-
200
- const recentUpdates = updates.slice(-100).reverse();
201
-
202
- for (let i = 0; i < recentUpdates.length; i++) {
203
- const item = recentUpdates[i];
204
-
205
- if (item.type === "divider") {
206
- continue;
207
- }
208
-
209
- const update = item as SignalUpdate;
210
- const depth = update.depth || 0;
211
-
212
- const nodeId = `${update.signalName}-${update.receivedAt}-${i}`;
213
-
214
- const node: UpdateTreeNodeSingle = {
215
- type: "single",
216
- id: nodeId,
217
- update,
218
- children: [],
219
- depth,
220
- hasChildren: false,
221
- };
222
-
223
- while (stack.length > 0 && stack[stack.length - 1].depth >= depth) {
224
- stack.pop();
225
- }
226
-
227
- if (stack.length === 0) {
228
- tree.push(node);
229
- } else {
230
- const parent = stack[stack.length - 1];
231
- parent.children.push(node);
232
- parent.hasChildren = true;
233
- }
234
-
235
- stack.push(node);
236
- }
237
-
238
- return tree;
239
- };
240
-
241
- return buildTree(updates.value);
242
- });
243
-
244
- const clearUpdates = () => {
245
- updates.value = [];
246
- disposedSignalIds.value = new Set();
247
- };
248
-
249
- // Listen to adapter events
250
- adapter.on("signalUpdate", (signalUpdates: SignalUpdate[]) => {
251
- if (isPaused.value) return;
252
-
253
- const updatesArray: Array<SignalUpdate | Divider> = [
254
- ...signalUpdates,
255
- ].reverse();
256
- updatesArray.push({ type: "divider" });
257
-
258
- addUpdate(updatesArray);
259
- });
260
-
261
- // Listen to disposal events
262
- adapter.on("signalDisposed", (disposals: SignalDisposed[]) => {
263
- if (isPaused.value) return;
264
- addDisposal(disposals);
265
- });
266
-
267
- const collapsedUpdateTree = computed(() => {
268
- const updateTreeValue = updateTree.value;
269
- if (settingsStore.settings.grouped) {
270
- return collapseTree(updateTreeValue);
271
- }
272
- return updateTreeValue;
273
- });
274
-
275
- return {
276
- updates,
277
- updateTree,
278
- collapsedUpdateTree,
279
- totalUpdates: computed(() => Object.keys(updateTree.value).length),
280
- signalCounts,
281
- disposedSignalIds,
282
- addUpdate,
283
- clearUpdates,
284
- hasUpdates,
285
- isPaused,
286
- };
287
- }
288
-
289
- export function createSettingsStore(adapter: DevToolsAdapter) {
290
- const settings = signal<Settings>({
291
- enabled: true,
292
- grouped: true,
293
- consoleLogging: true,
294
- maxUpdatesPerSecond: 60,
295
- filterPatterns: [],
296
- });
297
-
298
- const showDisposedSignals = signal<boolean>(false);
299
-
300
- const applySettings = (newSettings: Settings) => {
301
- settings.value = newSettings;
302
- adapter.sendConfig(newSettings);
303
- };
304
-
305
- const toggleShowDisposedSignals = () => {
306
- showDisposedSignals.value = !showDisposedSignals.value;
307
- };
308
-
309
- // Listen to adapter events
310
- adapter.on("configReceived", (config: { settings?: Settings }) => {
311
- if (config.settings) {
312
- settings.value = config.settings;
313
- }
314
- });
315
-
316
- return {
317
- get settings() {
318
- return settings.value;
319
- },
320
- get showDisposedSignals() {
321
- return showDisposedSignals.value;
322
- },
323
- set settings(newSettings: Settings) {
324
- settings.value = newSettings;
325
- },
326
- applySettings,
327
- toggleShowDisposedSignals,
328
- };
329
- }
330
-
331
- const THEME_STORAGE_KEY = "signals-devtools-theme";
332
-
333
- export function createThemeStore() {
334
- const stored = (() => {
335
- try {
336
- const val = localStorage.getItem(THEME_STORAGE_KEY);
337
- if (val === "light" || val === "dark" || val === "auto") return val;
338
- } catch {
339
- // localStorage unavailable
340
- }
341
- return "auto" as ThemeMode;
342
- })();
343
-
344
- const theme = signal<ThemeMode>(stored);
345
-
346
- const mediaQuery =
347
- typeof window !== "undefined"
348
- ? window.matchMedia("(prefers-color-scheme: dark)")
349
- : null;
350
- const systemIsDark = signal(mediaQuery?.matches ?? false);
351
-
352
- if (mediaQuery) {
353
- const handler = (e: MediaQueryListEvent) => {
354
- systemIsDark.value = e.matches;
355
- };
356
- mediaQuery.addEventListener("change", handler);
357
- }
358
-
359
- const resolvedTheme = computed<"light" | "dark">(() =>
360
- theme.value === "auto"
361
- ? systemIsDark.value
362
- ? "dark"
363
- : "light"
364
- : theme.value
365
- );
366
-
367
- // Apply data-theme attribute to the devtools container
368
- effect(() => {
369
- const resolved = resolvedTheme.value;
370
- const el = document.querySelector(".signals-devtools");
371
- if (el instanceof HTMLElement) {
372
- el.dataset.theme = resolved;
373
- }
374
- });
375
-
376
- const toggleTheme = () => {
377
- const order: ThemeMode[] = ["auto", "light", "dark"];
378
- const idx = order.indexOf(theme.value);
379
- theme.value = order[(idx + 1) % order.length];
380
- try {
381
- localStorage.setItem(THEME_STORAGE_KEY, theme.value);
382
- } catch {
383
- // localStorage unavailable
384
- }
385
- };
386
-
387
- return {
388
- get theme() {
389
- return theme.value;
390
- },
391
- get resolvedTheme() {
392
- return resolvedTheme.value;
393
- },
394
- toggleTheme,
395
- };
396
- }
397
-
398
36
  export function initDevTools(adapter: DevToolsAdapter): DevToolsContext {
399
- const settingsStore = createSettingsStore(adapter);
400
- const updatesStore = createUpdatesStore(adapter, settingsStore);
401
- const connectionStore = createConnectionStore(adapter);
402
- const themeStore = createThemeStore();
37
+ const settingsStore = new SettingsModel(adapter);
38
+ const updatesStore = new UpdatesModel(adapter, settingsStore);
39
+ const connectionStore = new ConnectionModel(adapter);
40
+ const themeStore = new ThemeModel();
403
41
 
404
42
  currentContext = {
405
43
  adapter,
@@ -414,6 +52,10 @@ export function initDevTools(adapter: DevToolsAdapter): DevToolsContext {
414
52
 
415
53
  export function destroyDevTools(): void {
416
54
  if (currentContext) {
55
+ currentContext.connectionStore[Symbol.dispose]();
56
+ currentContext.updatesStore[Symbol.dispose]();
57
+ currentContext.settingsStore[Symbol.dispose]();
58
+ currentContext.themeStore[Symbol.dispose]();
417
59
  currentContext.adapter.disconnect();
418
60
  currentContext = null;
419
61
  }
package/src/index.ts CHANGED
@@ -11,9 +11,9 @@ export {
11
11
  initDevTools,
12
12
  destroyDevTools,
13
13
  getContext,
14
- createConnectionStore,
15
- createUpdatesStore,
16
- createSettingsStore,
14
+ ConnectionModel,
15
+ UpdatesModel,
16
+ SettingsModel,
17
17
  type DevToolsContext,
18
18
  type SignalUpdate,
19
19
  type UpdateTreeNode,
@@ -0,0 +1,38 @@
1
+ import { signal, createModel } from "@preact/signals";
2
+ import type {
3
+ DevToolsAdapter,
4
+ ConnectionStatus,
5
+ ConnectionStatusType,
6
+ } from "@preact/signals-devtools-adapter";
7
+
8
+ export const ConnectionModel = createModel((adapter: DevToolsAdapter) => {
9
+ const status = signal<ConnectionStatusType>("connecting");
10
+ const message = signal<string>("Connecting...");
11
+ const isConnected = signal(false);
12
+
13
+ // Listen to adapter events
14
+ adapter.on(
15
+ "connectionStatusChanged",
16
+ (connectionStatus: ConnectionStatus) => {
17
+ status.value = connectionStatus.status;
18
+ message.value = connectionStatus.message;
19
+ }
20
+ );
21
+
22
+ adapter.on("signalsAvailable", (available: boolean) => {
23
+ isConnected.value = available;
24
+ });
25
+
26
+ const refreshConnection = () => {
27
+ status.value = "connecting";
28
+ message.value = "Connecting...";
29
+ adapter.requestState();
30
+ };
31
+
32
+ return {
33
+ status,
34
+ message,
35
+ isConnected,
36
+ refreshConnection,
37
+ };
38
+ });
@@ -0,0 +1,40 @@
1
+ import { signal, createModel } from "@preact/signals";
2
+ import type {
3
+ DevToolsAdapter,
4
+ Settings,
5
+ } from "@preact/signals-devtools-adapter";
6
+
7
+ export const SettingsModel = createModel((adapter: DevToolsAdapter) => {
8
+ const settings = signal<Settings>({
9
+ enabled: true,
10
+ grouped: true,
11
+ consoleLogging: true,
12
+ maxUpdatesPerSecond: 60,
13
+ filterPatterns: [],
14
+ });
15
+
16
+ const showDisposedSignals = signal<boolean>(false);
17
+
18
+ const applySettings = (newSettings: Settings) => {
19
+ settings.value = newSettings;
20
+ adapter.sendConfig(newSettings);
21
+ };
22
+
23
+ const toggleShowDisposedSignals = () => {
24
+ showDisposedSignals.value = !showDisposedSignals.value;
25
+ };
26
+
27
+ // Listen to adapter events
28
+ adapter.on("configReceived", (config: { settings?: Settings }) => {
29
+ if (config.settings) {
30
+ settings.value = config.settings;
31
+ }
32
+ });
33
+
34
+ return {
35
+ settings,
36
+ showDisposedSignals,
37
+ applySettings,
38
+ toggleShowDisposedSignals,
39
+ };
40
+ });
@@ -0,0 +1,70 @@
1
+ import { signal, computed, effect, createModel } from "@preact/signals";
2
+
3
+ export type ThemeMode = "auto" | "light" | "dark";
4
+
5
+ const THEME_STORAGE_KEY = "signals-devtools-theme";
6
+
7
+ export const ThemeModel = createModel(() => {
8
+ const stored = (() => {
9
+ try {
10
+ const val = localStorage.getItem(THEME_STORAGE_KEY);
11
+ if (val === "light" || val === "dark" || val === "auto") return val;
12
+ } catch {
13
+ // localStorage unavailable
14
+ }
15
+ return "auto" as ThemeMode;
16
+ })();
17
+
18
+ const theme = signal<ThemeMode>(stored);
19
+
20
+ const mediaQuery =
21
+ typeof window !== "undefined"
22
+ ? window.matchMedia("(prefers-color-scheme: dark)")
23
+ : null;
24
+ const systemIsDark = signal(mediaQuery?.matches ?? false);
25
+
26
+ effect(() => {
27
+ if (!mediaQuery) return;
28
+
29
+ const handler = (e: MediaQueryListEvent) => {
30
+ systemIsDark.value = e.matches;
31
+ };
32
+
33
+ mediaQuery.addEventListener("change", handler);
34
+ return () => mediaQuery.removeEventListener("change", handler);
35
+ });
36
+
37
+ const resolvedTheme = computed<"light" | "dark">(() =>
38
+ theme.value === "auto"
39
+ ? systemIsDark.value
40
+ ? "dark"
41
+ : "light"
42
+ : theme.value
43
+ );
44
+
45
+ // Apply data-theme attribute to the devtools container
46
+ effect(() => {
47
+ const resolved = resolvedTheme.value;
48
+ const el = document.querySelector(".signals-devtools");
49
+ if (el instanceof HTMLElement) {
50
+ el.dataset.theme = resolved;
51
+ }
52
+ });
53
+
54
+ const toggleTheme = () => {
55
+ const order: ThemeMode[] = ["auto", "light", "dark"];
56
+ const idx = order.indexOf(theme.value);
57
+ theme.value = order[(idx + 1) % order.length];
58
+ try {
59
+ localStorage.setItem(THEME_STORAGE_KEY, theme.value);
60
+ } catch {
61
+ // localStorage unavailable
62
+ }
63
+ };
64
+
65
+ return {
66
+ theme,
67
+ resolvedTheme,
68
+ toggleTheme,
69
+ };
70
+ });