Skip to content

Commit 79fc78a

Browse files
authored
Merge pull request #1302 from david87131/fix/issues-1227-1224-1222-1208
fix: resolve stale closures, memory leak, and add split-view telemetry
2 parents 0f2ba61 + 6430a19 commit 79fc78a

9 files changed

Lines changed: 506 additions & 24 deletions

File tree

Dechat/dex_with_fiat_frontend/src/hooks/__tests__/useSplitView.test.ts

Lines changed: 94 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
import { describe, expect, it } from 'vitest';
1+
import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest';
22
import { ChatSession } from '@/types';
33
import { useSplitView } from '@/hooks/useSplitView';
44
import { renderHook, act } from '@testing-library/react';
5+
import { setTelemetryConsent, type ChatEvent, type SplitViewTelemetryPayload } from '@/lib/chatTelemetry';
56

67
function makeSession(id: string, title = 'Session'): ChatSession {
78
const now = new Date();
@@ -120,3 +121,95 @@ describe('useSplitView – state management', () => {
120121
expect(result.current.state.selectedMessageId).toBeNull();
121122
});
122123
});
124+
125+
describe('useSplitView – structured telemetry (#1208)', () => {
126+
function captureEvents(): { events: ChatEvent<SplitViewTelemetryPayload>[] } {
127+
const box = { events: [] as ChatEvent<SplitViewTelemetryPayload>[] };
128+
window.addEventListener('chat:telemetry', ((e: Event) => {
129+
const detail = (e as CustomEvent<ChatEvent<SplitViewTelemetryPayload>>).detail;
130+
if (detail.name === 'split_view') {
131+
box.events.push(detail);
132+
}
133+
}) as EventListener);
134+
return box;
135+
}
136+
137+
beforeEach(() => {
138+
setTelemetryConsent(true);
139+
// `chatTelemetry`'s emitter defers the actual `chat:telemetry`
140+
// CustomEvent dispatch to `requestAnimationFrame` to avoid blocking
141+
// renders. Run the callback synchronously here so tests don't depend on
142+
// real frame timing (which is flaky/inconsistent across jsdom/happy-dom
143+
// versions when multiple events are scheduled back to back).
144+
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => {
145+
cb(0);
146+
return 0;
147+
});
148+
});
149+
150+
afterEach(() => {
151+
vi.restoreAllMocks();
152+
});
153+
154+
it('emits an "open" event with the session ids when the panel opens', () => {
155+
const box = captureEvents();
156+
const { result } = renderHook(() => useSplitView(sessions));
157+
158+
act(() => {
159+
result.current.open('s1', 's2');
160+
});
161+
162+
expect(box.events).toHaveLength(1);
163+
expect(box.events[0].payload).toMatchObject({
164+
action: 'open',
165+
leftSessionId: 's1',
166+
rightSessionId: 's2',
167+
});
168+
});
169+
170+
it('emits a "close" event when the panel closes', () => {
171+
const box = captureEvents();
172+
const { result } = renderHook(() => useSplitView(sessions));
173+
174+
act(() => {
175+
result.current.open('s1', 's2');
176+
});
177+
act(() => {
178+
result.current.close();
179+
});
180+
181+
const closeEvents = box.events.filter((e) => e.payload.action === 'close');
182+
expect(closeEvents).toHaveLength(1);
183+
});
184+
185+
it('emits a "swap_sessions" event with the post-swap session ids', () => {
186+
const box = captureEvents();
187+
const { result } = renderHook(() => useSplitView(sessions));
188+
189+
act(() => {
190+
result.current.open('s1', 's2');
191+
});
192+
act(() => {
193+
result.current.swapSessions();
194+
});
195+
196+
const swapEvents = box.events.filter((e) => e.payload.action === 'swap_sessions');
197+
expect(swapEvents).toHaveLength(1);
198+
expect(swapEvents[0].payload).toMatchObject({
199+
leftSessionId: 's2',
200+
rightSessionId: 's1',
201+
});
202+
});
203+
204+
it('does not emit telemetry without user consent', () => {
205+
setTelemetryConsent(false);
206+
const box = captureEvents();
207+
const { result } = renderHook(() => useSplitView(sessions));
208+
209+
act(() => {
210+
result.current.open('s1', 's2');
211+
});
212+
213+
expect(box.events).toHaveLength(0);
214+
});
215+
});

