…filled (#2982)
The form renderer adopts a changed `defaultValues` with `form.reset()`, which
replaces the WHOLE react-hook-form record — so it also blanks every field the
incoming defaults say nothing about. And it runs in a PASSIVE effect, one commit
after those fields were committed and painted, so input landing in that window
was silently discarded.
PR #2983 fixed only the test that caught it. This is the product-side hole it
left open. The wizard reuses ONE inner form across steps and feeds it
`defaultValues={formData}` — the merge of the steps submitted SO FAR — so at
every step boundary the incoming defaults are missing exactly the fields now on
screen:
RESET to {"name":"Alice"} (values before: {"name":"Alice","note":"hello"})
-> create POST {"name":"Alice"} — the last step is gone
The reset now carries such a value across instead of dropping it. Deliberately
narrow: only a field the CALLER HAS NEVER CARRIED (absent from both the outgoing
and the incoming defaults) and whose value the user actually changed is eligible.
Wherever the caller has an opinion it stays authoritative, so all three
load-bearing paths keep today's behavior exactly:
- an edit-mode record landing after first paint still fills every field it
names — an untouched field is empty-ish against the baseline under the same
comparison the dirty check uses, so a widget normalizing its own empty value
on mount ('' -> null) is not mistaken for input. This is why RHF's
`dirtyFields`/`keepDirtyValues` could not be used: it flags exactly those
self-normalizing fields, and would have rejected the loaded record for them;
- a `recordId` swap still replaces the record outright — drawer/modal/split
forms re-fetch WITHOUT re-entering their loading branch, so record B lands in
the still-mounted form and must not inherit an abandoned edit to record A;
- a field the caller withdraws from its defaults stops being the user's.
A reset that carried input also now reports dirty (it is, against the caller's
defaults) rather than unconditionally announcing pristine, so a host's discard
guard keeps hearing the truth.
Measured, pre-#2983 harness under CPU load, 400 iterations per arm: before 5
failures (each `{"name":"Alice"}`, note lost), after 0. New renderer-level test
pins the fix and the three paths above deterministically, without timing games.
Verified: `npx vitest run packages/plugin-form/ packages/components/` — 68 files
/ 577 tests pass; `@object-ui/components` type-check and lint clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closes the product-side hole that #2983 left open (it fixed only the test).
The bug
The form renderer adopts a changed
defaultValueswithform.reset(), which replaces the whole react-hook-form record — so it also blanks every field the incoming defaults say nothing about. And it runs in a passive effect, one commit after those fields were committed and painted, so input landing in that window was silently discarded.The wizard reuses ONE inner form across steps and feeds it
defaultValues={formData}— the merge of the steps submitted so far — so at every step boundary the incoming defaults are missing exactly the fields now on screen:In a browser this needs a busy main thread plus typing on the first frame after new defaults arrive — unlikely by hand, but paste and autofill land in a single tick.
This is the residual of an incident already hardened once: #1525 stopped
reset()firing ondefaultValuesidentity churn by comparing by value. This is the case where the value genuinely did change.Measured behavior before the change
{}→{name}, user typednotenotewiped, POST loses it ← the bug{name,note}→{name}, user editednoteA→B, user editednoteOnly A is wrong. B and C are unchanged by this PR.
The fix
The reset carries the value across instead of dropping it — deliberately narrow. Only a field the caller has never carried (absent from both the outgoing and the incoming defaults) whose value the user actually changed is eligible. Wherever the caller has an opinion it stays authoritative, so the three load-bearing paths are untouched:
'' -> null) is not mistaken for input.recordIdswap still replaces the record outright. Drawer/modal/split forms re-fetch without re-entering their loading branch, so record B lands in the still-mounted form and must not inherit an abandoned edit to record A.A reset that carried input now also reports the form as dirty (it is, against the caller's defaults) instead of unconditionally announcing pristine, so a host's discard guard keeps hearing the truth.
Why not
keepDirtyValues/dirtyFieldsRHF's dirty tracking flags exactly the self-normalizing widgets this file already rolled its own
computeDirtyto work around (null !== undefined→ dirty on an untouched form).keepDirtyValues: truewould therefore have rejected the loaded record for every such field — breaking the load-bearing path this reset exists for.The broader "keep anything the user touched" rule was also considered and rejected: it leaks an abandoned edit to record A into record B on an in-place
recordIdswap.Verification
act()drain), under CPU load, 400 iterations per arm: before 5 failures (each{"name":"Alice"},notelost), after 0. So the renderer change closes the real race, not just the test-side symptom.npx vitest run packages/plugin-form/ packages/components/→ 68 files / 577 tests pass.@object-ui/componentstype-checkclean;lint0 errors, no new warnings in the edited region.🤖 Generated with Claude Code