Skip to content

Commit 591fc99

Browse files
committed
feat: add withHistory, devtools, and subscribeKey utilities for enhanced state management
- `withHistory`: Adds undo/redo functionality to stores with a snapshot stack. - `devtools`: Integrates stores with Redux DevTools for debugging and time travel. - `subscribeKey`: Enables subscribing to changes on specific properties in stores.
1 parent 07889bd commit 591fc99

13 files changed

Lines changed: 1693 additions & 73 deletions

File tree

.changeset/good-parts-double.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@codebelt/classy-store": patch
3+
---
4+
5+
add `withHistory`, `devtools`, and `subscribeKey` utilities for enhanced state management
Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
import {afterEach, describe, expect, it, mock} from 'bun:test';
2+
import {createClassyStore} from '../../core/core';
3+
import {devtools} from './devtools';
4+
5+
/** Flush the queueMicrotask-based batching. */
6+
const tick = () => new Promise<void>((resolve) => setTimeout(resolve, 0));
7+
8+
// ── Mock DevTools Extension ──────────────────────────────────────────────────
9+
10+
type MockConnection = {
11+
init: ReturnType<typeof mock>;
12+
send: ReturnType<typeof mock>;
13+
subscribe: ReturnType<typeof mock>;
14+
_listener: ((message: unknown) => void) | null;
15+
};
16+
17+
function createMockConnection(): MockConnection {
18+
const conn: MockConnection = {
19+
init: mock(() => {}),
20+
send: mock(() => {}),
21+
subscribe: mock((listener: (message: unknown) => void) => {
22+
conn._listener = listener;
23+
return () => {
24+
conn._listener = null;
25+
};
26+
}),
27+
_listener: null,
28+
};
29+
return conn;
30+
}
31+
32+
function createMockExtension(conn: MockConnection) {
33+
return {
34+
connect: mock(() => conn),
35+
};
36+
}
37+
38+
/** Helper to set the mock extension on window. */
39+
function setExtension(ext: ReturnType<typeof createMockExtension>) {
40+
(window as unknown as Record<string, unknown>).__REDUX_DEVTOOLS_EXTENSION__ =
41+
ext;
42+
}
43+
44+
/** Helper to delete the extension from window. */
45+
function deleteExtension() {
46+
if (typeof window !== 'undefined') {
47+
delete (window as unknown as Record<string, unknown>)
48+
.__REDUX_DEVTOOLS_EXTENSION__;
49+
}
50+
}
51+
52+
// ── Tests ────────────────────────────────────────────────────────────────────
53+
54+
describe('devtools', () => {
55+
afterEach(() => {
56+
deleteExtension();
57+
});
58+
59+
it('returns a noop if __REDUX_DEVTOOLS_EXTENSION__ is not available', () => {
60+
deleteExtension();
61+
62+
class Store {
63+
count = 0;
64+
}
65+
66+
const store = createClassyStore(new Store());
67+
const dispose = devtools(store);
68+
69+
// Should not throw and return a function
70+
expect(typeof dispose).toBe('function');
71+
dispose(); // noop
72+
});
73+
74+
it('returns a noop when enabled is false', () => {
75+
const conn = createMockConnection();
76+
const ext = createMockExtension(conn);
77+
setExtension(ext);
78+
79+
class Store {
80+
count = 0;
81+
}
82+
83+
const store = createClassyStore(new Store());
84+
const dispose = devtools(store, {enabled: false});
85+
86+
expect(ext.connect).not.toHaveBeenCalled();
87+
dispose();
88+
});
89+
90+
it('connects with a custom name and sends init state', () => {
91+
const conn = createMockConnection();
92+
const ext = createMockExtension(conn);
93+
setExtension(ext);
94+
95+
class Store {
96+
count = 0;
97+
}
98+
99+
const store = createClassyStore(new Store());
100+
devtools(store, {name: 'MyStore'});
101+
102+
expect(ext.connect).toHaveBeenCalledWith({name: 'MyStore'});
103+
expect(conn.init).toHaveBeenCalledTimes(1);
104+
105+
// Init should receive a snapshot of the store
106+
const initState = conn.init.mock.calls[0][0] as Record<string, unknown>;
107+
expect(initState.count).toBe(0);
108+
});
109+
110+
it('sends state updates to DevTools on store mutation', async () => {
111+
const conn = createMockConnection();
112+
const ext = createMockExtension(conn);
113+
setExtension(ext);
114+
115+
class Store {
116+
count = 0;
117+
}
118+
119+
const store = createClassyStore(new Store());
120+
devtools(store);
121+
122+
store.count = 5;
123+
await tick();
124+
125+
expect(conn.send).toHaveBeenCalledTimes(1);
126+
const [action, state] = conn.send.mock.calls[0] as [
127+
{type: string},
128+
Record<string, unknown>,
129+
];
130+
expect(action.type).toBe('STORE_UPDATE');
131+
expect(state.count).toBe(5);
132+
});
133+
134+
it('handles JUMP_TO_STATE time-travel', async () => {
135+
const conn = createMockConnection();
136+
const ext = createMockExtension(conn);
137+
setExtension(ext);
138+
139+
class Store {
140+
count = 0;
141+
name = 'hello';
142+
}
143+
144+
const store = createClassyStore(new Store());
145+
devtools(store);
146+
147+
store.count = 10;
148+
store.name = 'world';
149+
await tick();
150+
151+
// Simulate time-travel back to initial state
152+
conn._listener?.({
153+
type: 'DISPATCH',
154+
payload: {type: 'JUMP_TO_STATE'},
155+
state: JSON.stringify({count: 0, name: 'hello'}),
156+
});
157+
await tick();
158+
159+
expect(store.count).toBe(0);
160+
expect(store.name).toBe('hello');
161+
});
162+
163+
it('skips getters during time-travel restore', async () => {
164+
const conn = createMockConnection();
165+
const ext = createMockExtension(conn);
166+
setExtension(ext);
167+
168+
class Store {
169+
count = 5;
170+
171+
get doubled() {
172+
return this.count * 2;
173+
}
174+
}
175+
176+
const store = createClassyStore(new Store());
177+
devtools(store);
178+
179+
// Simulate time-travel with a getter key included
180+
conn._listener?.({
181+
type: 'DISPATCH',
182+
payload: {type: 'JUMP_TO_STATE'},
183+
state: JSON.stringify({count: 3, doubled: 999}),
184+
});
185+
await tick();
186+
187+
expect(store.count).toBe(3);
188+
// Getter should recompute, not be overwritten
189+
expect(store.doubled).toBe(6);
190+
});
191+
192+
it('disposes correctly (unsubscribes from store and devtools)', async () => {
193+
const conn = createMockConnection();
194+
const ext = createMockExtension(conn);
195+
setExtension(ext);
196+
197+
class Store {
198+
count = 0;
199+
}
200+
201+
const store = createClassyStore(new Store());
202+
const dispose = devtools(store);
203+
204+
store.count = 1;
205+
await tick();
206+
expect(conn.send).toHaveBeenCalledTimes(1);
207+
208+
dispose();
209+
210+
store.count = 2;
211+
await tick();
212+
// No additional send after dispose
213+
expect(conn.send).toHaveBeenCalledTimes(1);
214+
// DevTools listener should be removed
215+
expect(conn._listener).toBeNull();
216+
});
217+
});