Dechat/dex_with_fiat_frontend/src/hooks/useChatPagination.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,39 @@ describe('useChatPagination', () => {
7777
}).not.toThrow();
7878
});
7979

80+
it('regression: uses the latest messages/pageSize when the pending timer fires, not the stale closure from when loadMore was called', () => {
81+
const initialMessages = createMessages(30);
82+
const { result, rerender } = renderHook(
83+
({ messages, pageSize }) => useChatPagination(messages, pageSize),
84+
{ initialProps: { messages: initialMessages, pageSize: 20 } },
85+
);
86+
87+
expect(result.current.visibleMessages).toHaveLength(20);
88+
89+
act(() => {
90+
result.current.loadMore();
91+
});
92+
93+
// Before the 400ms timer fires, several new messages arrive and the
94+
// list grows from 30 to 45. `getNextMessageCount` caps the next visible
95+
// count at the *total* message count it's given. A stale closure over
96+
// the original 30-message array would wrongly cap the next count at 30
97+
// (min(20 + 20, 30) = 30) even though 45 messages are now available -
98+
// under-showing 10 messages the user should be able to see immediately.
99+
const grownMessages = createMessages(45);
100+
rerender({ messages: grownMessages, pageSize: 20 });
101+
102+
act(() => {
103+
vi.advanceTimersByTime(500);
104+
});
105+
106+
// Correct: min(20 + 20, 45) = 40, computed against the *current*
107+
// message list at the time the timer fires - not the stale 30-message
108+
// list captured when loadMore() was called.
109+
expect(result.current.visibleMessages).toHaveLength(40);
110+
expect(result.current.visibleMessages[39].id).toBe('45');
111+
});
112+
80113
it('isLoadingMore resets to false after loadMore completes', () => {
81114
const messages = createMessages(50);
82115
const { result } = renderHook(() => useChatPagination(messages, 20));

Dechat/dex_with_fiat_frontend/src/hooks/useChatPagination.ts

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,31 @@ export const useChatPagination = (
2020
const [isLoadingMore, setIsLoadingMore] = useState(false);
2121
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
2222

23+
// Root cause (#1224): `loadMore` (below) closes over `allMessages` and
24+
// `pageSize` from the render in which it was created, then reads them
25+
// again 400ms later inside a `setTimeout` callback. If new messages
26+
// arrive, or the user switches sessions, while that timer is pending, the
27+
// callback still computes the next visible count against the *stale*
28+
// message list captured at call time - not the list that's actually on
29+
// screen when the timer fires. That can under- or over-count how many
30+
// messages should become visible after a session switch.
31+
//
32+
// Fixed by mirroring `allMessages`/`pageSize` into refs that are always
33+
// kept current, and reading from the refs inside the timeout callback
34+
// instead of the closed-over values - the same pattern already used for
35+
// `addToastRef` in `usePaystackWebhookStatus` and `messagesRef` in
36+
// `useChat`.
37+
const allMessagesRef = useRef(allMessages);
38+
const pageSizeRef = useRef(pageSize);
39+
40+
useEffect(() => {
41+
allMessagesRef.current = allMessages;
42+
}, [allMessages]);
43+
44+
useEffect(() => {
45+
pageSizeRef.current = pageSize;
46+
}, [pageSize]);
47+
2348
// Reset visible count when session changes (if we had a way to detect it)
2449
// Actually, useChat will manage messages per session, so we just react to allMessages length decreasing
2550
// (which happens on new chat or session switch)
@@ -54,10 +79,12 @@ export const useChatPagination = (
5479

5580
timerRef.current = setTimeout(() => {
5681
timerRef.current = null;
57-
setVisibleCount((prev: number) => getNextMessageCount(allMessages, prev, pageSize));
82+
setVisibleCount((prev: number) =>
83+
getNextMessageCount(allMessagesRef.current, prev, pageSizeRef.current),
84+
);
5885
setIsLoadingMore(false);
5986
}, 400);
60-
}, [hasMore, isLoadingMore, allMessages, pageSize]);
87+
}, [hasMore, isLoadingMore]);
6188

