Skip to content

Commit 64489ea

Browse files
authored
feat: add withHistory, devtools, and subscribeKey utilities for enhanced state management (#7)
* feat: add `useLocalStore` hook for component-scoped reactive stores with tests and documentation * feat: add `useLocalStore` hook for component-scoped reactive stores with tests and documentation * 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. * docs: update architecture and documentation for `useLocalStore` and utilities - Added `useLocalStore` details to architecture and docs. - Expanded file structures to include `devtools`, `subscribeKey`, and `withHistory` utilities. - Refined examples and tutorials for consistency and clarity. * refactor(utils): improve state handling with try-finally and enhance safety checks - Wrapped state application logic in `try-finally` blocks to ensure cleanup (`isTimeTraveling`, `paused`, `hydrating`) regardless of errors. - Improved safety by skipping operations during invalid states (e.g., `hydration`/`disposed` in `persist`). - Added `shallowEqual` utility export to utils index. * test(utils): add extensive test coverage for edge cases in utilities - Added additional tests for `devtools` to cover time travel, error handling, and message payloads. - Extended test cases for `persist` to handle null states, invalid envelopes, and unsubscribe behavior. - Introduced more cases for `subscribeKey` with various data types and subscriber behavior. - Enhanced `shallowEqual` tests to include edge cases such as array vs object comparison and handling undefined values. * test(core/react/utils/collections): extend test suites for edge cases and computed behavior - Added extensive tests for `useStore` covering error handling, multiple stores, selector edge cases, and derived selectors. - Enhanced `computed getter` memoization tests, including nested dependencies and structural sharing across versions. - Expanded test cases for `snapshot` behavior, including object freezing, deleted properties, and specialized structures. - Introduced test cases for `ReactiveMap` and `ReactiveSet` with numeric keys, `NaN`, object keys, and snapshot consistency. - Improved coverage for version tracking, child proxy management, and error scenarios in `core`.
1 parent 14ffd85 commit 64489ea

25 files changed

+3417
-141
lines changed

.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

src/collections/collections.test.ts

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,3 +260,158 @@ describe('collections inside class store', () => {
260260
expect(s.labels.has('bug')).toBe(true);
261261
});
262262
});
263+
264+
// ── ReactiveMap — additional edge cases ────────────────────────────────────
265+
266+
describe('reactiveMap() — edge cases', () => {
267+
test('supports numeric keys', () => {
268+
const m = reactiveMap<number, string>();
269+
m.set(1, 'one');
270+
m.set(2, 'two');
271+
expect(m.get(1)).toBe('one');
272+
expect(m.size).toBe(2);
273+
expect(m.delete(1)).toBe(true);
274+
expect(m.size).toBe(1);
275+
});
276+
277+
test('supports object keys with Object.is comparison', () => {
278+
const key1 = {id: 1};
279+
const key2 = {id: 2};
280+
const m = reactiveMap<object, string>();
281+
m.set(key1, 'first');
282+
m.set(key2, 'second');
283+
284+
expect(m.get(key1)).toBe('first');
285+
expect(m.get(key2)).toBe('second');
286+
287+
// Different object with same shape is NOT the same key
288+
expect(m.get({id: 1})).toBeUndefined();
289+
});
290+
291+
test('set() returns this for chaining', () => {
292+
const m = reactiveMap<string, number>();
293+
const result = m.set('a', 1);
294+
expect(result).toBe(m);
295+
296+
// Chaining
297+
m.set('b', 2).set('c', 3);
298+
expect(m.size).toBe(3);
299+
});
300+
301+
test('handles NaN as key (Object.is(NaN, NaN) is true)', () => {
302+
const m = reactiveMap<number, string>();
303+
m.set(Number.NaN, 'nan-value');
304+
305+
expect(m.has(Number.NaN)).toBe(true);
306+
expect(m.get(Number.NaN)).toBe('nan-value');
307+
expect(m.size).toBe(1);
308+
309+
// Overwriting NaN key
310+
m.set(Number.NaN, 'updated');
311+
expect(m.size).toBe(1);
312+
expect(m.get(Number.NaN)).toBe('updated');
313+
});
314+
315+
test('delete returns false for non-existent key', () => {
316+
const m = reactiveMap<string, number>();
317+
expect(m.delete('missing')).toBe(false);
318+
});
319+
320+
test('forEach receives the map as third argument', () => {
321+
const m = reactiveMap([['a', 1]] as [string, number][]);
322+
m.forEach((_v, _k, map) => {
323+
expect(map).toBe(m);
324+
});
325+
});
326+
327+
test('snapshot of mutated ReactiveMap reflects latest state', async () => {
328+
const s = createClassyStore({m: reactiveMap<string, number>()});
329+
s.m.set('x', 10);
330+
s.m.set('y', 20);
331+
await flush();
332+
333+
const snap = snapshot(s);
334+
expect(snap.m._entries).toEqual([
335+
['x', 10],
336+
['y', 20],
337+
]);
338+
});
339+
});
340+
341+
// ── ReactiveSet — additional edge cases ────────────────────────────────────
342+
343+
describe('reactiveSet() — edge cases', () => {
344+
test('add() returns this for chaining', () => {
345+
const s = reactiveSet<number>();
346+
const result = s.add(1);
347+
expect(result).toBe(s);
348+
349+
s.add(2).add(3);
350+
expect(s.size).toBe(3);
351+
});
352+
353+
test('handles NaN values (Object.is(NaN, NaN) is true)', () => {
354+
const s = reactiveSet<number>();
355+
s.add(Number.NaN);
356+
357+
expect(s.has(Number.NaN)).toBe(true);
358+
expect(s.size).toBe(1);
359+
360+
// Adding NaN again should be no-op
361+
s.add(Number.NaN);
362+
expect(s.size).toBe(1);
363+
364+
expect(s.delete(Number.NaN)).toBe(true);
365+
expect(s.size).toBe(0);
366+
});
367+
368+
test('delete returns false for non-existent value', () => {
369+
const s = reactiveSet<string>();
370+
expect(s.delete('missing')).toBe(false);
371+
});
372+
373+
test('forEach receives the set as third argument', () => {
374+
const s = reactiveSet([1]);
375+
s.forEach((_v, _k, set) => {
376+
expect(set).toBe(s);
377+
});
378+
});
379+
380+
test('entries returns [value, value] pairs matching Set spec', () => {
381+
const s = reactiveSet([10, 20]);
382+
expect([...s.entries()]).toEqual([
383+
[10, 10],
384+
[20, 20],
385+
]);
386+
});
387+
388+
test('keys and values return the same sequence', () => {
389+
const s = reactiveSet(['a', 'b', 'c']);
390+
expect([...s.keys()]).toEqual([...s.values()]);
391+
});
392+
393+
test('clear on empty set does not throw', () => {
394+
const s = reactiveSet<number>();
395+
expect(() => s.clear()).not.toThrow();
396+
});
397+
398+
test('snapshot of mutated ReactiveSet reflects latest state', async () => {
399+
const s = createClassyStore({tags: reactiveSet<string>()});
400+
s.tags.add('a');
401+
s.tags.add('b');
402+
s.tags.delete('a');
403+
await flush();
404+
405+
const snap = snapshot(s);
406+
expect(snap.tags._items).toEqual(['b']);
407+
});
408+
409+
test('add duplicate after delete re-adds the value', () => {
410+
const s = reactiveSet([1, 2, 3]);
411+
s.delete(2);
412+
expect(s.has(2)).toBe(false);
413+
s.add(2);
414+
expect(s.has(2)).toBe(true);
415+
expect(s.size).toBe(3);
416+
});
417+
});

