fix(components): keep field values controlled from the first render - #3078
fix(components): keep field values controlled from the first render#3078mfal wants to merge 7 commits into
Conversation
Coverage Report for ./packages/components/
File CoverageNo changed files found. |
|
Dispatched independently on #3027 ( Verified independently
One nit in the diff
const [value, setValue] = useState(
regularValue !== undefined
? regularValue
: defaultValue !== undefined
? defaultValue
: emptyValue,
);The marker cannot appear on the first render (it is only sent once a remote event has been handled), so Missing: coverage of the remote path, and of the other seven fieldsThis PR tests
Both are on
One finding from writing that file, worth knowing: in the Issue numbersThe body proves Also worth the Recommendation: merge this one, with the nit and the two test files folded in. |
🚀 Preview DeploymentPreview environments are ready:
Images:
|
✅ Visual Regression Tests PassedAll visual snapshots match the committed baselines. |
|
Landed everything from the review above onto this branch, so nothing needs cherry-picking any more:
Both test suites coexist — the cherry-pick did not touch Gates on the merged result: Body updated: both The dropped-keystroke observation from the review is now #3088, out of scope here and referenced from the body. |
`useControlledHostValueProps` owns a field's value from the first change on, but started its mirror state as `undefined` when the field rendered with neither `value` nor `defaultValue`. react-aria then got `undefined` first and a real value on the first keystroke: `useControlledState` reads only `undefined` as uncontrolled, warns about the transition, and the value changes owner mid-flight from the DOM input to the hook. The hook now takes the field's empty value and is controlled from render one — `""` for the text inputs, `NaN` for `NumberField`, `null` for `DateRangePicker`, matching what react-aria's own state uses for "nothing entered". Behaviour after the first change is unchanged; the input was already rendered from `useControlledState` either way. This is what produced the nine `uncontrolled to controlled` warnings reported against `Modal` — every one came from the `TextField` inside a `Field` without a `defaultValue`, not from the modal's open state. Across the browser suite the warnings drop from 20 to 4: 2 are the `Tabs` defect fixed in #3025, 2 are `Field` feeding `selectedKey` to `Select`, which needs its own decision about overriding `defaultSelectedKey`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two files, both red before the fix in `useControlledHostValueProps`: - `useControlledHostValueProps.browser.test.tsx` drives all eight fields that run the hook, uncontrolled and controlled, and fails on any `uncontrolled to controlled` message from react-stately or React DOM. Seven of the eight warned before the fix — `CodeEditor` did not, because CodeMirror does not use `useControlledState`. - `RemoteControlledValue.browser.test.tsx` covers the reason the hook exists. The mirror is what makes the host ignore the echo of its own keystrokes (`controlledRemoteValueMarker`), so a change to the mirror is a change to that protection. Asserted in both environments: everything typed survives, a value the remote app sets itself still arrives, and neither a controlled nor an uncontrolled remote field crosses the line. Dropping the marker filter puts the literal marker into the host input, which fails three of the four tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ue fallback
`regularValue ?? defaultValue ?? emptyValue` falls back on `null` as well
as on `undefined`, but only `undefined` means uncontrolled to react-aria.
`null` is how a caller controls a `DateRangePicker` with no range
selected, so a caller passing `value={null}` together with a
`defaultValue` rendered the `defaultValue` for one commit, until the
layout effect below replaced it with `null`.
Fall back on `undefined` only.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…g its element `Locator.element()` is typed `HTMLElement | SVGElement`, so reading `.value` off it fails `test:compile` with TS2339 — the package's own `test:compile`, which is not covered by running the one in `components`. Assert through `expect.element(locator).toHaveDisplayValue(…)` instead of `expect.poll(() => locator.element().value)`. That is what the other browser tests use for an input's value (`TextField`, `MarkdownEditor`, `Form`, `ResetButton`), it needs no cast, and it keeps the retrying behaviour the `Remote` environment depends on. All four call sites resolve the same `input` locator. What they assert is unchanged: with the marker filter removed, the literal `___flowControlledRemoteValue___` still reaches the host input and three of the four tests still fail. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A diff that touches a second package leaves that package's `test:compile` unrun, and its browser tests passing says nothing about types — vitest transpiles without typechecking. So the only gate that reads types there is the one a per-package run skips, and the error surfaces in CI instead. Cost that exact round trip on this PR. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ec5bda9 to
4eb500c
Compare
The rationale for `?? null` was a 14-line block comment in `Tabs.tsx`, and #3078 carries the same react-aria fact again in `useControlledHostValueProps`. Two copies in two files that are never read together. Move it to one bullet in the package's non-obvious conventions: react-aria's `useControlledState` reads only `undefined` as uncontrolled, so a component that mirrors the value in its own state has to pass a sentinel. The bullet covers both sentinel shapes (`null` for "nothing selected yet", the type's empty value otherwise), the `??` trap once the sentinel is `null`, and the silent case (`CodeEditor` changes owner without warning). The comment at the call site keeps what a reader needs there: which value keeps it controlled, a pointer to the convention, and why the cast exists. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The hook explained react-aria's controlled/uncontrolled rule in full, and #3048 explained the same rule again in `Tabs.tsx`. That rationale now lives in one bullet in the package's AGENTS.md (added by #3048), so both call sites can stop carrying a copy. What stays here is what a reader needs at this spot: what `emptyValue` is per field type, and why the initialisation spells out `!== undefined` instead of `??` — a caller-supplied `null` controls a `DateRangePicker` with no range selected and must not fall through to `defaultValue`. Comment-only; no behaviour change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every Flow field component that renders without
valueand withoutdefaultValueflipped from uncontrolled to controlled on its first change. This is what produced the nineWARN: A component changed from uncontrolled to controlled.warnings reported fromModal.browser.test.tsx.The defect
useControlledHostValueProps(packages/components/src/lib/remote/useControlledHostValueProps.ts) mirrors a field's value in state and hands that state to react-aria. ItsonChangewrites the state unconditionally, so the hook owns the value from the first change on — but the state started asregularValue ?? defaultValue, i.e.undefinedfor an uncontrolled field.react-aria's
useControlledStatereads onlyundefinedas uncontrolled. So the field gotundefinedon render one and a real value on the first keystroke: it warns about the transition, and the value silently changes owner mid-flight, from the DOM input to the hook.Eight components run this hook:
TextField,TextArea,SearchField,NumberField,PasswordCreationField,MarkdownEditor,CodeEditor,DateRangePicker. Seven of them warn.CodeEditordoes not, because CodeMirror keeps its own document state and never callsuseControlledState— it is affected by the same ownership change, just silently.Modalis not involved — #3026's premise is disproven#3026 read the warnings as
Modal's ownisOpenflipping between its prop path and its controller path. It does not, and there is no separateModaldefect:Overlay.tsx:68resolvesisOpenasisOpenFromProps ?? controller.useIsOpen(),OverlayController.isOpenis initialisedfalse(OverlayController.ts:54), anduseIsOpen()returns it throughuseSelector. SoisOpenis a defined boolean on every render, on both paths — it never crosses the line.Measured with
console.warninstrumented to print the emitting test plus a stack:<Modal isOpen>, then a rerender toisOpen={false}: 0 warnings.<Modal controller={…}>opened via the controller: 0 warnings.<ModalTrigger><Modal>opened by its button: 0 warnings.Form+Field name="x"+TextField, no modal anywhere: 1 warning.<TextField />typed into, no form, no modal: 1 warning.All nine warnings in
Modal.browser.test.tsxcome from theTextFieldinside aFieldwithout adefaultValue— they share #3027's root cause, and they are gone with this fix. The four Modal tests that passdefaultValue=""never emitted any. #3026 is therefore closed by this PR as the symptom it reported, not as a defect of its own.The judgement call
Which side of the line the fields land on. Controlled from the first render.
The hook already makes every field controlled from the first change on — that is its purpose, protecting a remotely driven value from marker values that must be ignored. Starting controlled only extends what already holds for the rest of the field's life, and it is the smaller change: react-aria renders the input from
useControlledState's current value either way, so nothing about the rendered output moves.The alternative — stay uncontrolled until a
valueactually arrives — hands ownership back to react-aria. That is architecturally cleaner but changes far more (external resets anddefaultValuestart flowing through react-aria again), and it breaks three consumers outright, all of which read the mirrored value themselves:MarkdownEditorrenders it in preview mode (MarkdownEditor.tsx:157) and feeds it tomodifyValueByMarkdownSyntaxfor the toolbar actions.CodeEditorfeeds it to itsCopyButton(CodeEditor.tsx:199).PasswordCreationFieldvalidates it against the password policy —usePolicyValidationResult(validationPolicy, value ?? "", …)(:131) andisEmptyValue = !value(:162). Without a defined mirror, the strength meter and the policy errors go dead for every uncontrolled password field.Being controlled from render one needs a defined empty value, which is per type and cannot be derived generically. The hook now takes it as an argument, matching what react-aria's own state uses for "nothing entered":
""for the text inputs,NaNforNumberField(useNumberFieldState),nullforDateRangePicker(useDateRangePickerState). Because those are react-aria's own uncontrolled fallbacks, the rendered output is identical either way for all three types.The fallback is on
undefinedonly, not??:nullis how a caller controls aDateRangePickerwith no range selected, and??let it fall through todefaultValuefor one commit.Verification
pnpm nx test:browser components --browser.name=webkit— 39 files, 280 tests pass. Zerouncontrolled to controlledwarnings remain inModal.browser.test.tsx.pnpm nx test:browser remote-react-components --browser.name=webkit— 5 files, 18 tests pass.pnpm nx test:unit components— 253 pass.pnpm nx test:compile components— clean.pnpm lint— 0 errors.pnpm buildover the workspace leavesgit statusclean: no@flr-generatecomponent's props changed, so there is nothing to regenerate.Tests, all red before the fix
TextField stays controlled across the first changepins the root cause;Modal and its fields stay on one side of the controlled linepins Modal flips isOpen from uncontrolled to controlled between its prop and controller paths #3026's reproduction.useControlledHostValueProps.browser.test.tsxdrives all eight fields, uncontrolled and controlled, and fails onuncontrolled to controlledfrom react-stately or React DOM. 7 of 8 were red per browser before the fix, which is how theCodeEditordetail above was established.RemoteControlledValue.browser.test.tsxcovers the reason the hook exists at all. The remote echo path had no test whatsoever: with the marker filter removed, the literal string___flowControlledRemoteValue___reaches the host input and the entire existing browser suite stays green. The new file asserts, in both theLocalandRemoteenvironments, that everything typed survives, that a value the remote app sets itself still arrives (the non-marker path must not be swallowed), and that neither a controlled nor an uncontrolled remote field crosses the line. Removing the marker filter fails three of its four tests.One
AGENTS.mdrowThe compile fix in
aaee11a79exists becausetest:compilewas run forcomponentsbut not forremote-react-components, where the new test file lives — and that package's browser tests passing said nothing about its types, since vitest transpiles without typechecking. So the only gate that reads types there is exactly the one a per-package run skips, and the error surfaced in CI.ec5bda925adds that as one row to theCommon failurestable, in its own commit so it reads separately from the fix. Nothing else inAGENTS.md.Deliberately out of scope
Tabs.browser.test.tsx— theTabsdefect fixed in fix(components): keep overlay trigger buttons inside their trigger #3025, not yet onmain.Field.browser.test.tsx—FieldpassesselectedKey: valuetoSelect,undefineduntil the form has a value. The?? nullfix from fix(components): keep overlay trigger buttons inside their trigger #3025 applies, but it would also override aSelect's owndefaultSelectedKey; that trade-off deserves its own issue rather than being folded in here.Remoteenvironment the remote side does not receive every keystroke: a host event that fires while a remote render is in flight is dropped, load-dependently. Pre-existing and unrelated to this change — filed as A host event fired during an in-flight remote render is dropped #3088. It is whyRemoteControlledValue.browser.test.tsxasserts that every reported value is a prefix of what was typed, rather than that the remote side saw the full text.fixes #3027
fixes #3026
🤖 Generated with Claude Code