Skip to content

Commit 42c68c9

Browse files
authored
refactor: migrate MessageComposer state to a per-instance Zustand store (#7381)
1 parent 5f7e5bd commit 42c68c9

2 files changed

Lines changed: 321 additions & 134 deletions

File tree

Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
1+
import React from 'react';
2+
import { act, render } from '@testing-library/react-native';
3+
4+
import { type IShareAttachment } from '../../definitions';
5+
import {
6+
MessageComposerProvider,
7+
useAlsoSendThreadToChannel,
8+
useAutocompleteParams,
9+
useComposerAttachments,
10+
useFocused,
11+
useMessageComposerApi,
12+
useMicOrSend,
13+
useRecordingAudio,
14+
useShowMarkdownToolbar
15+
} from './context';
16+
17+
type Api = ReturnType<typeof useMessageComposerApi>;
18+
19+
// Tests for the composer store's public hook/provider contract and its per-slice re-render granularity.
20+
// Non-obvious constraint: the `actions` bag must stay a stable reference — a selector returning a fresh object
21+
// each render trips zustand v5's snapshot-equality loop. Renders are counted with jest.fn spies, not an outer
22+
// counter (a spy call in render is pure; mutation isn't).
23+
24+
// One probe per `probes` entry (name -> hook) plus an api probe; returns the stable api and per-probe render/value readers.
25+
const renderComposer = (probes: Record<string, () => unknown>) => {
26+
const probeSpies: Record<string, jest.Mock> = {};
27+
const apiSpy = jest.fn();
28+
29+
const probeElements = Object.entries(probes).map(([name, useHook]) => {
30+
const spy = jest.fn();
31+
probeSpies[name] = spy;
32+
const Probe = () => {
33+
spy(useHook());
34+
return null;
35+
};
36+
return <Probe key={name} />;
37+
});
38+
39+
const ApiProbe = () => {
40+
apiSpy(useMessageComposerApi());
41+
return null;
42+
};
43+
44+
render(
45+
<MessageComposerProvider>
46+
<>
47+
{probeElements}
48+
<ApiProbe />
49+
</>
50+
</MessageComposerProvider>
51+
);
52+
53+
const renderCount = (name: string) => probeSpies[name].mock.calls.length;
54+
const latestValue = (name: string) => {
55+
const { calls } = probeSpies[name].mock;
56+
return calls[calls.length - 1]?.[0];
57+
};
58+
59+
// api is stable, so the first render's value is the one every test calls setters on.
60+
const [[api]] = apiSpy.mock.calls as [Api][];
61+
return { api, renderCount, latestValue };
62+
};
63+
64+
const attachment = (path: string, extra?: Partial<IShareAttachment>): IShareAttachment => ({
65+
filename: `${path}.png`,
66+
size: 1,
67+
path,
68+
...extra
69+
});
70+
71+
// One row per scalar slice; each runs the same set→read + granularity tests. (attachments is a list slice, tested below.)
72+
type ScalarCase = {
73+
name: string;
74+
useHook: () => unknown;
75+
initial: unknown;
76+
mutate: (api: Api) => void;
77+
next: unknown;
78+
};
79+
80+
const SCALAR_SLICES: ScalarCase[] = [
81+
{ name: 'focused', useHook: useFocused, initial: false, mutate: api => api.setFocused(true), next: true },
82+
{ name: 'micOrSend', useHook: useMicOrSend, initial: 'mic', mutate: api => api.setMicOrSend('send'), next: 'send' },
83+
{
84+
name: 'showMarkdownToolbar',
85+
useHook: useShowMarkdownToolbar,
86+
initial: false,
87+
mutate: api => api.setMarkdownToolbar(true),
88+
next: true
89+
},
90+
{
91+
name: 'alsoSendThreadToChannel',
92+
useHook: useAlsoSendThreadToChannel,
93+
initial: false,
94+
mutate: api => api.setAlsoSendThreadToChannel(true),
95+
next: true
96+
},
97+
{ name: 'recordingAudio', useHook: useRecordingAudio, initial: false, mutate: api => api.setRecordingAudio(true), next: true },
98+
{
99+
name: 'autocompleteParams',
100+
useHook: useAutocompleteParams,
101+
initial: { text: '', type: null },
102+
mutate: api => api.setAutocompleteParams({ text: '@ro', type: '@' }),
103+
next: { text: '@ro', type: '@' }
104+
}
105+
];
106+
107+
describe('MessageComposer state container', () => {
108+
describe('per-instance isolation', () => {
109+
it('keeps state independent across provider instances', () => {
110+
const spyA = jest.fn();
111+
const spyB = jest.fn();
112+
113+
const Probe = ({ spy }: { spy: jest.Mock }) => {
114+
spy({ focused: useFocused(), api: useMessageComposerApi() });
115+
return null;
116+
};
117+
118+
render(
119+
<>
120+
<MessageComposerProvider>
121+
<Probe spy={spyA} />
122+
</MessageComposerProvider>
123+
<MessageComposerProvider>
124+
<Probe spy={spyB} />
125+
</MessageComposerProvider>
126+
</>
127+
);
128+
129+
const latest = (spy: jest.Mock): { focused: boolean; api: Api } => spy.mock.calls[spy.mock.calls.length - 1][0];
130+
131+
expect(latest(spyA).focused).toBe(false);
132+
expect(latest(spyB).focused).toBe(false);
133+
134+
// Mutating one instance must not leak into the other
135+
act(() => latest(spyA).api.setFocused(true));
136+
137+
expect(latest(spyA).focused).toBe(true);
138+
expect(latest(spyB).focused).toBe(false);
139+
140+
// And the reverse direction stays isolated too
141+
act(() => latest(spyB).api.setRecordingAudio(true));
142+
143+
expect(latest(spyA).focused).toBe(true);
144+
});
145+
});
146+
147+
describe('scalar slices', () => {
148+
describe.each(SCALAR_SLICES)('$name', ({ name, useHook, initial, mutate, next }) => {
149+
it('exposes the initial value, then reflects the update (set→read)', () => {
150+
const { api, latestValue } = renderComposer({ [name]: useHook });
151+
152+
expect(latestValue(name)).toEqual(initial);
153+
154+
act(() => mutate(api));
155+
156+
expect(latestValue(name)).toEqual(next);
157+
});
158+
159+
it('re-renders its own consumer but not an unrelated one when it changes (granularity)', () => {
160+
// attachments is the control slice — it is never the scalar under test
161+
const { api, renderCount } = renderComposer({ [name]: useHook, attachments: useComposerAttachments });
162+
163+
const sliceBaseline = renderCount(name);
164+
const controlBaseline = renderCount('attachments');
165+
166+
act(() => mutate(api));
167+
168+
expect(renderCount(name)).toBeGreaterThan(sliceBaseline);
169+
expect(renderCount('attachments')).toBe(controlBaseline);
170+
});
171+
});
172+
});
173+
174+
describe('attachments', () => {
175+
it('add appends attachments in order', () => {
176+
const { api, latestValue } = renderComposer({ attachments: useComposerAttachments });
177+
178+
expect(latestValue('attachments')).toEqual([]);
179+
180+
act(() => api.addAttachments([attachment('a')]));
181+
act(() => api.addAttachments([attachment('b')]));
182+
183+
expect((latestValue('attachments') as IShareAttachment[]).map(a => a.path)).toEqual(['a', 'b']);
184+
});
185+
186+
it('update merges a patch into the matching attachment by path, leaving others untouched', () => {
187+
const { api, latestValue } = renderComposer({ attachments: useComposerAttachments });
188+
189+
act(() => api.addAttachments([attachment('a'), attachment('b')]));
190+
act(() => api.updateAttachment('a', { description: 'hello' }));
191+
192+
const result = latestValue('attachments') as IShareAttachment[];
193+
expect(result.find(a => a.path === 'a')).toMatchObject({ path: 'a', filename: 'a.png', description: 'hello' });
194+
expect(result.find(a => a.path === 'b')?.description).toBeUndefined();
195+
});
196+
197+
it('remove filters out the attachment with the matching path', () => {
198+
const { api, latestValue } = renderComposer({ attachments: useComposerAttachments });
199+
200+
act(() => api.addAttachments([attachment('a'), attachment('b')]));
201+
act(() => api.removeAttachment('a'));
202+
203+
expect((latestValue('attachments') as IShareAttachment[]).map(a => a.path)).toEqual(['b']);
204+
});
205+
206+
it('clear empties the attachments', () => {
207+
const { api, latestValue } = renderComposer({ attachments: useComposerAttachments });
208+
209+
act(() => api.addAttachments([attachment('a'), attachment('b')]));
210+
act(() => api.clearAttachments());
211+
212+
expect(latestValue('attachments')).toEqual([]);
213+
});
214+
215+
// Reverse of the table's granularity check: a list mutation must not re-render scalar consumers.
216+
it('does not re-render a scalar consumer when attachments change', () => {
217+
const { api, renderCount } = renderComposer({ attachments: useComposerAttachments, focused: useFocused });
218+
219+
const attachmentsBaseline = renderCount('attachments');
220+
const focusedBaseline = renderCount('focused');
221+
222+
act(() => api.addAttachments([attachment('a')]));
223+
224+
expect(renderCount('attachments')).toBeGreaterThan(attachmentsBaseline);
225+
expect(renderCount('focused')).toBe(focusedBaseline);
226+
});
227+
});
228+
229+
describe('actions reference stability', () => {
230+
it('keeps the same api reference across slice updates', () => {
231+
const probe = jest.fn();
232+
233+
// Subscribes to a changing slice AND the api, so the re-render is real — the api ref must still be identical.
234+
const Probe = () => {
235+
useFocused();
236+
probe(useMessageComposerApi());
237+
return null;
238+
};
239+
240+
render(
241+
<MessageComposerProvider>
242+
<Probe />
243+
</MessageComposerProvider>
244+
);
245+
246+
const callsBefore = probe.mock.calls.length;
247+
const ref1: Api = probe.mock.calls[callsBefore - 1][0];
248+
249+
act(() => ref1.setFocused(true));
250+
251+
expect(probe.mock.calls.length).toBeGreaterThan(callsBefore);
252+
const ref2: Api = probe.mock.calls[probe.mock.calls.length - 1][0];
253+
expect(ref2).toBe(ref1);
254+
});
255+
});
256+
});

0 commit comments

Comments
 (0)