Skip to content

Commit 0b1e03d

Browse files
authored
Merge pull request #893 from alan-turing-institute/fix/sector-select-hydration
fix: sector select never hydrates the saved value in the completion pane
2 parents 1167c9f + 34c5da9 commit 0b1e03d

3 files changed

Lines changed: 252 additions & 2 deletions

File tree

Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
2+
import userEvent from "@testing-library/user-event";
3+
import { vi } from "vitest";
4+
5+
// Real Radix Select, not the suite-wide mock (`src/__tests__/mocks/radix-ui-mocks.tsx`).
6+
// The bug this file guards against is specific to Radix's actual `Select` root: it
7+
// mirrors its controlled `value` onto a hidden native `<select>` for native form
8+
// semantics (`SelectBubbleInput`), and re-syncs that mirror by dispatching a native
9+
// "change" event whenever the controlled value prop changes — including the mount-time
10+
// change from empty to a hydrated value. The mock does not model that mirror, so it
11+
// cannot reproduce (or regress-guard) this failure mode; only the real Radix
12+
// implementation can.
13+
vi.unmock("@radix-ui/react-select");
14+
15+
const { useCaseInformation } = await import("@/hooks/use-case-information");
16+
const { CaseInformationSection } = await import("../case-information-section");
17+
18+
vi.mock("@/hooks/use-case-information", () => ({
19+
useCaseInformation: vi.fn(),
20+
}));
21+
22+
const mockedUseCaseInformation = vi.mocked(useCaseInformation);
23+
const FINANCIAL_SERVICES_PATTERN = /financial services/i;
24+
const LEGACY_MEDICAL_DEVICES_PATTERN = /medical devices \(legacy value\)/i;
25+
26+
function stubHook(overrides: Partial<ReturnType<typeof useCaseInformation>>) {
27+
mockedUseCaseInformation.mockReturnValue({
28+
forCaseId: "case-1",
29+
information: null,
30+
loading: false,
31+
saving: false,
32+
uploadingImage: false,
33+
save: vi.fn().mockResolvedValue(true),
34+
uploadFeatureImage: vi.fn().mockResolvedValue(true),
35+
removeFeatureImage: vi.fn().mockResolvedValue(true),
36+
...overrides,
37+
});
38+
}
39+
40+
describe("CaseInformationSection sector hydration (real Radix Select)", () => {
41+
beforeEach(() => {
42+
vi.clearAllMocks();
43+
});
44+
45+
// Deliberately the FIRST test in this file, as belt-and-braces — but this
46+
// is not load-bearing. The warning this assertion captures comes from
47+
// Radix's `@radix-ui/react-use-controllable-state` hook (see its
48+
// `console.warn` call, caller "Select"), which tracks "was controlled" in
49+
// a fresh `React.useRef` per component instance and carries no
50+
// module-scoped dedup state; it fires on every mount that flips
51+
// controlled/uncontrolled, regardless of what earlier tests in this file
52+
// triggered. (React DOM does have a module-scoped deduped version of this
53+
// warning — `didWarnUncontrolledToControlled` — but that guards native
54+
// elements directly, not this Radix-wrapped one.) Verified empirically by
55+
// moving this test to second position in the file: it still fails
56+
// correctly on a regression.
57+
it("never switches the Select between controlled and uncontrolled across the loading-to-hydrated transition", async () => {
58+
// Real-browser evidence (2026-08-19) showed the actual failure mode
59+
// wasn't the phantom-clear onValueChange this suite otherwise guards —
60+
// it was the Select's `value` prop itself flipping between `undefined`
61+
// (while loading, pre-hydration) and a string (post-hydration), which
62+
// React treats as a controlled/uncontrolled switch and can silently
63+
// drop. jsdom doesn't reproduce the drop (Radix's hidden native
64+
// `<select>` mirror timing differs from a real browser's), but it DOES
65+
// reproduce the warning React logs for the switch itself — so assert
66+
// on that directly, synchronously. React logs this one via
67+
// `console.error` for a native `<select>`, but Chromium categorises
68+
// the equivalent as `console.warn` — spy on both so this doesn't
69+
// depend on that implementation detail.
70+
const consoleError = vi
71+
.spyOn(console, "error")
72+
.mockImplementation(() => undefined);
73+
const consoleWarn = vi
74+
.spyOn(console, "warn")
75+
.mockImplementation(() => undefined);
76+
77+
stubHook({
78+
information: {
79+
description: "A worked example",
80+
authors: "Ada Lovelace",
81+
sector: "Financial Services",
82+
featureImageUrl: null,
83+
},
84+
});
85+
86+
render(<CaseInformationSection canEdit={true} caseId="case-1" />);
87+
await screen.findByTestId("case-information-form");
88+
await waitFor(() => {
89+
expect(screen.getByRole("combobox")).toHaveTextContent(
90+
FINANCIAL_SERVICES_PATTERN
91+
);
92+
});
93+
94+
const isControlledSwitchWarning = (call: unknown[]) =>
95+
String(call[0]).includes("is changing from uncontrolled to controlled") ||
96+
String(call[0]).includes("is changing from controlled to uncontrolled");
97+
const controlledSwitchWarnings = [
98+
...consoleError.mock.calls,
99+
...consoleWarn.mock.calls,
100+
].filter(isControlledSwitchWarning);
101+
expect(controlledSwitchWarnings).toHaveLength(0);
102+
103+
consoleError.mockRestore();
104+
consoleWarn.mockRestore();
105+
});
106+
107+
it("shows a saved canonical sector on the closed trigger without any interaction", async () => {
108+
stubHook({
109+
information: {
110+
description: "A worked example",
111+
authors: "Ada Lovelace",
112+
sector: "Financial Services",
113+
featureImageUrl: null,
114+
},
115+
});
116+
117+
render(<CaseInformationSection canEdit={true} caseId="case-1" />);
118+
await screen.findByTestId("case-information-form");
119+
120+
await waitFor(() => {
121+
expect(screen.getByRole("combobox")).toHaveTextContent(
122+
FINANCIAL_SERVICES_PATTERN
123+
);
124+
});
125+
});
126+
127+
it("shows a saved legacy free-text sector, tagged '(legacy value)', on the closed trigger without any interaction", async () => {
128+
stubHook({
129+
information: {
130+
description: "A worked example",
131+
authors: "Ada Lovelace",
132+
sector: "Medical Devices",
133+
featureImageUrl: null,
134+
},
135+
});
136+
137+
render(<CaseInformationSection canEdit={true} caseId="case-1" />);
138+
await screen.findByTestId("case-information-form");
139+
140+
await waitFor(() => {
141+
expect(screen.getByRole("combobox")).toHaveTextContent(
142+
LEGACY_MEDICAL_DEVICES_PATTERN
143+
);
144+
});
145+
});
146+
147+
it("propagates a genuine user selection to the form value and the closed trigger", async () => {
148+
stubHook({
149+
information: {
150+
description: "A worked example",
151+
authors: "Ada Lovelace",
152+
sector: "",
153+
featureImageUrl: null,
154+
},
155+
});
156+
const user = userEvent.setup();
157+
158+
render(<CaseInformationSection canEdit={true} caseId="case-1" />);
159+
await screen.findByTestId("case-information-form");
160+
161+
const trigger = screen.getByRole("combobox");
162+
await user.click(trigger);
163+
await user.click(
164+
await screen.findByRole("option", { name: FINANCIAL_SERVICES_PATTERN })
165+
);
166+
167+
// This is the assertion the fix's premise rests on: a real user
168+
// selection (a genuine `onValueChange` call carrying a non-empty
169+
// value) must still reach `field.onChange` and update the trigger.
170+
// The empty-string guard added for the phantom-hydration bug only
171+
// filters `nextValue === ""` — it must never suppress this path.
172+
await waitFor(() => {
173+
expect(trigger).toHaveTextContent(FINANCIAL_SERVICES_PATTERN);
174+
});
175+
});
176+
177+
it("never clears an already-hydrated value on a phantom empty onValueChange (no clear affordance exists in this list)", async () => {
178+
stubHook({
179+
information: {
180+
description: "A worked example",
181+
authors: "Ada Lovelace",
182+
sector: "Financial Services",
183+
featureImageUrl: null,
184+
},
185+
});
186+
187+
const { container } = render(
188+
<CaseInformationSection canEdit={true} caseId="case-1" />
189+
);
190+
await screen.findByTestId("case-information-form");
191+
192+
const trigger = screen.getByRole("combobox");
193+
await waitFor(() => {
194+
expect(trigger).toHaveTextContent(FINANCIAL_SERVICES_PATTERN);
195+
});
196+
197+
// Reproduce the phantom event directly, rather than relying on mount
198+
// timing: Radix's hidden `SelectBubbleInput` mirrors the controlled
199+
// value onto a real native `<select aria-hidden>` and wires its
200+
// `onChange` straight to `context.onValueChange` (see
201+
// @radix-ui/react-select's `Select` — `onChange: (event) =>
202+
// setValue(event.target.value)`). Firing a native "change" with an
203+
// empty value on that element is exactly the event our guard exists
204+
// to ignore.
205+
const nativeSelect = container.querySelector('select[aria-hidden="true"]');
206+
expect(nativeSelect).not.toBeNull();
207+
if (nativeSelect) {
208+
fireEvent.change(nativeSelect, { target: { value: "" } });
209+
}
210+
211+
// The guard in case-information-section.tsx's `onValueChange` treats
212+
// any empty-string callback as the phantom mount-time sync described
213+
// above and ignores it, because no `SelectItem value=""` exists in
214+
// this list — there is no "clear sector" affordance a user could have
215+
// triggered instead. If a Clear control is ever added, it will need
216+
// its own non-empty sentinel (not "") or this guard will silently eat
217+
// its event too — see the comment at that guard.
218+
expect(trigger).toHaveTextContent(FINANCIAL_SERVICES_PATTERN);
219+
});
220+
});

