Skip to content

Commit 0a9eca7

Browse files
committed
🤖 refactor: deduplicate Map/Record identity stabilization pattern
Extract common pattern from useUnreadTracking and useWorkspaceAggregators into a reusable hook with comparator utilities. Changes: - Add useStableReference hook to manage identity stabilization - Add compareMaps, compareRecords, compareArrays utility functions - Comprehensive tests for all comparator functions - Refactor useUnreadTracking to use useStableReference + compareMaps - Refactor useWorkspaceAggregators to use useStableReference + compareRecords Benefits: - DRY - single implementation of the stabilization pattern - Type-safe - generics preserve types throughout - Testable - comparator functions are independently tested - Reusable - easy to apply to new cases (e.g., arrays, custom objects) - Maintainable - clear separation of comparison logic The pattern maintains reference identity when deep values haven't changed, preventing unnecessary re-renders in React components that depend on reference equality.
1 parent 4bf71e9 commit 0a9eca7

File tree

4 files changed

+313
-61
lines changed

4 files changed

+313
-61
lines changed
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
/**
2+
* Tests for stable reference utilities.
3+
*
4+
* Note: Hook tests (useStableReference) are omitted because they require jsdom setup.
5+
* The comparator functions are the critical logic and are thoroughly tested here.
6+
* The hook itself is a thin wrapper around useMemo and useRef with manual testing.
7+
*/
8+
import { compareMaps, compareRecords, compareArrays } from "./useStableReference";
9+
10+
describe("compareMaps", () => {
11+
it("returns true for empty maps", () => {
12+
expect(compareMaps(new Map(), new Map())).toBe(true);
13+
});
14+
15+
it("returns true for maps with same entries", () => {
16+
const prev = new Map([
17+
["a", 1],
18+
["b", 2],
19+
]);
20+
const next = new Map([
21+
["a", 1],
22+
["b", 2],
23+
]);
24+
expect(compareMaps(prev, next)).toBe(true);
25+
});
26+
27+
it("returns false for maps with different sizes", () => {
28+
const prev = new Map([["a", 1]]);
29+
const next = new Map([
30+
["a", 1],
31+
["b", 2],
32+
]);
33+
expect(compareMaps(prev, next)).toBe(false);
34+
});
35+
36+
it("returns false for maps with different keys", () => {
37+
const prev = new Map([["a", 1]]);
38+
const next = new Map([["b", 1]]);
39+
expect(compareMaps(prev, next)).toBe(false);
40+
});
41+
42+
it("returns false for maps with different values", () => {
43+
const prev = new Map([["a", 1]]);
44+
const next = new Map([["a", 2]]);
45+
expect(compareMaps(prev, next)).toBe(false);
46+
});
47+
48+
it("supports custom value equality function", () => {
49+
const prev = new Map([["a", { id: 1 }]]);
50+
const next = new Map([["a", { id: 1 }]]);
51+
52+
// Default comparison (reference equality) returns false
53+
expect(compareMaps(prev, next)).toBe(false);
54+
55+
// Custom comparison (by id) returns true
56+
expect(compareMaps(prev, next, (a, b) => a.id === b.id)).toBe(true);
57+
});
58+
});
59+
60+
describe("compareRecords", () => {
61+
it("returns true for empty records", () => {
62+
expect(compareRecords({}, {})).toBe(true);
63+
});
64+
65+
it("returns true for records with same entries", () => {
66+
const prev = { a: 1, b: 2 };
67+
const next = { a: 1, b: 2 };
68+
expect(compareRecords(prev, next)).toBe(true);
69+
});
70+
71+
it("returns false for records with different sizes", () => {
72+
const prev = { a: 1 };
73+
const next = { a: 1, b: 2 };
74+
expect(compareRecords(prev, next)).toBe(false);
75+
});
76+
77+
it("returns false for records with different keys", () => {
78+
const prev = { a: 1 };
79+
const next = { b: 1 };
80+
expect(compareRecords(prev, next)).toBe(false);
81+
});
82+
83+
it("returns false for records with different values", () => {
84+
const prev = { a: 1 };
85+
const next = { a: 2 };
86+
expect(compareRecords(prev, next)).toBe(false);
87+
});
88+
89+
it("supports custom value equality function", () => {
90+
const prev = { a: { id: 1 } };
91+
const next = { a: { id: 1 } };
92+
93+
// Default comparison (reference equality) returns false
94+
expect(compareRecords(prev, next)).toBe(false);
95+
96+
// Custom comparison (by id) returns true
97+
expect(compareRecords(prev, next, (a, b) => a.id === b.id)).toBe(true);
98+
});
99+
});
100+
101+
describe("compareArrays", () => {
102+
it("returns true for empty arrays", () => {
103+
expect(compareArrays([], [])).toBe(true);
104+
});
105+
106+
it("returns true for arrays with same elements", () => {
107+
expect(compareArrays([1, 2, 3], [1, 2, 3])).toBe(true);
108+
});
109+
110+
it("returns false for arrays with different lengths", () => {
111+
expect(compareArrays([1, 2], [1, 2, 3])).toBe(false);
112+
});
113+
114+
it("returns false for arrays with different values", () => {
115+
expect(compareArrays([1, 2, 3], [1, 2, 4])).toBe(false);
116+
});
117+
118+
it("returns false for arrays with same values in different order", () => {
119+
expect(compareArrays([1, 2, 3], [3, 2, 1])).toBe(false);
120+
});
121+
122+
it("supports custom value equality function", () => {
123+
const prev = [{ id: 1 }, { id: 2 }];
124+
const next = [{ id: 1 }, { id: 2 }];
125+
126+
// Default comparison (reference equality) returns false
127+
expect(compareArrays(prev, next)).toBe(false);
128+
129+
// Custom comparison (by id) returns true
130+
expect(compareArrays(prev, next, (a, b) => a.id === b.id)).toBe(true);
131+
});
132+
});
133+
134+
// Hook integration tests would require jsdom setup with bun.
135+
// The comparator functions above are the critical logic and are thoroughly tested.
136+
// The hook itself is tested manually through its usage in useUnreadTracking and
137+
// useWorkspaceAggregators.
138+

