CodeIssuesPull RequestsActionsSecurityInsights
✨ AI
More
Settings

fix: prevent infinite render loop crash in DictationPanel (React #185) #4744

Merged⚡ AI-generatedXSccantynz wants to mergeclaude/dazzling-shannon-ck8lmrmainopened Jun 13, 2026
2 changed files+84−1
Modifiedsrc/components/dictation/DictationPanel.tsx+6−1View fileUnifiedSplit
2222import { Badge } from "@/components/ui/Badge";
2323import { Waveform } from "./Waveform";
2424import { TranscriptView } from "./TranscriptView";
25import { useShallow } from "zustand/react/shallow";
2526import { useDictationStore, buildSessionRecord, loadDraftRecord } from "@/stores/dictation";
2627import { useAudioStore } from "@/stores/audio";
2728import { useSettingsStore } from "@/stores/settings";
309310 const autoDetectLanguage = useSettingsStore((s) => s.autoDetectLanguage);
310311 const currentLang = SUPPORTED_LANGUAGES.find((l) => l.code === sttLanguage) ?? SUPPORTED_LANGUAGES[0];
311312 const activeClientId = useClientsStore((s) => s.activeClientId);
312 const allClients = useClientsStore((s) => s.clients.filter((c) => !c.archived));
313 // useShallow memoises the derived array by shallow equality. Without it,
314 // `.filter()` returns a brand-new array reference on every render, which makes
315 // Zustand v5's useSyncExternalStore snapshot change every render and triggers
316 // an infinite update loop (React error #185 — "Maximum update depth exceeded").
317 const allClients = useClientsStore(useShallow((s) => s.clients.filter((c) => !c.archived)));
313318 const activeClient = allClients.find((c) => c.id === activeClientId) ?? null;
314319 const setActiveClient = useClientsStore((s) => s.setActiveClient);
315320
Addedsrc/components/dictation/clientSelector.test.tsx+78−0View fileUnifiedSplit
1import { describe, it, expect, beforeEach } from "vitest";
2import { renderHook, act } from "@testing-library/react";
3import { useShallow } from "zustand/react/shallow";
4import { useClientsStore } from "@/stores/clients";
5
6/**
7 * Regression test for React error #185 ("Maximum update depth exceeded").
8 *
9 * DictationPanel selects the non-archived clients with:
10 * useClientsStore(useShallow((s) => s.clients.filter((c) => !c.archived)))
11 *
12 * Under Zustand v5 (useSyncExternalStore), a selector that calls `.filter()`
13 * without `useShallow` allocates a brand-new array reference on every render —
14 * even `[].filter()` returns a fresh `[]` that is not `Object.is`-equal to the
15 * previous one. React's snapshot consistency check then re-renders forever and
16 * throws error #185, crashing the Dictation view. `useShallow` memoises the
17 * derived array by shallow equality, keeping the reference stable.
18 */
19describe("clients useShallow selector — React #185 regression", () => {
20 beforeEach(() => {
21 useClientsStore.setState({ clients: [], activeClientId: null, entries: [] });
22 });
23
24 const selectActive = () =>
25 useClientsStore(useShallow((s) => s.clients.filter((c) => !c.archived)));
26
27 it("returns a stable reference across re-renders when state is unchanged", () => {
28 const { result, rerender } = renderHook(selectActive);
29 const first = result.current;
30 rerender();
31 rerender();
32 // A new reference here would mean an infinite update loop in the real app.
33 expect(result.current).toBe(first);
34 });
35
36 it("stays stable with the empty array (the unconditional crash case)", () => {
37 const { result, rerender } = renderHook(selectActive);
38 expect(result.current).toHaveLength(0);
39 const first = result.current;
40 rerender();
41 expect(result.current).toBe(first);
42 });
43
44 it("produces a new reference only when the underlying clients change", () => {
45 const { result } = renderHook(selectActive);
46 const before = result.current;
47
48 act(() => {
49 useClientsStore.getState().addClient({
50 name: "Acme Corp",
51 billableRate: 0,
52 color: "#7345d1",
53 });
54 });
55
56 expect(result.current).not.toBe(before);
57 expect(result.current).toHaveLength(1);
58 expect(result.current[0].name).toBe("Acme Corp");
59 });
60
61 it("excludes archived clients", () => {
62 let id = "";
63 act(() => {
64 id = useClientsStore.getState().addClient({
65 name: "Old Matter",
66 billableRate: 0,
67 color: "#ef4444",
68 });
69 });
70 const { result } = renderHook(selectActive);
71 expect(result.current).toHaveLength(1);
72
73 act(() => {
74 useClientsStore.getState().archiveClient(id);
75 });
76 expect(result.current).toHaveLength(0);
77 });
78});
079
c comment · e edit title · m merge · a approve · r request changes · ? shortcuts