‎components/cases/case-information-section.tsx‎

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -293,8 +293,36 @@ export function CaseInformationSection({
293293
<FormItem>
294294
<FormLabel>Sector</FormLabel>
295295
<Select
296-
onValueChange={field.onChange}
297-
value={currentValue || undefined}
296+
onValueChange={(nextValue) => {
297+
// Radix's Select mirrors its controlled value onto a
298+
// hidden native <select> (for native form semantics —
299+
// see its `SelectBubbleInput`), and re-syncs that mirror
300+
// with a dispatched native "change" event whenever the
301+
// controlled value prop changes — including the change
302+
// from empty to a hydrated value on mount. If that sync
303+
// runs before the mirror's own <option> for the new
304+
// value has committed, the native element silently
305+
// falls back to "", and the bubbled event reports value
306+
// "" — overwriting the value `reset()` just hydrated in
307+
// with a phantom clear. There is no real SelectItem for
308+
// "" (no "clear" affordance in this list), so an
309+
// empty-string callback can only be that phantom event,
310+
// never a genuine user selection — safe to ignore.
311+
if (nextValue) {
312+
field.onChange(nextValue);
313+
}
314+
}}
315+
// Always pass a string (never `undefined`), even before
316+
// hydration — Radix's hidden native <select> mirror
317+
// (`SelectBubbleInput`) receives this value directly, and
318+
// React warns (and can drop the value) if a form element
319+
// switches between an uncontrolled (`undefined`) and
320+
// controlled (string) value across renders. An empty
321+
// string is a perfectly valid controlled value here: it
322+
// matches no `SelectItem`, so Radix shows the placeholder,
323+
// exactly like `undefined` did — but without ever being
324+
// uncontrolled.
325+
value={currentValue}
298326
>
299327
<FormControl>
300328
<SelectTrigger ref={field.ref}>

‎lib/sectors.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ export interface Sector {
1515
Name: string;
1616
}
1717

18+
// Empty string ("") is a reserved sentinel for "no sector selected" — never add a
19+
// sector with that value here; see the onValueChange guard in case-information-section.tsx.
1820
export const sectors: Sector[] = [
1921
{
2022
ID: 1,

0 commit comments

Comments
 (0)