src/hooks/useStableReference.ts

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import { useRef, useMemo, type DependencyList } from "react";
2+
3+
/**
4+
* Compare two Maps for deep equality (same keys and values).
5+
* Uses === for value comparison by default.
6+
*
7+
* @param prev Previous Map
8+
* @param next Next Map
9+
* @param valueEquals Optional custom value equality function
10+
* @returns true if Maps are equal, false otherwise
11+
*/
12+
export function compareMaps<K, V>(
13+
prev: Map<K, V>,
14+
next: Map<K, V>,
15+
valueEquals: (a: V, b: V) => boolean = (a, b) => a === b
16+
): boolean {
17+
if (prev.size !== next.size) return false;
18+
19+
for (const [key, value] of next) {
20+
if (!prev.has(key)) return false;
21+
if (!valueEquals(prev.get(key)!, value)) return false;
22+
}
23+
24+
return true;
25+
}
26+
27+
/**
28+
* Compare two Records for deep equality (same keys and values).
29+
* Uses === for value comparison by default.
30+
*
31+
* @param prev Previous Record
32+
* @param next Next Record
33+
* @param valueEquals Optional custom value equality function
34+
* @returns true if Records are equal, false otherwise
35+
*/
36+
export function compareRecords<V>(
37+
prev: Record<string, V>,
38+
next: Record<string, V>,
39+
valueEquals: (a: V, b: V) => boolean = (a, b) => a === b
40+
): boolean {
41+
const prevKeys = Object.keys(prev);
42+
const nextKeys = Object.keys(next);
43+
44+
if (prevKeys.length !== nextKeys.length) return false;
45+
46+
for (const key of nextKeys) {
47+
if (!(key in prev)) return false;
48+
if (!valueEquals(prev[key], next[key])) return false;
49+
}
50+
51+
return true;
52+
}
53+
54+
/**
55+
* Compare two Arrays for deep equality (same length and values).
56+
* Uses === for value comparison by default.
57+
*
58+
* @param prev Previous Array
59+
* @param next Next Array
60+
* @param valueEquals Optional custom value equality function
61+
* @returns true if Arrays are equal, false otherwise
62+
*/
63+
export function compareArrays<V>(
64+
prev: V[],
65+
next: V[],
66+
valueEquals: (a: V, b: V) => boolean = (a, b) => a === b
67+
): boolean {
68+
if (prev.length !== next.length) return false;
69+
70+
for (let i = 0; i < next.length; i++) {
71+
if (!valueEquals(prev[i], next[i])) return false;
72+
}
73+
74+
return true;
75+
}
76+
77+
/**
78+
* Hook to stabilize reference identity for computed values.
79+
*
80+
* Returns the previous reference if the new value is deeply equal to the previous value,
81+
* preventing unnecessary re-renders in components that depend on reference equality.
82+
*
83+
* Common use case: Stabilizing Map/Record/Array identities in useMemo when the
84+
* underlying values haven't actually changed.
85+
*
86+
* @example
87+
* ```typescript
88+
* // Stabilize a Map<string, boolean>
89+
* const unreadStatus = useStableReference(
90+
* () => {
91+
* const map = new Map<string, boolean>();
92+
* for (const [id, state] of workspaceStates) {
93+
* map.set(id, calculateUnread(state));
94+
* }
95+
* return map;
96+
* },
97+
* compareMaps,
98+
* [workspaceStates]
99+
* );
100+
* ```
101+
*
102+
* @param factory Function that creates the new value
103+
* @param comparator Function to check equality between prev and next values
104+
* @param deps Dependency list for useMemo
105+
* @returns Stable reference to the value
106+
*/
107+
export function useStableReference<T>(
108+
factory: () => T,
109+
comparator: (prev: T, next: T) => boolean,
110+
deps: DependencyList
111+
): T {
112+
const ref = useRef<T | undefined>(undefined);
113+
114+
return useMemo(() => {
115+
const next = factory();
116+
117+
// First render or no previous value
118+
if (ref.current === undefined) {
119+
ref.current = next;
120+
return next;
121+
}
122+
123+
// Compare with previous value
124+
if (comparator(ref.current, next)) {
125+
return ref.current; // Maintain identity
126+
}
127+
128+
// Value changed, update ref and return new value
129+
ref.current = next;
130+
return next;
131+
// eslint-disable-next-line react-hooks/exhaustive-deps
132+
}, deps);
133+
}
134+