6289
return {
6390
visibleMessages,
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { describe, expect, it, vi } from 'vitest';
2+
import { renderHook } from '@testing-library/react';
3+
import { useMasking, SENSITIVE_TERMS_UPDATED_KEY } from './useMasking';
4+
5+
describe('useMasking', () => {
6+
it('returns unmasked text when disabled', () => {
7+
const { result } = renderHook(() =>
8+
useMasking('this is damn annoying', { enabled: false }),
9+
);
10+
expect(result.current).toBe('this is damn annoying');
11+
});
12+
13+
it('masks sensitive terms when enabled', () => {
14+
const { result } = renderHook(() =>
15+
useMasking('this is damn annoying', { enabled: true }),
16+
);
17+
expect(result.current).not.toContain('damn');
18+
});
19+
20+
it('regression: cleans up its storage listener on unmount (no leaked subscription)', () => {
21+
const addSpy = vi.spyOn(window, 'addEventListener');
22+
const removeSpy = vi.spyOn(window, 'removeEventListener');
23+
24+
const { unmount } = renderHook(() => useMasking('hello world', { enabled: true }));
25+
26+
const storageAddCalls = addSpy.mock.calls.filter(([type]) => type === 'storage').length;
27+
expect(storageAddCalls).toBe(1);
28+
29+
unmount();
30+
31+
const storageRemoveCalls = removeSpy.mock.calls.filter(([type]) => type === 'storage').length;
32+
33+
// Before the fix, there was no cleanup function returned from the
34+
// effect, so `removeEventListener('storage', ...)` was never called and
35+
// this assertion would fail (0 !== 1) after unmount.
36+
expect(storageRemoveCalls).toBe(1);
37+
38+
addSpy.mockRestore();
39+
removeSpy.mockRestore();
40+
});
41+
42+
it('regression: repeated mount/unmount cycles do not accumulate listeners', () => {
43+
const addSpy = vi.spyOn(window, 'addEventListener');
44+
const removeSpy = vi.spyOn(window, 'removeEventListener');
45+
46+
for (let i = 0; i < 5; i += 1) {
47+
const { unmount } = renderHook(() => useMasking('hello world', { enabled: true }));
48+
unmount();
49+
}
50+
51+
const totalAdds = addSpy.mock.calls.filter(([type]) => type === 'storage').length;
52+
const totalRemoves = removeSpy.mock.calls.filter(([type]) => type === 'storage').length;
53+
54+
expect(totalAdds).toBe(5);
55+
expect(totalRemoves).toBe(5);
56+
57+
addSpy.mockRestore();
58+
removeSpy.mockRestore();
59+
});
60+
61+
it('rebuilds the masking manager when a sensitive-terms storage event fires', () => {
62+
const { result, rerender } = renderHook(
63+
({ text }) => useMasking(text, { enabled: true }),
64+
{ initialProps: { text: 'this is damn annoying' } },
65+
);
66+
67+
expect(result.current).not.toContain('damn');
68+
69+
// Simulate another tab/settings screen signalling that the sensitive
70+
// terms configuration changed; this should not throw and the hook
71+
// should keep functioning (manager rebuilt via refreshToken).
72+
window.dispatchEvent(
73+
new StorageEvent('storage', { key: SENSITIVE_TERMS_UPDATED_KEY }),
74+
);
75+
76+
rerender({ text: 'this is damn annoying' });
77+
expect(result.current).not.toContain('damn');
78+
});
79+
});

Dechat/dex_with_fiat_frontend/src/hooks/useMasking.ts

Lines changed: 65 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
import { SensitiveTermsManager } from '@/lib/sensitiveTerms';
66
import { MaskingStyle, maskText } from '@/lib/textMasking';
7-
import { useMemo } from 'react';
7+
import { useEffect, useMemo, useState } from 'react';
88

99
export interface UseMaskingOptions {
1010
enabled: boolean;
@@ -13,26 +13,87 @@ export interface UseMaskingOptions {
1313
}
1414

1515
/**
16-
* Hook to mask sensitive terms in text based on user preferences
16+
* Storage key another part of the app (e.g. a compliance/settings screen
17+
* that lets a user edit their sensitive-terms list) writes to when the
18+
* masking configuration changes, so every open tab/component can refresh.
19+
*/
20+
export const SENSITIVE_TERMS_UPDATED_KEY = 'stellar_sensitive_terms_updated';
21+
22+
/**
23+
* Hook to mask sensitive terms in text based on user preferences.
24+
*
25+
* Root cause (#1222): every mounted instance of this hook needs to know
26+
* when the sensitive-terms configuration changes elsewhere (another tab, a
27+
* settings panel) so its memoized `manager`/`maskedText` can refresh. That
28+
* requires listening for a `storage` event on `window`. Wiring that up
29+
* without an unsubscribe path leaks one `storage` listener per mount - in a
30+
* chat view that can render dozens of `<Message>` components, and where
31+
* sessions/messages re-mount frequently, the listener count grows without
32+
* bound and each stale listener keeps a reference to that render's closure
33+
* alive, preventing it (and everything it captured) from being
34+
* garbage-collected.
35+
*
36+
* Fixed by registering the listener inside a `useEffect` with a cleanup
37+
* function that calls `removeEventListener` on unmount/re-subscribe, the
38+
* same pattern used elsewhere in this codebase (see `addToastRef` cleanup
39+
* in `usePaystackWebhookStatus`).
1740
*/
1841
export const useMasking = (
1942
text: string,
2043
{ enabled, style = 'asterisk', customTerms }: UseMaskingOptions,
2144
) => {
45+
// Bumped whenever we're told (via a `storage` event) that the
46+
// sensitive-terms configuration changed elsewhere, forcing the memoized
47+
// manager below to rebuild.
48+
const [refreshToken, setRefreshToken] = useState(0);
49+
50+
useEffect(() => {
51+
if (typeof window === 'undefined') {
52+
return undefined;
53+
}
54+
55+
const handleStorageChange = (event: StorageEvent) => {
56+
if (event.key === SENSITIVE_TERMS_UPDATED_KEY) {
57+
setRefreshToken((prev) => prev + 1);
58+
}
59+
};
60+
61+
window.addEventListener('storage', handleStorageChange);
62+
63+
// Cleanup: without this, every mount of a component using `useMasking`
64+
// (e.g. one per chat message) leaves a dangling `storage` listener
65+
// behind on unmount, leaking memory for the lifetime of the page.
66+
return () => {
67+
window.removeEventListener('storage', handleStorageChange);
68+
};
69+
}, []);
70+
2271
// Create or use provided manager
2372
const manager = useMemo(() => {
2473
if (customTerms instanceof SensitiveTermsManager) {
2574
return customTerms;
2675
}
2776
return new SensitiveTermsManager();
28-
}, [customTerms]);
77+
// `refreshToken` intentionally triggers a rebuild when the sensitive
78+
// terms configuration changes elsewhere; it does not affect the value
79+
// itself.
80+
// eslint-disable-next-line react-hooks/exhaustive-deps
81+
}, [customTerms, refreshToken]);
2982

3083
// Apply masking only if enabled
3184
const maskedText = useMemo(() => {
3285
if (!enabled) {
3386
return text;
3487
}
35-
return maskText(text, manager, style);
88+
try {
89+
return maskText(text, manager, style);
90+
} catch (error) {
91+
// Fail safe rather than silently: log so the failure is visible to
92+
// whoever is watching the console/error monitoring, and fall back to
93+
// the original text instead of throwing and breaking the chat view.
94+
console.error('useMasking: failed to mask sensitive text', error);
95+
return text;
96+
}
3697
}, [text, enabled, style, manager]);
3798

3899
return maskedText;

0 commit comments

Comments
 (0)