@praxisjs/devtools 0.2.34 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/assets/uno.generated.css +5 -0
  3. package/dist/assets/logo.svg.js +4 -0
  4. package/dist/assets/tailwind-v4.css.js +4 -0
  5. package/dist/assets/uno.generated.css.js +4 -0
  6. package/dist/core/event-bus.js +21 -0
  7. package/dist/core/profiler.js +79 -0
  8. package/dist/core/registry.js +147 -0
  9. package/dist/decorators/debug.js +126 -0
  10. package/dist/decorators/trace.js +45 -0
  11. package/dist/icons/ellipsis-vertical.js +38 -0
  12. package/dist/icons/panel-bottom.js +28 -0
  13. package/dist/icons/panel-left.js +28 -0
  14. package/dist/icons/panel-right.js +28 -0
  15. package/dist/icons/panel-top.js +28 -0
  16. package/dist/icons/x.js +22 -0
  17. package/dist/index.d.ts +96 -3
  18. package/dist/index.js +10 -1157
  19. package/dist/plugins/components/components/component-detail.js +53 -0
  20. package/dist/plugins/components/components/component-row.js +22 -0
  21. package/dist/plugins/components/components/detail-row.js +15 -0
  22. package/dist/plugins/components/components/detail-section.js +14 -0
  23. package/dist/plugins/components/components/status-dot.js +15 -0
  24. package/dist/plugins/components/components-tab.js +54 -0
  25. package/dist/plugins/components/index.js +9 -0
  26. package/dist/plugins/performance/index.js +9 -0
  27. package/dist/plugins/performance/performance-tab.js +62 -0
  28. package/dist/plugins/signals/components/signal-detail.js +29 -0
  29. package/dist/plugins/signals/components/signal-row.js +17 -0
  30. package/dist/plugins/signals/index.js +9 -0
  31. package/dist/plugins/signals/signals-tab.js +53 -0
  32. package/dist/plugins/timeline/components/badge.js +16 -0
  33. package/dist/plugins/timeline/components/timeline-row.js +33 -0
  34. package/dist/plugins/timeline/constants.js +54 -0
  35. package/dist/plugins/timeline/index.js +9 -0
  36. package/dist/plugins/timeline/timeline-tab.js +46 -0
  37. package/dist/ui/dev-tools.js +74 -0
  38. package/dist/ui/panel.js +106 -0
  39. package/dist/ui/shared/empty-state.js +14 -0
  40. package/dist/ui/shared/icon-button.js +17 -0
  41. package/dist/ui/shared/panel-section.js +14 -0
  42. package/dist/ui/shared/search-input.js +17 -0
  43. package/dist/ui/shared/side-panel.js +15 -0
  44. package/dist/utils/format-time.js +7 -0
  45. package/dist/utils/format-value.js +14 -0
  46. package/package.json +14 -6
  47. package/src/core/profiler.ts +162 -0
  48. package/src/core/registry.ts +23 -21
  49. package/src/decorators/debug.ts +96 -32
  50. package/src/decorators/index.ts +1 -0
  51. package/src/decorators/trace.ts +27 -4
  52. package/src/index.ts +5 -0
  53. package/src/plugins/performance/index.ts +9 -0
  54. package/src/plugins/performance/performance-tab.tsx +111 -0
  55. package/src/ui/dev-tools.tsx +5 -0
  56. package/src/__tests__/debug.test.ts +0 -333
  57. package/src/__tests__/event-bus.test.ts +0 -123
  58. package/src/__tests__/format-time.test.ts +0 -52
  59. package/src/__tests__/format-value.test.ts +0 -46
  60. package/src/__tests__/plugins.test.ts +0 -101
  61. package/src/__tests__/registry.test.ts +0 -386
  62. package/src/__tests__/trace.test.ts +0 -200
  63. package/tsconfig.json +0 -21
  64. package/uno.config.ts +0 -57
  65. package/vite.config.ts +0 -38