src/hooks/useUnreadTracking.ts

Lines changed: 27 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
import { useEffect, useCallback, useMemo, useRef } from "react";
1+
import { useEffect, useCallback, useRef } from "react";
22
import type { WorkspaceSelection } from "@/components/ProjectSidebar";
33
import type { WorkspaceState } from "./useWorkspaceAggregators";
44
import { usePersistedState } from "./usePersistedState";
5+
import { useStableReference, compareMaps } from "./useStableReference";
56

67
/**
78
* Hook to track unread message status for all workspaces.
@@ -73,47 +74,35 @@ export function useUnreadTracking(
7374
}, [selectedWorkspace?.workspaceId, workspaceStates, markAsRead]);
7475

7576
// Calculate unread status for all workspaces
76-
const unreadStatusRef = useRef<Map<string, boolean>>(new Map());
77-
const unreadStatus = useMemo(() => {
78-
const next = new Map<string, boolean>();
79-
80-
for (const [workspaceId, state] of workspaceStates) {
81-
// Streaming workspaces are never unread
82-
if (state.canInterrupt) {
83-
next.set(workspaceId, false);
84-
continue;
85-
}
86-
87-
// Check for any assistant-originated content newer than last-read timestamp:
88-
// assistant text, tool calls/results (e.g., propose_plan), reasoning, and errors.
89-
// Exclude user's own messages and UI markers.
90-
const lastRead = lastReadMap[workspaceId] ?? 0;
91-
const hasUnread = state.messages.some(
92-
(msg) =>
93-
msg.type !== "user" && msg.type !== "history-hidden" && (msg.timestamp ?? 0) > lastRead
94-
);
77+
// Use stable reference to prevent unnecessary re-renders when values haven't changed
78+
const unreadStatus = useStableReference(
79+
() => {
80+
const map = new Map<string, boolean>();
81+
82+
for (const [workspaceId, state] of workspaceStates) {
83+
// Streaming workspaces are never unread
84+
if (state.canInterrupt) {
85+
map.set(workspaceId, false);
86+
continue;
87+
}
9588

96-
next.set(workspaceId, hasUnread);
97-
}
89+
// Check for any assistant-originated content newer than last-read timestamp:
90+
// assistant text, tool calls/results (e.g., propose_plan), reasoning, and errors.
91+
// Exclude user's own messages and UI markers.
92+
const lastRead = lastReadMap[workspaceId] ?? 0;
93+
const hasUnread = state.messages.some(
94+
(msg) =>
95+
msg.type !== "user" && msg.type !== "history-hidden" && (msg.timestamp ?? 0) > lastRead
96+
);
9897

99-
// Return previous Map reference if nothing actually changed to keep identity stable
100-
const prev = unreadStatusRef.current;
101-
if (prev.size === next.size) {
102-
let same = true;
103-
for (const [k, v] of next) {
104-
if (prev.get(k) !== v) {
105-
same = false;
106-
break;
107-
}
108-
}
109-
if (same) {
110-
return prev;
98+
map.set(workspaceId, hasUnread);
11199
}
112-
}
113100

114-
unreadStatusRef.current = next;
115-
return next;
116-
}, [workspaceStates, lastReadMap]);
101+
return map;
102+
},
103+
compareMaps,
104+
[workspaceStates, lastReadMap]
105+
);
117106

118107
// Manual toggle function for clicking the indicator
119108
const toggleUnread = useCallback(

0 commit comments

Comments
 (0)