Skip to content

Commit fb54d14

Browse files
committed
feat: Refactor store implementation
- Use Map and Set for enhanced runtime performance. - Add more test coverage.
1 parent fb4b9a1 commit fb54d14

2 files changed

Lines changed: 134 additions & 94 deletions

File tree

src/store.ts

Lines changed: 20 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
1-
type Handler<T, K extends keyof T> = (value: T[K], prev: T[K]) => void;
2-
type Store<T> = T & {
3-
readonly on: <K extends keyof T>(
1+
type Handler<T, K extends keyof T> = (value: T[K], prev: Readonly<T[K]>) => void;
2+
type Store<T, K extends keyof T> = T & {
3+
readonly on: (
44
key: K,
55
callback: Handler<T, K>,
6-
) => /** off */ () => void;
6+
) => /**
7+
* off
8+
* @returns Returns true if the handler was removed, or false if it was already removed.
9+
*/ () => boolean;
710
};
811

912
/**
@@ -15,28 +18,28 @@ type Store<T> = T & {
1518
* @returns A proxied state object that triggers registered callback handler
1619
* functions when its properties are set.
1720
*/
18-
export const store = <T extends Record<string | symbol, unknown>>(
21+
export const store = <
22+
T extends Record<string | symbol, unknown>,
23+
K extends Exclude<keyof T, number>,
24+
>(
1925
initialState: T & { on?: never },
20-
): Store<T> => {
21-
const handlers: { [K in keyof T]?: Handler<T, K>[] } = {};
26+
): Store<T, K> => {
27+
const handlers = new Map<K, Set<Handler<T, K>>>();
2228

2329
return new Proxy(
24-
// proxied state object
2530
{
26-
// shallow copy to prevent mutation of the initial state object
31+
// Shallow copy to prevent mutating the initial state object
2732
...initialState,
2833
on(key, fn) {
29-
(handlers[key] ??= []).push(fn);
30-
return /** off */ () => {
31-
// eslint-disable-next-line no-bitwise
32-
handlers[key]?.splice(handlers[key].indexOf(fn) >>> 0, 1);
33-
};
34+
let list = handlers.get(key);
35+
if (!list) handlers.set(key, (list = new Set()));
36+
list.add(fn);
37+
return () => list.delete(fn);
3438
},
3539
},
36-
// setter handler
3740
{
38-
set(target, property: keyof T, value: T[keyof T]) {
39-
handlers[property]?.forEach((fn) => fn(value, target[property]));
41+
set(target, property: K, value: T[K]) {
42+
handlers.get(property)?.forEach((fn) => fn(value, target[property]));
4043
// eslint-disable-next-line no-param-reassign
4144
(target as T)[property] = value;
4245
return true;

test/unit/store.test.ts

Lines changed: 114 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -61,21 +61,123 @@ describe("store", () => {
6161
}
6262
});
6363

64-
test("returns an object with an on() function", () => {
65-
expect.assertions(3);
64+
test('returns an object with an "on" property', () => {
65+
expect.assertions(1);
6666
const state = store({});
67-
expect(state.on).toBeFunction();
68-
expect(state.on).not.toBeClass();
69-
expect(state.on).toHaveParameters(2, 0);
67+
expect(state).toHaveProperty("on");
7068
});
7169

72-
test("returns off() function from on()", () => {
73-
expect.assertions(3);
74-
const state = store({ a: 1 });
75-
const off = state.on("a", () => {});
76-
expect(off).toBeFunction();
77-
expect(off).not.toBeClass();
78-
expect(off).toHaveParameters(0, 0);
70+
describe("on()", () => {
71+
test("is a function", () => {
72+
expect.assertions(2);
73+
const state = store({});
74+
expect(state.on).toBeFunction();
75+
expect(state.on).not.toBeClass();
76+
});
77+
78+
test("expects 2 parameters", () => {
79+
expect.assertions(1);
80+
const state = store({});
81+
expect(state.on).toHaveParameters(2, 0);
82+
});
83+
84+
test("returns off() function", () => {
85+
expect.assertions(2);
86+
const state = store({ a: 1 });
87+
const off = state.on("a", () => {});
88+
expect(off).toBeFunction();
89+
expect(off).not.toBeClass();
90+
});
91+
92+
describe("off()", () => {
93+
test("expects 0 parameters", () => {
94+
expect.assertions(1);
95+
const state = store({ a: 1 });
96+
const off = state.on("a", () => {});
97+
expect(off).toHaveParameters(0, 0);
98+
});
99+
100+
test("returns true when handler is removed", () => {
101+
expect.assertions(1);
102+
const state = store({ a: 1 });
103+
const off = state.on("a", () => {});
104+
expect(off()).toBeTrue();
105+
});
106+
107+
test("returns false when handler was already removed", () => {
108+
expect.assertions(3);
109+
const state = store({ a: 1 });
110+
const off = state.on("a", () => {});
111+
expect(off()).toBeTrue(); // first call removes handler
112+
expect(off()).toBeFalse();
113+
expect(off()).toBeFalse();
114+
});
115+
});
116+
117+
test("calls callback with new value and previous value", () => {
118+
expect.assertions(1);
119+
const initialState = { a: "old" };
120+
const state = store(initialState);
121+
const callback = mock(() => {});
122+
state.on("a", callback);
123+
state.a = "new";
124+
expect(callback).toHaveBeenCalledWith("new", "old");
125+
});
126+
127+
test("calls all callbacks for mutated property", () => {
128+
expect.assertions(9);
129+
const initialState = { a: 0 };
130+
const state = store(initialState);
131+
const callback1 = mock(() => {});
132+
const callback2 = mock(() => {});
133+
const callback3 = mock(() => {});
134+
state.on("a", callback1);
135+
state.on("a", callback2);
136+
state.on("a", callback3);
137+
state.a = 1;
138+
expect(callback1).toHaveBeenCalledTimes(1);
139+
expect(callback2).toHaveBeenCalledTimes(1);
140+
expect(callback3).toHaveBeenCalledTimes(1);
141+
state.a = 2;
142+
expect(callback1).toHaveBeenCalledTimes(2);
143+
expect(callback2).toHaveBeenCalledTimes(2);
144+
expect(callback3).toHaveBeenCalledTimes(2);
145+
state.a = 3;
146+
state.a = 4;
147+
expect(callback1).toHaveBeenCalledTimes(4);
148+
expect(callback2).toHaveBeenCalledTimes(4);
149+
expect(callback3).toHaveBeenCalledTimes(4);
150+
});
151+
152+
test("calls only callbacks for mutated property", () => {
153+
expect.assertions(12);
154+
const initialState = { a: 0, b: 0, c: 0 };
155+
const state = store(initialState);
156+
const callbackA = mock(() => {});
157+
const callbackB = mock(() => {});
158+
const callbackC1 = mock(() => {});
159+
const callbackC2 = mock(() => {});
160+
state.on("a", callbackA);
161+
state.on("b", callbackB);
162+
state.on("c", callbackC1);
163+
state.on("c", callbackC2);
164+
state.a = 1;
165+
expect(callbackA).toHaveBeenCalledTimes(1);
166+
expect(callbackB).toHaveBeenCalledTimes(0);
167+
expect(callbackC1).toHaveBeenCalledTimes(0);
168+
expect(callbackC2).toHaveBeenCalledTimes(0);
169+
state.b = 2;
170+
expect(callbackA).toHaveBeenCalledTimes(1);
171+
expect(callbackB).toHaveBeenCalledTimes(1);
172+
expect(callbackC1).toHaveBeenCalledTimes(0);
173+
expect(callbackC2).toHaveBeenCalledTimes(0);
174+
state.c = 3;
175+
state.c = 4;
176+
expect(callbackA).toHaveBeenCalledTimes(1);
177+
expect(callbackB).toHaveBeenCalledTimes(1);
178+
expect(callbackC1).toHaveBeenCalledTimes(2);
179+
expect(callbackC2).toHaveBeenCalledTimes(2);
180+
});
79181
});
80182

81183
test("mutating initial state does not mutate store state", () => {
@@ -122,69 +224,4 @@ describe("store", () => {
122224
state.a = 3;
123225
expect(callback).toHaveBeenCalledTimes(1); // still called only once
124226
});
125-
126-
test("calls callback with new value and previous value", () => {
127-
expect.assertions(1);
128-
const initialState = { a: "old" };
129-
const state = store(initialState);
130-
const callback = mock(() => {});
131-
state.on("a", callback);
132-
state.a = "new";
133-
expect(callback).toHaveBeenCalledWith("new", "old");
134-
});
135-
136-
test("calls all callbacks for mutated property", () => {
137-
expect.assertions(9);
138-
const initialState = { a: 0 };
139-
const state = store(initialState);
140-
const callback1 = mock(() => {});
141-
const callback2 = mock(() => {});
142-
const callback3 = mock(() => {});
143-
state.on("a", callback1);
144-
state.on("a", callback2);
145-
state.on("a", callback3);
146-
state.a = 1;
147-
expect(callback1).toHaveBeenCalledTimes(1);
148-
expect(callback2).toHaveBeenCalledTimes(1);
149-
expect(callback3).toHaveBeenCalledTimes(1);
150-
state.a = 2;
151-
expect(callback1).toHaveBeenCalledTimes(2);
152-
expect(callback2).toHaveBeenCalledTimes(2);
153-
expect(callback3).toHaveBeenCalledTimes(2);
154-
state.a = 3;
155-
state.a = 4;
156-
expect(callback1).toHaveBeenCalledTimes(4);
157-
expect(callback2).toHaveBeenCalledTimes(4);
158-
expect(callback3).toHaveBeenCalledTimes(4);
159-
});
160-
161-
test("calls only callbacks for mutated property", () => {
162-
expect.assertions(12);
163-
const initialState = { a: 0, b: 0, c: 0 };
164-
const state = store(initialState);
165-
const callbackA = mock(() => {});
166-
const callbackB = mock(() => {});
167-
const callbackC1 = mock(() => {});
168-
const callbackC2 = mock(() => {});
169-
state.on("a", callbackA);
170-
state.on("b", callbackB);
171-
state.on("c", callbackC1);
172-
state.on("c", callbackC2);
173-
state.a = 1;
174-
expect(callbackA).toHaveBeenCalledTimes(1);
175-
expect(callbackB).toHaveBeenCalledTimes(0);
176-
expect(callbackC1).toHaveBeenCalledTimes(0);
177-
expect(callbackC2).toHaveBeenCalledTimes(0);
178-
state.b = 2;
179-
expect(callbackA).toHaveBeenCalledTimes(1);
180-
expect(callbackB).toHaveBeenCalledTimes(1);
181-
expect(callbackC1).toHaveBeenCalledTimes(0);
182-
expect(callbackC2).toHaveBeenCalledTimes(0);
183-
state.c = 3;
184-
state.c = 4;
185-
expect(callbackA).toHaveBeenCalledTimes(1);
186-
expect(callbackB).toHaveBeenCalledTimes(1);
187-
expect(callbackC1).toHaveBeenCalledTimes(2);
188-
expect(callbackC2).toHaveBeenCalledTimes(2);
189-
});
190227
});

0 commit comments

Comments
 (0)