Skip to content

Commit 162f108

Browse files
os-zhuangclaude
andauthored
fix(components): apply new form defaultValues in the commit that renders them (#3001)
The form applies a `defaultValues` change by resetting react-hook-form to it. While that ran in a PASSIVE effect there was a window: the render had already committed, so the new inputs were mounted and interactive, but the form still held the old record. Anything typed in that window was destroyed — the pending `reset()` overwrote the whole record with `defaultValues`, dropping the field the user had just filled, with no error and nothing in the payload. It surfaced as the flaky wizard test fixed in #2983: a step transition changes `defaultValues`, and `note` typed on the new step vanished from the create body. That fix drained pending effects in the test, which AVOIDS the window; this one CLOSES it. Measured with the pre-fix test pattern, 1500 replays under CPU load: 6 failures before, 0 after. Running the reset as a layout effect is only half of it. The `form_change` subscription was silent across a reset by accident of ordering, not by design: `onAction` is usually an inline arrow, so its identity changes every render and the subscription effect re-runs each commit — and React runs every passive DESTROY before any passive CREATE, so the watcher was unsubscribed before the reset fired. Hoisting only the reset to the layout phase puts it AHEAD of that cleanup, so the still-live previous subscription sees it and a record landing looks like the user having edited every field it filled. That regressed #2968 deterministically (`changes=[{"category":"not-offered","status":"pending"}]`). Both `form.watch` subscriptions therefore move to the same phase, restoring the destroy-then-create order exactly. The new test pins the window shut without depending on timing: React flushes passive effects in tree order, so a probe sibling rendered before the form is guaranteed to run inside the window and types from there. It fails on every run against the previous code. Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 5b084eb commit 162f108

2 files changed

Lines changed: 165 additions & 5 deletions

File tree

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* New `defaultValues` must be applied in the SAME commit that renders them —
11+
* never a commit later (#2982).
12+
*
13+
* The form applies a `defaultValues` change by resetting react-hook-form to it.
14+
* While that ran in a PASSIVE effect there was a window: the render had already
15+
* committed, so the inputs were mounted and interactive, but the form still
16+
* held the old record. A value typed in that window was destroyed — the pending
17+
* `reset()` overwrote the whole record with `defaultValues`, dropping the field
18+
* the user had just filled, with no error and nothing in the payload.
19+
*
20+
* It surfaced as a flaky wizard test: a step transition changes `defaultValues`,
21+
* and `note` typed on the new step vanished from the create body. In a browser
22+
* it needs a busy main thread plus typing on the very first frame of the new
23+
* step — rare, but silent data loss when it hits.
24+
*
25+
* The test below constructs that window deterministically, without relying on
26+
* timing. React flushes passive effects in tree order, so a probe sibling
27+
* rendered BEFORE the form is guaranteed to run inside the window: its own
28+
* passive effect fires on the same commit that delivered the new defaults, but
29+
* strictly before the form's. Typing from there is therefore exactly the
30+
* hostile interleaving — a keystroke that beat the reset.
31+
*
32+
* With the reset in a layout effect it has already run by then and the typed
33+
* value survives; revert it to `React.useEffect` and this test fails every run.
34+
*/
35+
import { describe, it, expect, beforeAll } from 'vitest';
36+
import { render, waitFor, fireEvent, act } from '@testing-library/react';
37+
import React from 'react';
38+
import { ComponentRegistry } from '@object-ui/core';
39+
40+
beforeAll(async () => {
41+
await import('../../../renderers');
42+
}, 30000);
43+
44+
const FIELDS = [
45+
{ name: 'name', label: 'Name', type: 'input' },
46+
{ name: 'note', label: 'Note', type: 'input' },
47+
];
48+
49+
type Record_ = Record<string, unknown>;
50+
const getFormRenderer = () =>
51+
ComponentRegistry.get('form') as React.ComponentType<{ schema: Record_ }>;
52+
53+
describe('form — the defaultValues reset leaves no window open', () => {
54+
it('keeps a value typed in the same frame the new defaults commit', async () => {
55+
const Form = getFormRenderer();
56+
const submitted: Record_[] = [];
57+
let setDefaults!: (v: Record_) => void;
58+
59+
// Fires on every commit, but only acts once armed. Rendered BEFORE <Form>,
60+
// so on the commit that delivers the new defaults this runs first — inside
61+
// the window, before the form's own effect.
62+
let armed = false;
63+
const TypeInsideTheWindow = () => {
64+
React.useEffect(() => {
65+
if (!armed) return;
66+
armed = false;
67+
const note = document.querySelector('input[name="note"]') as HTMLInputElement | null;
68+
if (note) fireEvent.change(note, { target: { value: 'hello' } });
69+
});
70+
return null;
71+
};
72+
73+
const Host = () => {
74+
const [defaults, setD] = React.useState<Record<string, unknown>>({});
75+
setDefaults = setD;
76+
return (
77+
<>
78+
<TypeInsideTheWindow />
79+
<Form
80+
schema={{
81+
type: 'form',
82+
fields: FIELDS,
83+
defaultValues: defaults,
84+
showSubmit: false,
85+
onSubmit: (d: Record_) => { submitted.push(d); },
86+
}}
87+
/>
88+
</>
89+
);
90+
};
91+
92+
const { container } = render(<Host />);
93+
await waitFor(() => {
94+
if (!container.querySelector('input[name="note"]')) throw new Error('not ready');
95+
});
96+
await act(async () => {});
97+
98+
// Commit a new `defaultValues` — what a wizard step transition does. The
99+
// armed probe types into `note` from within the resulting effect flush.
100+
armed = true;
101+
await act(async () => { setDefaults({ name: 'Alice' }); });
102+
103+
fireEvent.submit(container.querySelector('form') as HTMLFormElement);
104+
105+
await waitFor(() => expect(submitted.length).toBe(1));
106+
// With a passive reset this is `{ name: 'Alice' }` — `note` silently gone.
107+
expect(submitted[0]).toEqual(expect.objectContaining({ name: 'Alice', note: 'hello' }));
108+
});
109+
110+
it('still applies defaults that arrive late (edit-mode record landing)', async () => {
111+
const Form = getFormRenderer();
112+
let setDefaults!: (v: Record_) => void;
113+
114+
const Host = () => {
115+
const [defaults, setD] = React.useState<Record_>({});
116+
setDefaults = setD;
117+
return (
118+
<Form
119+
schema={{ type: 'form', fields: FIELDS, defaultValues: defaults, showSubmit: false }}
120+
/>
121+
);
122+
};
123+
124+
const { container } = render(<Host />);
125+
await waitFor(() => {
126+
if (!container.querySelector('input[name="name"]')) throw new Error('not ready');
127+
});
128+
129+
// The record finishes loading well after first paint — the form must adopt it.
130+
await act(async () => { setDefaults({ name: 'Loaded', note: 'from server' }); });
131+
132+
await waitFor(() => {
133+
const name = container.querySelector('input[name="name"]') as HTMLInputElement;
134+
const note = container.querySelector('input[name="note"]') as HTMLInputElement;
135+
expect(name.value).toBe('Loaded');
136+
expect(note.value).toBe('from server');
137+
});
138+
});
139+
});

packages/components/src/renderers/form/form.tsx

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -560,7 +560,18 @@ ComponentRegistry.register('form',
560560
const baselineRef = React.useRef<Record<string, unknown>>(
561561
(defaultValues ?? {}) as Record<string, unknown>,
562562
);
563-
React.useEffect(() => {
563+
// LAYOUT effect, deliberately — not a passive one. A passive effect runs a
564+
// commit LATER than the render that produced the new values, so there is a
565+
// window in which the new inputs are already mounted and interactive but the
566+
// form still holds the old record. Anything typed in that window is
567+
// destroyed: the pending `reset()` overwrites the whole record with
568+
// `defaultValues`, silently dropping the field the user just filled. A
569+
// layout effect runs synchronously inside the same commit, before the
570+
// browser can paint or deliver a keystroke to those inputs, so the window
571+
// does not exist. This surfaced as a flaky wizard test (#2982): a step
572+
// transition changes `defaultValues`, and a value entered on the new step
573+
// before the deferred reset landed vanished from the create payload.
574+
React.useLayoutEffect(() => {
564575
let key: string;
565576
try { key = JSON.stringify(defaultValues ?? {}); } catch { key = String(Date.now()); }
566577
if (lastDefaultsKey.current === key) return;
@@ -614,8 +625,17 @@ ComponentRegistry.register('form',
614625
// eslint-disable-next-line react-hooks/exhaustive-deps
615626
}, [defaultValues]);
616627

617-
// Watch for form changes - only track changes when onAction is available
618-
React.useEffect(() => {
628+
// Watch for form changes - only track changes when onAction is available.
629+
// LAYOUT effect to stay in the same phase as the `defaultValues` reset
630+
// above. React runs every layout DESTROY (mutation phase) before any layout
631+
// CREATE, so a caller passing a fresh `onAction` each render — the common
632+
// case, it is usually an inline arrow — has this subscription torn down
633+
// before the reset runs and re-established after. That is what keeps a
634+
// reset from being reported as a user edit. Leaving this passive while the
635+
// reset is layout-phase inverts the order: the reset fires into the still
636+
// live previous subscription and a record landing looks like the user
637+
// editing every field it filled (#2968).
638+
React.useLayoutEffect(() => {
619639
if (onAction) {
620640
const subscription = form.watch((data) => {
621641
onAction({
@@ -632,8 +652,9 @@ ComponentRegistry.register('form',
632652
// accidental discard of unsaved input). We compute it via a normalized
633653
// comparison against the pristine baseline (see computeDirty) rather than
634654
// react-hook-form's `isDirty`, which false-positives on fields that
635-
// self-normalize their empty value on mount.
636-
React.useEffect(() => {
655+
// self-normalize their empty value on mount. Layout-phase for the same
656+
// ordering reason as the subscription above.
657+
React.useLayoutEffect(() => {
637658
const subscription = form.watch((values) => {
638659
onDirtyChangeProp?.(
639660
computeDirty(baselineRef.current, values as Record<string, unknown>),

0 commit comments

Comments
 (0)