Skip to content

Commit af67bf5

Browse files
committed
feat(debug): add trace and customFormatter
1 parent 0730d05 commit af67bf5

11 files changed

Lines changed: 285 additions & 22 deletions

.export-size-svg.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export default defineConfig({
2626
{
2727
title: "*",
2828
code: "export * from './src/index.ts'",
29+
externals: ["./dev"],
2930
},
3031
{
3132
title: "{ readable, writable } (core)",

mangle-cache.json

Lines changed: 16 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,18 @@
11
{
2-
"addDep_": "d",
3-
"batching_": "c",
4-
"data_": "a",
5-
"delete_": "l",
6-
"dependents_": "n",
7-
"deps_": "p",
8-
"equal_": "e",
9-
"multi_": "m",
10-
"notify_": "y",
11-
"onDisposeValue_": "v",
12-
"onReaction_": "o",
13-
"single_": "s",
14-
"task_": "k",
15-
"tasks_": "t",
16-
"upsert_": "u"
2+
"addDep_": "_d",
3+
"batching_": "_c",
4+
"data_": "_a",
5+
"delete_": "_l",
6+
"dependents_": "_n",
7+
"deps_": "_p",
8+
"equal_": "_e",
9+
"multi_": "_m",
10+
"notify_": "_y",
11+
"onDisposeValue_": "_v",
12+
"onReaction_": "_o",
13+
"single_": "_s",
14+
"task_": "_k",
15+
"tasks_": "_t",
16+
"upsert_": "_u",
17+
"watchDebug_": "_w"
1718
}

src/compute.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { ReadableImpl } from "./readable";
22
import { type Config, type Get, type OwnedReadable, type Readable } from "./typings";
33
import { isReadable } from "./utils";
44

5-
export interface ComputeFn<TValue> {
5+
export interface ComputeFn<TValue = any> {
66
(get: Get): TValue;
77
}
88

src/dev/customFormatter.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
import { isReadable, isWritable } from "../utils";
22

3-
export function initCustomFormatter(): void {
3+
/**
4+
* Enables custom formatting for Readable and Writable objects in Chrome DevTools.
5+
* It is enabled in development by default.
6+
*
7+
* @see {@link https://www.mattzeunert.com/2016/02/19/custom-chrome-devtools-object-formatters.html}
8+
*/
9+
export function customFormatter(): void {
410
if (typeof window === "undefined") {
511
return;
612
}

src/dev/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * from "./customFormatter";
2+
export * from "./trace";

src/dev/trace.ts

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
import { type ComputeFn } from "../compute";
2+
import { type Get, type Readable } from "../typings";
3+
import { isReadable, isWritable } from "../utils";
4+
import { type WatchEffect } from "../watch";
5+
6+
export interface TraceConfig {
7+
name?: string;
8+
/** @default true */
9+
printStack?: boolean;
10+
/** @default true */
11+
defaultExpand?: boolean;
12+
}
13+
14+
export interface Trace {
15+
<T extends Readable>($: T, config?: TraceConfig): T;
16+
(effect: WatchEffect, config?: TraceConfig): WatchEffect;
17+
(fn: ComputeFn, config?: TraceConfig): ComputeFn;
18+
}
19+
20+
/**
21+
* Console logs trace information about the provided Readable or WatchEffect.
22+
* @example
23+
* ```ts
24+
* import { trace, writable, watch } from "@embra";
25+
* const count$ = writable(0);
26+
*
27+
* trace(count$);
28+
*
29+
* watch(() => count$.value++);
30+
*
31+
* count$.set(1);
32+
* ```
33+
*/
34+
export const trace: Trace = (x: unknown, config?: TraceConfig): any => {
35+
if (isReadable(x)) {
36+
return traceReadable(x, config);
37+
}
38+
if (typeof x === "function") {
39+
return traceWatch(x as ComputeFn | WatchEffect, config);
40+
}
41+
throw new TypeError("trace expects a Readable, WatchEffect, or ComputeFn as the first argument");
42+
};
43+
44+
interface Info {
45+
value: any;
46+
version: number;
47+
}
48+
49+
class Deps {
50+
public oldDeps = new Map<Readable, Info>();
51+
public newDeps = new Map<Readable, Info>();
52+
53+
private readonly added: [Readable, Info][] = [];
54+
private readonly updated: [Readable, Info][] = [];
55+
private readonly unchanged: [Readable, Info][] = [];
56+
private readonly removed: [Readable, Info][] = [];
57+
58+
public print(): void {
59+
for (const [dep, info] of this.newDeps) {
60+
const oldInfo = this.oldDeps.get(dep);
61+
if (oldInfo) {
62+
if (oldInfo.version !== info.version) {
63+
this.updated.push([dep, info]);
64+
} else {
65+
this.unchanged.push([dep, info]);
66+
}
67+
} else {
68+
this.added.push([dep, info]);
69+
}
70+
}
71+
72+
for (const [dep, info] of this.oldDeps) {
73+
if (!this.newDeps.has(dep)) {
74+
this.removed.push([dep, info]);
75+
}
76+
}
77+
78+
for (const [dep, info] of this.added) {
79+
console.log(`\x1b[32m(+)\x1b[0m ${this.getDepName(dep)} ->`, info.value);
80+
}
81+
for (const [dep, info] of this.updated) {
82+
console.log(
83+
`\x1b[33m(*)\x1b[0m ${this.getDepName(dep)} ->`,
84+
info.value,
85+
`<- previous:`,
86+
this.oldDeps.get(dep)?.value,
87+
);
88+
}
89+
for (const [dep, info] of this.unchanged) {
90+
console.log(`\x1b[37m(=) ${this.getDepName(dep)}\x1b[0m ->`, info.value);
91+
}
92+
for (const [dep, info] of this.removed) {
93+
console.log(`\x1b[31m(-)\x1b[0m \x1b[9m${this.getDepName(dep)}\x1b[0m ->`, info.value);
94+
}
95+
96+
this.added.length = this.updated.length = this.unchanged.length = this.removed.length = 0;
97+
}
98+
99+
private depNameIndex = 1;
100+
private readonly depNames = new WeakMap<Readable, string>();
101+
private getDepName(dep: Readable): string {
102+
if (this.depNames.has(dep)) {
103+
return this.depNames.get(dep)!;
104+
}
105+
const depType = isWritable(dep) ? "Writable" : isReadable(dep) ? "Readable" : "Unknown";
106+
let name =
107+
dep.name ||
108+
(this.depNameIndex <= 20 ? String.fromCodePoint(9311 + this.depNameIndex++) : `${this.depNameIndex++}`);
109+
name = `${depType}(${name})`;
110+
this.depNames.set(dep, name);
111+
return name;
112+
}
113+
}
114+
115+
const traceReadable = <T extends Readable>($: T, config?: TraceConfig): T => {
116+
const deps = new Deps();
117+
const $type = isWritable($) ? "Writable" : isReadable($) ? "Readable" : "Unknown";
118+
const traceLocation = new Error("LocationDebugError");
119+
let lastValue: any = traceLocation;
120+
121+
$.subscribe(() => {
122+
((config?.defaultExpand ?? true) ? console.group : console.groupCollapsed)(
123+
`\x1b[36m[embra]\x1b[0m trace ${$type}${$.name ? `(\x1b[33m${$.name}\x1b[0m)` : ""}${config?.name ? `: ${config.name}` : ""}`,
124+
);
125+
126+
if (config?.printStack ?? true) {
127+
console.groupCollapsed("trace location");
128+
console.log(traceLocation);
129+
console.groupEnd();
130+
131+
console.groupCollapsed(`effect location`);
132+
console.trace();
133+
console.groupEnd();
134+
}
135+
136+
if (lastValue === traceLocation) {
137+
console.log($.value);
138+
} else {
139+
console.log($.value, "<- previous:", lastValue);
140+
lastValue = $.value;
141+
}
142+
143+
if ($.deps_) {
144+
for (const dep of $.deps_.keys()) {
145+
deps.newDeps.set(dep, { value: dep.value, version: dep.$version });
146+
}
147+
148+
deps.print();
149+
150+
[deps.oldDeps, deps.newDeps] = [deps.newDeps, deps.oldDeps];
151+
deps.newDeps.clear();
152+
} else {
153+
deps.oldDeps.clear();
154+
deps.newDeps.clear();
155+
}
156+
157+
console.groupEnd();
158+
});
159+
160+
return $;
161+
};
162+
163+
const traceWatch = <T extends WatchEffect | ComputeFn>(effect: T, config?: TraceConfig): T => {
164+
const deps = new Deps();
165+
const traceLocation = new Error("TraceLocationDebugError");
166+
167+
return ((get: Get, ...args: [any]): any => {
168+
const myGet: Get = $ => {
169+
if (isReadable($)) {
170+
deps.newDeps.set($, { value: $.value, version: $.$version });
171+
}
172+
return get($);
173+
};
174+
175+
const type = typeof args[0] === "function" ? "watch" : "compute";
176+
const start = performance.now();
177+
const effectResult = effect(myGet, ...args);
178+
const time = (performance.now() - start).toFixed(2);
179+
180+
((config?.defaultExpand ?? true) ? console.group : console.groupCollapsed)(
181+
`\x1b[36m[embra]\x1b[0m trace ${type}${effect.name ? `(\x1b[33m${effect.name}\x1b[0m)` : ""}${config?.name ? `: ${config.name}` : ""}`,
182+
);
183+
184+
if (config?.printStack ?? true) {
185+
console.groupCollapsed("trace location");
186+
console.log(traceLocation);
187+
console.groupEnd();
188+
189+
console.groupCollapsed(`effect executed in ${time} ms`);
190+
console.trace();
191+
console.groupEnd();
192+
} else {
193+
console.log(`effect executed in ${time} ms`);
194+
}
195+
196+
deps.print();
197+
198+
console.groupEnd();
199+
200+
[deps.oldDeps, deps.newDeps] = [deps.newDeps, deps.oldDeps];
201+
deps.newDeps.clear();
202+
203+
return effectResult;
204+
}) as T;
205+
};

src/index.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { initCustomFormatter } from "./dev/customFormatter";
1+
import { customFormatter } from "./dev";
22

33
export type {
44
Get,
@@ -26,7 +26,15 @@ export { combine, type Combine, type MapReadablesToValues } from "./combine";
2626
export { readable, type CreateReadable, writable, type CreateWritable, toWritable, type ToWritable } from "./readable";
2727
export { watch, type WatchEffect } from "./watch";
2828

29-
export { isReadable, type IsReadable, unsubscribe, strictEqual, arrayShallowEqual } from "./utils";
29+
export {
30+
isReadable,
31+
type IsReadable,
32+
isWritable,
33+
type IsWritable,
34+
unsubscribe,
35+
strictEqual,
36+
arrayShallowEqual,
37+
} from "./utils";
3038

3139
export { type OnDisposeValue } from "./collections/utils";
3240

@@ -53,6 +61,8 @@ export {
5361
type ReadonlyReactiveArray,
5462
} from "./collections/reactiveArray";
5563

64+
export { customFormatter, trace, type Trace, type TraceConfig } from "./dev";
65+
5666
if (process.env.NODE_ENV !== "production") {
57-
/* @__PURE__ */ initCustomFormatter();
67+
/* @__PURE__ */ customFormatter();
5868
}

src/typings.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export interface Get {
3030
* A Readable is a reactive value that can be read and subscribed to.
3131
*/
3232
export interface Readable<TValue = any> {
33+
readonly name?: string;
3334
/**
3435
* A version representation of the value.
3536
* If two versions of a $ is not equal(`Object.is`), it means the `value` has changed (event if the `value` is equal).
@@ -39,6 +40,10 @@ export interface Readable<TValue = any> {
3940
* @internal
4041
*/
4142
readonly [BRAND]: BRAND;
43+
/**
44+
* @internal
45+
*/
46+
deps_?: Map<Readable, Version>;
4247
/**
4348
* Current value of the $.
4449
*/

src/utils.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,12 @@ export interface IsWritable {
8787
*/
8888
export const isWritable: IsWritable = ($: unknown): $ is Writable => isReadable($) && !!($ as Writable).set;
8989

90-
export const invokeEach = <T>(iterable: Iterable<(value: T) => any>, value: T) => {
90+
interface InvokeEach {
91+
(iterable: Iterable<() => any>): void;
92+
<T>(iterable: Iterable<(value: T) => any>, value: T): void;
93+
}
94+
95+
export const invokeEach: InvokeEach = <T>(iterable: Iterable<(value?: T) => any>, value?: T) => {
9196
let error: unknown = UNIQUE_VALUE;
9297
for (const fn of iterable) {
9398
try {

test/utils.test.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,15 @@
11
import { describe, expect, it, vi } from "vitest";
22

3-
import { arrayShallowEqual, compute, isReadable, readable, strictEqual, unsubscribe, writable } from "../src";
3+
import {
4+
arrayShallowEqual,
5+
compute,
6+
isReadable,
7+
isWritable,
8+
readable,
9+
strictEqual,
10+
unsubscribe,
11+
writable,
12+
} from "../src";
413

514
describe("utils", () => {
615
describe("unsubscribe", () => {
@@ -122,4 +131,22 @@ describe("utils", () => {
122131
expect(isReadable(notReadable)).toBe(false);
123132
});
124133
});
134+
135+
describe("isWritable", () => {
136+
it("should return true for Writable instances", () => {
137+
const a = writable("a");
138+
expect(isWritable(a)).toBe(true);
139+
});
140+
141+
it("should return false for Readable instances", () => {
142+
const [b] = readable("b");
143+
expect(isWritable(b)).toBe(false);
144+
});
145+
146+
it("should return false for non-Readable/Writable instances", () => {
147+
const notReadable = { value: "not readable" };
148+
expect(isWritable(notReadable)).toBe(false);
149+
expect(isWritable(undefined)).toBe(false);
150+
});
151+
});
125152
});

0 commit comments

Comments
 (0)