@@ -1,4 +1,4 @@
1
- import { computed } from "@praxisjs/core/internal";
1
+ import { computed, effect, untrack } from "@praxisjs/core/internal";
2
2
  import {
3
3
  createMethodDecorator,
4
4
  createGetterObserverDecorator,
@@ -8,8 +8,23 @@ import {
8
8
 
9
9
  import { Registry } from "../core/registry";
10
10
 
11
+ // Read inline (not through a helper): bundlers replace process.env.NODE_ENV at build time, and only a
12
+ // literal check lets them drop the instrumentation, and the registry it uses, from production builds.
13
+ declare const process: { env: { NODE_ENV?: string } };
14
+
11
15
  export interface DebugOptions {
16
+ /** Name shown in the panel. Defaults to the member name. */
12
17
  label?: string;
18
+ /**
19
+ * Also logs every change (fields, getters) or call (methods) to the console. `"trace"` logs with
20
+ * a stack trace, which shows what caused it.
21
+ */
22
+ log?: boolean | "trace";
23
+ /**
24
+ * Pauses in the debugger on a change or call. A function decides per change: it receives the new
25
+ * and the previous value (for methods, the arguments and `undefined`).
26
+ */
27
+ break?: boolean | ((next: unknown, prev: unknown) => boolean);
13
28
  }
14
29
 
15
30
  // Duck-type check: callable with .subscribe but no .set → Computed
@@ -37,11 +52,48 @@ interface DebugDecorator {
37
52
  (value: undefined, context: ClassFieldDecoratorContext): void;
38
53
  }
39
54
 
55
+ function componentNameOf(instance: object): string {
56
+ return (instance.constructor as { name: string }).name;
57
+ }
58
+
59
+ function isThenable(value: unknown): value is PromiseLike<unknown> {
60
+ return (
61
+ (typeof value === "object" || typeof value === "function") &&
62
+ value !== null &&
63
+ typeof (value as { then?: unknown }).then === "function"
64
+ );
65
+ }
66
+
67
+ /** Console logging and debugger pauses requested through the options. */
68
+ function makeReporter(options: DebugOptions) {
69
+ const { log, break: pause } = options;
70
+ if (!log && !pause) return null;
71
+ return (what: string, next: unknown, prev: unknown): void => {
72
+ untrack(() => {
73
+ if (log === "trace") console.trace(`[PraxisJS] ${what}`, next, prev);
74
+ else if (log) console.log(`[PraxisJS] ${what}`, next, prev);
75
+ if (pause === true || (typeof pause === "function" && pause(next, prev))) {
76
+ debugger;
77
+ }
78
+ });
79
+ };
80
+ }
81
+
40
82
  /**
41
- * Tracks state, computed values, and methods in the devtools panel.
42
- * Supports @State() fields, @Computed() getters, and methods.
83
+ * Tracks state, props, computed values and methods in the devtools panel.
84
+ *
85
+ * - Fields with an accessor (`@State`, `@Prop`, `@Persisted`, …) are observed reactively, so a
86
+ * change is reported whatever caused it, including a parent passing a new prop.
87
+ * - Fields holding a `computed()` report each new value.
88
+ * - `@Computed()` getters report each new value.
89
+ * - Methods report every call with its arguments, result and duration; for a method that returns a
90
+ * promise, when the promise settles.
43
91
  */
44
92
  export function Debug(options: DebugOptions = {}): DebugDecorator {
93
+ // Production builds keep the member exactly as declared: no wrapper, no observation.
94
+ if (process.env.NODE_ENV === "production") return (() => undefined) as DebugDecorator;
95
+ const report = makeReporter(options);
96
+
45
97
  const impl = function (
46
98
  value: unknown,
47
99
  context:
@@ -56,23 +108,28 @@ export function Debug(options: DebugOptions = {}): DebugDecorator {
56
108
  createMethodDecorator({
57
109
  wrap(_original, _instance, _name) {
58
110
  return function (this: object, ...args: unknown[]) {
59
- const componentName = (this.constructor as { name: string }).name;
111
+ const componentName = componentNameOf(this);
112
+ report?.(`${componentName}.${label}()`, args, undefined);
60
113
  const start = performance.now();
114
+ const record = (result: unknown): void => {
115
+ Registry.instance.recordMethodCall(
116
+ this, label, args, result, performance.now() - start, componentName,
117
+ );
118
+ };
61
119
  let result: unknown;
62
- let threw = false;
63
120
  try {
64
121
  result = original.apply(this, args);
65
122
  } catch (err) {
66
- threw = true;
67
- result = err;
123
+ record(`throw ${String(err)}`);
68
124
  throw err;
69
- } finally {
70
- const duration = performance.now() - start;
71
- Registry.instance.recordMethodCall(
72
- this, label, args,
73
- threw ? `throw ${String(result)}` : result,
74
- duration, componentName,
125
+ }
126
+ if (isThenable(result)) {
127
+ result.then(
128
+ (resolved) => { record(resolved); },
129
+ (err: unknown) => { record(`reject ${String(err)}`); },
75
130
  );
131
+ } else {
132
+ record(result);
76
133
  }
77
134
  return result;
78
135
  };
@@ -85,7 +142,7 @@ export function Debug(options: DebugOptions = {}): DebugDecorator {
85
142
  const originalGetter = value as (this: object) => unknown;
86
143
  createGetterObserverDecorator({
87
144
  observe(getter, instance, _name) {
88
- const componentName = (instance.constructor as { name: string }).name;
145
+ const componentName = componentNameOf(instance);
89
146
  // Defer so field initializers (e.g. @State()) have run before we read them.
90
147
  queueMicrotask(() => {
91
148
  const c = computed(() => getter.call(instance));
@@ -95,6 +152,7 @@ export function Debug(options: DebugOptions = {}): DebugDecorator {
95
152
  c.subscribe((newValue) => {
96
153
  if (skipFirst) { skipFirst = false; return; }
97
154
  Registry.instance.updateSignal(instance, label, newValue, prevValue);
155
+ report?.(`${componentName}.${label}`, newValue, prevValue);
98
156
  prevValue = newValue;
99
157
  });
100
158
  });
@@ -105,26 +163,30 @@ export function Debug(options: DebugOptions = {}): DebugDecorator {
105
163
 
106
164
  createFieldDecorator({
107
165
  bind(instance, name, _initialValue): FieldBinding {
108
- const componentName = (instance.constructor as { name: string }).name;
166
+ const componentName = componentNameOf(instance);
109
167
 
110
- // @State() runs inner-first, so its getter/setter is already on the instance.
168
+ // @State(), @Prop() and friends run inner-first, so their accessor is already on the
169
+ // instance. Reading it in an effect reports every change, whoever made it.
111
170
  const existingDesc = Object.getOwnPropertyDescriptor(instance, name);
112
- if (existingDesc?.get && existingDesc.set) {
113
- // eslint-disable-next-line @typescript-eslint/unbound-method
114
- const originalGet = existingDesc.get;
115
- // eslint-disable-next-line @typescript-eslint/unbound-method
116
- const originalSet = existingDesc.set;
117
- Registry.instance.registerSignal(instance, label, originalGet.call(instance), componentName);
118
- return {
119
- descriptor: {
120
- get(this: object) { return originalGet.call(this) as unknown; },
121
- set(this: object, newValue: unknown) {
122
- const oldValue: unknown = originalGet.call(this);
123
- originalSet.call(this, newValue);
124
- Registry.instance.updateSignal(this, label, newValue, oldValue);
125
- },
126
- },
127
- };
171
+ if (existingDesc?.get !== undefined) {
172
+ const read = (): unknown => (instance as unknown as Record<string, unknown>)[name];
173
+ let prev: unknown;
174
+ let first = true;
175
+ const stop = effect(() => {
176
+ const next = read();
177
+ if (first) {
178
+ first = false;
179
+ prev = next;
180
+ untrack(() => { Registry.instance.registerSignal(instance, label, next, componentName); });
181
+ return;
182
+ }
183
+ if (Object.is(next, prev)) return;
184
+ const old = prev;
185
+ prev = next;
186
+ untrack(() => { Registry.instance.updateSignal(instance, label, next, old); });
187
+ report?.(`${componentName}.${label}`, next, old);
188
+ });
189
+ return { onUnmount: stop };
128
190
  }
129
191
 
130
192
  const initialValue = _initialValue;
@@ -148,6 +210,7 @@ export function Debug(options: DebugOptions = {}): DebugDecorator {
148
210
  const unsub = comp.subscribe((newValue) => {
149
211
  if (skipFirst) { skipFirst = false; return; }
150
212
  Registry.instance.updateSignal(instance, label, newValue, prevValue);
213
+ report?.(`${componentName}.${label}`, newValue, prevValue);
151
214
  prevValue = newValue;
152
215
  });
153
216
  Registry.instance.registerSignal(instance, label, prevValue, componentName);
@@ -171,6 +234,7 @@ export function Debug(options: DebugOptions = {}): DebugDecorator {
171
234
  slot = { computed: newValue, unsub: subscribe(newValue) };
172
235
  },
173
236
  },
237
+ onUnmount() { slot.unsub(); },
174
238
  };
175
239
  },
176
240
  })(undefined, context);
@@ -1,3 +1,4 @@
1
1
  export { Debug } from './debug';
2
2
  export type { DebugOptions } from './debug';
3
3
  export { Trace } from './trace';
4
+ export type { TraceOptions } from './trace';
@@ -7,6 +7,10 @@ import {
7
7
 
8
8
  import { Registry } from "../core/registry";
9
9
 
10
+ // Read inline (not through a helper): bundlers replace process.env.NODE_ENV at build time, and only a
11
+ // literal check lets them drop the instrumentation, and the registry it uses, from production builds.
12
+ declare const process: { env: { NODE_ENV?: string } };
13
+
10
14
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
11
15
  type AnyConstructor = new (...args: any[]) => any;
12
16
 
@@ -16,13 +20,23 @@ const EXTRA_HOOKS = ["onBeforeUpdate", "onUpdate", "onAfterUpdate"] as const;
16
20
  * Instruments a component class to report renders and lifecycle events
17
21
  * to the devtools panel.
18
22
  *
19
- * @Trace()
23
+ * @Trace({ slow: 4 }) // also warn when a render takes more than 4 ms
20
24
  * @Component()
21
25
  * class MyComponent extends StatefulComponent { ... }
22
26
  */
27
+ export interface TraceOptions {
28
+ /** Warns in the console when a render of this component takes longer than this many ms. */
29
+ slow?: number;
30
+ }
31
+
23
32
  class TraceBehavior extends ClassBehavior {
33
+ constructor(private readonly slow: number | undefined) {
34
+ super();
35
+ }
36
+
24
37
  create(instance: RootComponent): ClassEnhancement {
25
38
  const registry = Registry.instance;
39
+ const slow = this.slow;
26
40
 
27
41
  return {
28
42
  onMount() {
@@ -36,6 +50,11 @@ class TraceBehavior extends ClassBehavior {
36
50
  const result = originalRender();
37
51
  const duration = performance.now() - start;
38
52
  registry.recordRender(instance, duration);
53
+ if (slow !== undefined && duration > slow) {
54
+ console.warn(
55
+ `[PraxisJS DevTools] <${instance.constructor.name}> rendered in ${duration.toFixed(1)} ms (over ${String(slow)} ms).`,
56
+ );
57
+ }
39
58
  return result;
40
59
  },
41
60
  };
@@ -64,8 +83,12 @@ class TraceBehavior extends ClassBehavior {
64
83
  }
65
84
  }
66
85
 
67
- const traceBehavior = new TraceBehavior();
86
+ // Created on first use: a module-level instance would keep this class and the registry in
87
+ // production bundles, where @Trace() does nothing.
88
+ let defaultBehavior: TraceBehavior | undefined;
68
89
 
69
- export function Trace() {
70
- return createClassDecorator(traceBehavior);
90
+ export function Trace(options: TraceOptions = {}) {
91
+ // Production builds keep the class exactly as declared.
92
+ if (process.env.NODE_ENV === "production") return <T>(constructor: T, _context: ClassDecoratorContext): T => constructor;
93
+ return createClassDecorator(options.slow === undefined ? (defaultBehavior ??= new TraceBehavior(undefined)) : new TraceBehavior(options.slow));
71
94
  }
package/src/index.ts CHANGED
@@ -3,6 +3,7 @@ export type { DevToolsOptions } from "@/ui/dev-tools";
3
3
 
4
4
  export { Debug, Trace } from "@/decorators";
5
5
  export type { DebugOptions } from "@/decorators/debug";
6
+ export type { TraceOptions } from "@/decorators/trace";
6
7
 
7
8
  export { Registry } from "@/core/registry";
8
9
  export type {
@@ -18,3 +19,7 @@ export type { DevtoolsPlugin } from "@/plugins/types";
18
19
  export { SignalsPlugin } from "@/plugins/signals";
19
20
  export { ComponentsPlugin } from "@/plugins/components";
20
21
  export { TimelinePlugin } from "@/plugins/timeline";
22
+ export { PerformancePlugin } from "@/plugins/performance";
23
+
24
+ export { Profiler } from "@/core/profiler";
25
+ export type { ProfileSnapshot, OwnerStats } from "@/core/profiler";
@@ -0,0 +1,9 @@
1
+ import { PerformanceTab } from "./performance-tab";
2
+
3
+ import type { DevtoolsPlugin } from "@plugins/types";
4
+
5
+ export const PerformancePlugin: DevtoolsPlugin = {
6
+ id: "performance",
7
+ label: "Performance",
8
+ component: PerformanceTab,
9
+ };
@@ -0,0 +1,111 @@
1
+ import { EmptyState } from "@shared/empty-state";
2
+
3
+ import { StatefulComponent } from "@praxisjs/core";
4
+ import { Component, Prop, State } from "@praxisjs/decorators";
5
+ import { For } from "@praxisjs/runtime";
6
+
7
+ import type { ProfileSnapshot } from "@core/profiler";
8
+ import type { Registry } from "@core/registry";
9
+
10
+ const BUTTON =
11
+ "text-[11px] px-2 py-[3px] rounded cursor-pointer font-sans border border-border text-muted hover:text-text transition-colors duration-150";
12
+
13
+ function ms(value: number): string {
14
+ return `${value.toFixed(value < 10 ? 2 : 1)} ms`;
15
+ }
16
+
17
+ @Component()
18
+ export class PerformanceTab extends StatefulComponent {
19
+ @State() snapshot: ProfileSnapshot | null = null;
20
+ @Prop() registry!: Registry;
21
+
22
+ private _off: (() => void) | null = null;
23
+
24
+ onMount() {
25
+ const { profiler } = this.registry;
26
+ this.refresh();
27
+ this._off = profiler.bus.on("profile:changed", () => { this.refresh(); });
28
+ }
29
+
30
+ onUnmount() {
31
+ this._off?.();
32
+ }
33
+
34
+ // The panel's own updates are kept out of the recording.
35
+ refresh() {
36
+ const { profiler } = this.registry;
37
+ profiler.muted(() => { this.snapshot = profiler.snapshot(); });
38
+ }
39
+
40
+ render() {
41
+ const { profiler } = this.registry;
42
+ return (
43
+ <div class="flex flex-col h-full overflow-hidden">
44
+ <div class="flex items-center gap-[6px] px-2 py-2 border-b border-border bg-bg shrink-0">
45
+ <button
46
+ class={() => `${BUTTON} ${this.snapshot?.recording ? "text-warn border-warn" : ""}`}
47
+ onClick={() => {
48
+ if (profiler.isRecording) profiler.stop();
49
+ else profiler.start();
50
+ }}
51
+ >
52
+ {() => (this.snapshot?.recording ? "Stop" : "Record")}
53
+ </button>
54
+ <button class={BUTTON} onClick={() => { profiler.reset(); }}>
55
+ Reset
56
+ </button>
57
+ <div class="flex-1" />
58
+ <span class="text-[10px] text-subtle font-mono">
59
+ {() => (this.snapshot ? `${(this.snapshot.duration / 1000).toFixed(1)} s` : "")}
60
+ </span>
61
+ </div>
62
+
63
+ <div class="grid grid-cols-4 gap-px bg-border border-b border-border shrink-0">
64
+ {() => {
65
+ const s = this.snapshot;
66
+ const cells: Array<[string, string]> = s
67
+ ? [
68
+ ["Writes", String(s.writes)],
69
+ ["Effect runs", String(s.effectRuns)],
70
+ ["Effect time", ms(s.effectMs)],
71
+ ["Computed runs", `${String(s.computeRuns)} · ${ms(s.computeMs)}`],
72
+ ]
73
+ : [];
74
+ return cells.map(([label, value]) => (
75
+ <div class="bg-bg px-3 py-2">
76
+ <div class="text-[9px] text-subtle font-bold tracking-[0.12em] uppercase">{label}</div>
77
+ <div class="text-[13px] text-text font-mono">{value}</div>
78
+ </div>
79
+ ));
80
+ }}
81
+ </div>
82
+
83
+ <div class="grid grid-cols-[1.4fr_0.6fr_0.8fr_0.8fr] items-center px-3 h-7 text-[9px] text-subtle font-bold tracking-[0.12em] uppercase border-b border-border bg-section gap-2 shrink-0">
84
+ <span>Component</span>
85
+ <span>Runs</span>
86
+ <span>Time</span>
87
+ <span>Slowest</span>
88
+ </div>
89
+
90
+ <div class="flex-1 overflow-y-auto">
91
+ <For
92
+ each={() => this.snapshot?.owners ?? []}
93
+ by="name"
94
+ fallback={
95
+ <EmptyState message="Press Record, use your app, then Stop. Bindings and effects are grouped by the component that created them." />
96
+ }
97
+ >
98
+ {(owner) => (
99
+ <div class="grid grid-cols-[1.4fr_0.6fr_0.8fr_0.8fr] items-center px-3 h-7 text-[11px] border-b border-border gap-2 font-mono">
100
+ <span class="text-accent truncate">{() => owner().name}</span>
101
+ <span class="text-text">{() => String(owner().effectRuns)}</span>
102
+ <span class="text-text">{() => ms(owner().effectMs)}</span>
103
+ <span class="text-muted">{() => ms(owner().maxMs)}</span>
104
+ </div>
105
+ )}
106
+ </For>
107
+ </div>
108
+ </div>
109
+ );
110
+ }
111
+ }
@@ -2,6 +2,7 @@ import logo from "@assets/logo.svg";
2
2
  import unoStyles from "@assets/uno.generated.css?inline";
3
3
  import { Registry } from "@core/registry";
4
4
  import { ComponentsPlugin } from "@plugins/components";
5
+ import { PerformancePlugin } from "@plugins/performance";
5
6
  import { SignalsPlugin } from "@plugins/signals";
6
7
  import { TimelinePlugin } from "@plugins/timeline";
7
8
  import unoReset from "@unocss/reset/tailwind-v4.css?inline";
@@ -18,6 +19,7 @@ const DEFAULT_PLUGINS: DevtoolsPlugin[] = [
18
19
  SignalsPlugin,
19
20
  ComponentsPlugin,
20
21
  TimelinePlugin,
22
+ PerformancePlugin,
21
23
  ];
22
24
 
23
25
  export interface DevToolsOptions {
@@ -75,6 +77,9 @@ export const DevTools = {
75
77
 
76
78
  this.plugins.forEach((p) => p.setup?.(Registry.instance));
77
79
 
80
+ // Reachable from the browser console: __PRAXIS_DEVTOOLS__.profiler.start(), .getSignals(), …
81
+ (globalThis as { __PRAXIS_DEVTOOLS__?: Registry }).__PRAXIS_DEVTOOLS__ = Registry.instance;
82
+
78
83
  const host = document.createElement("div");
79
84
  host.id = "praxisjs-devtools";
80
85
  document.body.appendChild(host);