Skip to content

Commit 012284d

Browse files
refactor: use Set instead of Array for O(1) listener operations in singleton-handler (#187)
1 parent 1d5daa7 commit 012284d

File tree

2 files changed

+59
-5
lines changed

2 files changed

+59
-5
lines changed

src/internal/singleton-handler/__tests__/create-singleton-handler.test.tsx

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,3 +79,55 @@ test('should remove listeners when component unmounts', () => {
7979
expect(onChangeFirst).toHaveBeenCalledTimes(2);
8080
expect(onChangeSecond).toHaveBeenCalledTimes(1);
8181
});
82+
83+
test('should notify listeners in registration order', () => {
84+
const { Demo, state } = setup();
85+
const callOrder: string[] = [];
86+
const onChangeFirst = jest.fn(() => callOrder.push('first'));
87+
const onChangeSecond = jest.fn(() => callOrder.push('second'));
88+
const onChangeThird = jest.fn(() => callOrder.push('third'));
89+
render(
90+
<>
91+
<Demo onChange={onChangeFirst} />
92+
<Demo onChange={onChangeSecond} />
93+
<Demo onChange={onChangeThird} />
94+
</>
95+
);
96+
state.handler(42);
97+
expect(callOrder).toEqual(['first', 'second', 'third']);
98+
});
99+
100+
test('should handle rapid mount/unmount cycles correctly', () => {
101+
const { Demo, state } = setup();
102+
const onChange1 = jest.fn();
103+
const onChange2 = jest.fn();
104+
const onChange3 = jest.fn();
105+
106+
const { rerender, unmount } = render(<Demo onChange={onChange1} />);
107+
expect(state.subscriptions).toEqual(1);
108+
109+
// Rapid additions
110+
rerender(
111+
<>
112+
<Demo onChange={onChange1} />
113+
<Demo onChange={onChange2} />
114+
</>
115+
);
116+
rerender(
117+
<>
118+
<Demo onChange={onChange1} />
119+
<Demo onChange={onChange2} />
120+
<Demo onChange={onChange3} />
121+
</>
122+
);
123+
expect(state.subscriptions).toEqual(1);
124+
125+
state.handler(100);
126+
expect(onChange1).toHaveBeenCalledWith(100);
127+
expect(onChange2).toHaveBeenCalledWith(100);
128+
expect(onChange3).toHaveBeenCalledWith(100);
129+
130+
// Complete cleanup
131+
unmount();
132+
expect(state.subscriptions).toEqual(0);
133+
});

src/internal/singleton-handler/index.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@ type CleanupCallback = () => void;
99
export type UseSingleton<T> = (listener: ValueCallback<T>) => void;
1010

1111
export function createSingletonHandler<T>(factory: (handler: ValueCallback<T>) => CleanupCallback): UseSingleton<T> {
12-
const listeners: Array<ValueCallback<T>> = [];
12+
// Using Set for O(1) add/delete operations instead of Array's O(n) indexOf + splice.
13+
// Sets maintain insertion order (ES2015+), preserving iteration consistency.
14+
const listeners = new Set<ValueCallback<T>>();
1315
const callback: ValueCallback<T> = value => {
1416
unstable_batchedUpdates(() => {
1517
for (const listener of listeners) {
@@ -21,14 +23,14 @@ export function createSingletonHandler<T>(factory: (handler: ValueCallback<T>) =
2123

2224
return function useSingleton(listener: ValueCallback<T>) {
2325
useEffect(() => {
24-
if (listeners.length === 0) {
26+
if (listeners.size === 0) {
2527
cleanup = factory(callback);
2628
}
27-
listeners.push(listener);
29+
listeners.add(listener);
2830

2931
return () => {
30-
listeners.splice(listeners.indexOf(listener), 1);
31-
if (listeners.length === 0) {
32+
listeners.delete(listener);
33+
if (listeners.size === 0) {
3234
cleanup!();
3335
cleanup = undefined;
3436
}

0 commit comments

Comments
 (0)