src/utils/devtools/devtools.ts

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import {subscribe} from '../../core/core';
2+
import {snapshot} from '../../snapshot/snapshot';
3+
import {findGetterDescriptor} from '../internal/internal';
4+
5+
// ── Redux DevTools types (minimal subset) ────────────────────────────────────
6+
7+
type DevToolsMessage = {
8+
type: string;
9+
payload?: {type?: string};
10+
state?: string;
11+
};
12+
13+
type DevToolsConnection = {
14+
init: (state: unknown) => void;
15+
send: (action: string | {type: string}, state: unknown) => void;
16+
subscribe: (
17+
listener: (message: DevToolsMessage) => void,
18+
) => (() => void) | {unsubscribe: () => void};
19+
};
20+
21+
type DevToolsExtension = {
22+
connect: (options?: {name?: string}) => DevToolsConnection;
23+
};
24+
25+
declare global {
26+
interface Window {
27+
__REDUX_DEVTOOLS_EXTENSION__?: DevToolsExtension;
28+
}
29+
}
30+
31+
// ── Options ──────────────────────────────────────────────────────────────────
32+
33+
export type DevtoolsOptions = {
34+
/** Display name in the DevTools panel. Defaults to `'ClassyStore'`. */
35+
name?: string;
36+
/** Set to `false` to disable the integration (returns a noop). Defaults to `true`. */
37+
enabled?: boolean;
38+
};
39+
40+
// ── Implementation ───────────────────────────────────────────────────────────
41+
42+
/**
43+
* Connect a store proxy to Redux DevTools for state inspection and time-travel debugging.
44+
*
45+
* Uses `subscribe()` + `snapshot()` to send state on each change.
46+
* Listens for `DISPATCH` messages (`JUMP_TO_STATE`, `JUMP_TO_ACTION`) and applies
47+
* the received state back to the store proxy, skipping getters and methods.
48+
*
49+
* @param proxyStore - A reactive proxy created by `createClassyStore()`.
50+
* @param options - Optional configuration.
51+
* @returns A dispose function that disconnects from DevTools and unsubscribes.
52+
*/
53+
export function devtools<T extends object>(
54+
proxyStore: T,
55+
options?: DevtoolsOptions,
56+
): () => void {
57+
const {name = 'ClassyStore', enabled = true} = options ?? {};
58+
59+
// Guard: no extension or disabled
60+
if (
61+
!enabled ||
62+
typeof window === 'undefined' ||
63+
!window.__REDUX_DEVTOOLS_EXTENSION__
64+
) {
65+
return () => {};
66+
}
67+
68+
const extension = window.__REDUX_DEVTOOLS_EXTENSION__;
69+
const connection = extension.connect({name});
70+
71+
// Send initial state
72+
connection.init(snapshot(proxyStore));
73+
74+
// Track whether we're currently applying time-travel state to avoid
75+
// re-sending the state we just applied.
76+
let isTimeTraveling = false;
77+
78+
// Subscribe to store mutations → send to DevTools
79+
const unsubscribeFromStore = subscribe(proxyStore, () => {
80+
if (isTimeTraveling) return;
81+
connection.send({type: 'STORE_UPDATE'}, snapshot(proxyStore));
82+
});
83+
84+
// Listen for DevTools dispatches (time-travel)
85+
const devToolsUnsub = connection.subscribe((message: DevToolsMessage) => {
86+
if (message.type === 'DISPATCH' && message.state) {
87+
const payloadType = message.payload?.type;
88+
if (payloadType === 'JUMP_TO_STATE' || payloadType === 'JUMP_TO_ACTION') {
89+
try {
90+
const newState = JSON.parse(message.state) as Record<string, unknown>;
91+
isTimeTraveling = true;
92+
93+
// Apply state back to the proxy, skipping getters and methods
94+
for (const key of Object.keys(newState)) {
95+
// Skip getters
96+
if (findGetterDescriptor(proxyStore, key)?.get) continue;
97+
// Skip methods
98+
if (
99+
typeof (proxyStore as Record<string, unknown>)[key] === 'function'
100+
) {
101+
continue;
102+
}
103+
(proxyStore as Record<string, unknown>)[key] = newState[key];
104+
}
105+
106+
isTimeTraveling = false;
107+
} catch {
108+
isTimeTraveling = false;
109+
}
110+
}
111+
}
112+
});
113+
114+
// Return dispose function
115+
return () => {
116+
unsubscribeFromStore();
117+
if (typeof devToolsUnsub === 'function') {
118+
devToolsUnsub();
119+
} else if (
120+
devToolsUnsub &&
121+
typeof devToolsUnsub.unsubscribe === 'function'
122+
) {
123+
devToolsUnsub.unsubscribe();
124+
}
125+
};
126+
}

0 commit comments

Comments
 (0)