src/core/core.test.ts

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -462,4 +462,228 @@ describe('createClassyStore() — core reactivity', () => {
462462
expect(listener).toHaveBeenCalledTimes(1); // all batched
463463
});
464464
});
465+
466+
// ── Computed getter memoization ───────────────────────────────────────
467+
468+
describe('computed getter memoization', () => {
469+
it('memoizes getter result when deps have not changed', () => {
470+
let callCount = 0;
471+
472+
class Store {
473+
count = 5;
474+
get expensive() {
475+
callCount++;
476+
return this.count * 2;
477+
}
478+
}
479+
480+
const s = createClassyStore(new Store());
481+
482+
expect(s.expensive).toBe(10);
483+
expect(callCount).toBe(1);
484+
485+
// Accessing again without mutation should use cache
486+
expect(s.expensive).toBe(10);
487+
expect(callCount).toBe(1);
488+
});
489+
490+
it('recomputes getter when dependency changes', async () => {
491+
let callCount = 0;
492+
493+
class Store {
494+
count = 5;
495+
get doubled() {
496+
callCount++;
497+
return this.count * 2;
498+
}
499+
}
500+
501+
const s = createClassyStore(new Store());
502+
expect(s.doubled).toBe(10);
503+
expect(callCount).toBe(1);
504+
505+
s.count = 10;
506+
// Getter should recompute because count changed
507+
expect(s.doubled).toBe(20);
508+
expect(callCount).toBe(2);
509+
});
510+
511+
it('getter that reads another getter (nested computed)', () => {
512+
class Store {
513+
count = 3;
514+
get doubled() {
515+
return this.count * 2;
516+
}
517+
get quadrupled() {
518+
return this.doubled * 2;
519+
}
520+
}
521+
522+
const s = createClassyStore(new Store());
523+
expect(s.quadrupled).toBe(12);
524+
525+
s.count = 5;
526+
expect(s.quadrupled).toBe(20);
527+
});
528+
529+
it('getter with nested object dependency recomputes on child mutation', async () => {
530+
class Store {
531+
items = [1, 2, 3];
532+
get total() {
533+
return this.items.reduce((a: number, b: number) => a + b, 0);
534+
}
535+
}
536+
537+
const s = createClassyStore(new Store());
538+
expect(s.total).toBe(6);
539+
540+
s.items.push(4);
541+
expect(s.total).toBe(10);
542+
});
543+
544+
it('getter invalidates when property is replaced entirely', async () => {
545+
class Store {
546+
data = {value: 1};
547+
get label() {
548+
return `value: ${this.data.value}`;
549+
}
550+
}
551+
552+
const s = createClassyStore(new Store());
553+
expect(s.label).toBe('value: 1');
554+
555+
// Replace the entire object
556+
s.data = {value: 99};
557+
expect(s.label).toBe('value: 99');
558+
});
559+
});
560+
561+
// ── Error handling ────────────────────────────────────────────────────
562+
563+
describe('error handling', () => {
564+
it('getInternal throws for a non-store object', () => {
565+
const plainObject = {count: 0};
566+
expect(() => subscribe(plainObject, () => {})).toThrow(
567+
/not a store proxy/,
568+
);
569+
});
570+
571+
it('getInternal throws for a primitive wrapper', () => {
572+
expect(() => subscribe({} as object, () => {})).toThrow(
573+
/not a store proxy/,
574+
);
575+
});
576+
577+
it('getVersion throws for a non-store object', () => {
578+
expect(() => getVersion({})).toThrow(/not a store proxy/);
579+
});
580+
});
581+
582+
// ── Child proxy management ────────────────────────────────────────────
583+
584+
describe('child proxy management', () => {
585+
it('replacing a nested object creates a new child proxy', async () => {
586+
const s = createClassyStore({nested: {a: 1}});
587+
const listener = mock(() => {});
588+
subscribe(s, listener);
589+
590+
const oldRef = s.nested;
591+
s.nested = {a: 2};
592+
const newRef = s.nested;
593+
594+
// Should be different proxy references
595+
expect(oldRef).not.toBe(newRef);
596+
expect(newRef.a).toBe(2);
597+
598+
await flush();
599+
expect(listener).toHaveBeenCalledTimes(1);
600+
});
601+
602+
it('mutations on old child proxy after replacement do not trigger notifications', async () => {
603+
const s = createClassyStore({nested: {a: 1}});
604+
const listener = mock(() => {});
605+
606+
const oldNested = s.nested; // get child proxy
607+
s.nested = {a: 2}; // replace — old child proxy detached
608+
609+
subscribe(s, listener);
610+
611+
// Mutate the old detached proxy reference (directly on target)
612+
// This shouldn't crash, but won't trigger listener on the store
613+
// because the child is no longer linked.
614+
// Note: old proxy still has its own internal, so mutations work on it
615+
// but the store's root won't be notified since the child is orphaned.
616+
oldNested.a = 999;
617+
await flush();
618+
619+
// The store's nested should still be the new value
620+
expect(s.nested.a).toBe(2);
621+
});
622+
623+
it('deeply nested replacement triggers root listener', async () => {
624+
const s = createClassyStore({
625+
level1: {level2: {level3: {value: 'deep'}}},
626+
});
627+
const listener = mock(() => {});
628+
subscribe(s, listener);
629+
630+
s.level1.level2.level3.value = 'changed';
631+
await flush();
632+
633+
expect(listener).toHaveBeenCalledTimes(1);
634+
expect(s.level1.level2.level3.value).toBe('changed');
635+
});
636+
});
637+
638+
// ── Version tracking ──────────────────────────────────────────────────
639+
640+
describe('version tracking', () => {
641+
it('version does not change when same value is set', () => {
642+
const s = createClassyStore({count: 0});
643+
const v1 = getVersion(s);
644+
645+
s.count = 0; // noop — same value
646+
const v2 = getVersion(s);
647+
648+
expect(v2).toBe(v1);
649+
});
650+
651+
it('version increments on nested mutation', async () => {
652+
const s = createClassyStore({nested: {value: 1}});
653+
const v1 = getVersion(s);
654+
655+
s.nested.value = 2;
656+
const v2 = getVersion(s);
657+
658+
expect(v2).toBeGreaterThan(v1);
659+
});
660+
661+
it('version increments on delete', async () => {
662+
const s = createClassyStore({a: 1, b: 2} as Record<string, number>);
663+
const v1 = getVersion(s);
664+
665+
delete s.b;
666+
const v2 = getVersion(s);
667+
668+
expect(v2).toBeGreaterThan(v1);
669+
});
670+
671+
it('multiple rapid mutations produce one notification but multiple version bumps', async () => {
672+
const s = createClassyStore({count: 0});
673+
const listener = mock(() => {});
674+
subscribe(s, listener);
675+
676+
const v1 = getVersion(s);
677+
s.count = 1;
678+
s.count = 2;
679+
s.count = 3;
680+
const v2 = getVersion(s);
681+
682+
await flush();
683+
684+
expect(v2).toBeGreaterThan(v1);
685+
expect(listener).toHaveBeenCalledTimes(1); // batched
686+
expect(s.count).toBe(3);
687+
});
688+
});
465689
});

0 commit comments

Comments
 (0)