Commit 82c5dd1405f for woocommerce
commit 82c5dd1405f741b2c6de230c1ccd9a40eca3a744
Author: Vlad Olaru <vlad.olaru@automattic.com>
Date: Tue Sep 1 08:30:45 2026 +0300
Fix Form synchronous value updates and changed-field reporting (#68050)
* fix(components): fix Form setValue reporting and same-stack writes
Since the Hooks rewrite (WC 6.9), both Form setters derived the next
values from the `values` object captured by the last committed render,
so two synchronous setValue()/setValues() calls in one handler both
started from the same stale snapshot and the second discarded the
first. Since nested-name support (WC 7.1), setValue() also handed the
complete next values object to setValues(), whose reporting loop
treats every key as a change, so a single-field write notified
onChange once per form field.
Keep a private pending-values ref next to the rendered state. Both
setters read from it and advance it before enqueueing the state
update, so same-stack writes accumulate in call order. setValue() now
hands setValues() only the top-level entry it wrote, so a write
reports exactly one pair with the same name and value it reported
before; a nested write is still reported under its top-level key with
the updated subtree. setValues() itself is unchanged.
The setters no longer depend on `values`, so they keep a stable
identity for consumers whose validate/onChange/onChanges props are
stable; consumers omitting the callbacks are unaffected because the
inline defaults re-allocate every render.
Refs #37168
Refs #37169
* chore(components): add changelog for the Form setValue fixes
Refs #37168
Refs #37169
* docs(components): make the Form README FormContext link descriptive
The link text "here" fails markdownlint MD059, which blocks the
Validate markdown job for any change to this file.
* docs(components): document Form change notifications
The README's onChange row described a select control and onChanges
was not listed at all, so the shape and cadence of change
notifications were undocumented. Add both prop rows and a section
stating what each setter reports, in which order, and that
same-stack writes accumulate.
Refs #37168
Refs #37169
* chore(components): raise the Form fix changelog to minor significance
The entry was filed as `patch`, which the changelogger reserves for
fixes with no consumer-visible API or behavior change. This fix does
change observable behavior: onChange now fires once per value call
instead of once per form field, so a DynamicForm gateway settings
form that forwards onChange goes from six calls per keystroke to one.
The PR is flagged for a Developer Advisory on the same grounds, so
`patch` understated the change and contradicted the classification
already stated in review.
Refs #37168
Refs #37169
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs(components): point the useFormContext link at the type declaration
The sentence promises the properties available within
useFormContext(), but linked to form-context.ts, which only creates
the context value and the hook. The properties are declared as
`FormContextType` in types.ts, so the link sent readers to a file
that does not contain what the sentence advertises.
Link to types.ts and name FormContextType. The wrong target predates
this branch; the earlier link change only made the text descriptive
for markdownlint MD059 and carried the target over unexamined.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(components): drop Form writes lodash refuses to make
setValue() hands setValues() only the entry a write touched, deriving
that key from the name it was given. lodash refuses to write a path
that steps through '__proto__', 'constructor' or 'prototype', so for
those names setWith() returns the values unchanged and the derived key
resolves against the prototype chain instead. The reported entry then
carried a value the caller never passed: the native Object function
for 'constructor', Object.prototype for '__proto__', and undefined for
'prototype'.
setValues() merged that entry into the form state, so the key was
added as an own property and onChange/onChanges reported it as a
change. Trunk had neither effect, since it handed setValues() the
whole values object rather than a derived entry.
Drop the write instead, which is what lodash already does with it. The
check mirrors lodash's own path resolution: a name the form already
holds as a literal key is a single segment and is never split, so an
existing key such as 'a.constructor' is still written normally.
Refs #37168
Refs #37169
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(components): cover Form reporting of a literal dotted key
The README states that paths resolve the way lodash set() resolves
them, so a form holding a literal key such as 'a.b' has that key
written and reported as is. The suite only exercised a nested path,
leaving the literal-key half of that sentence unpinned.
Add a case asserting that setValue( 'a.b', 2 ) on a form holding
'a.b' reports { name: 'a.b', value: 2 } rather than splitting the
name into a path.
Refs #37168
Refs #37169
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(components): report only own keys from Form setValues
setValues() merges its patch with an object spread, which copies own
enumerable keys, but reported the changed fields with a for...in loop,
which also walks the prototype chain.
A patch carrying an enumerable inherited key therefore made onChange
and onChanges report a field that was never merged into the form
state, notifying consumers about a field the form does not hold. It
also contradicted the contract documented alongside this branch, that
setValues() reports the patch's keys.
Iterate Object.keys() instead, which is exactly the set of own
enumerable string keys the spread merged. Both callback signatures
already declare name as a string, so the reported shape is unchanged.
Refs #37168
Refs #37169
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(components): keep a nullish Form setValues patch a no-op
Reporting the changed keys with Object.keys() instead of for...in made
setValues() throw on a nullish patch: Object.keys() raises a TypeError
on null and undefined, where for...in silently yields no keys.
The object spread that builds the merged values tolerates a nullish
patch, so before that change setValues( null ) merged nothing, ran
validation and reported an empty change list. Afterwards it threw
before either callback fired. The TypeScript signature forbids null,
but a JavaScript consumer can still reach it.
Guard the enumeration with `|| {}`, the same idiom the error check
just above already uses, restoring the previous no-op behavior.
Refs #37168
Refs #37169
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
diff --git a/packages/js/components/changelog/fix-37168-37169-form-state-updates b/packages/js/components/changelog/fix-37168-37169-form-state-updates
new file mode 100644
index 00000000000..7f7b73c122d
--- /dev/null
+++ b/packages/js/components/changelog/fix-37168-37169-form-state-updates
@@ -0,0 +1,4 @@
+Significance: minor
+Type: fix
+
+Fix Form setValue reporting every field as changed and dropping earlier writes made in the same synchronous stack.
diff --git a/packages/js/components/src/form/README.md b/packages/js/components/src/form/README.md
index e9b7c33a813..de5804d882e 100644
--- a/packages/js/components/src/form/README.md
+++ b/packages/js/components/src/form/README.md
@@ -59,16 +59,30 @@ const Field = () => {
</Form>
```
-To see the properties available within `useFormContext()` check out the `FormContext` type [here](./form-context.ts).
+To see the properties available within `useFormContext()`, check out the [`FormContextType` definition](./types.ts).
### Props
-| Name | Type | Default | Description |
-| --------------- | -------- | ------- | ------------------------------------------------------------------------------------------------------------------------------ |
-| `children` | \* | `null` | A renderable component in which to pass this component's state and helpers. Generally a number of input or other form elements |
-| `errors` | Object | `{}` | Object of all initial errors to store in state |
-| `initialValues` | Object | `{}` | Object key:value pair list of all initial field values |
-| `onSubmit` | Function | `noop` | Function to call when a form is submitted with valid fields |
-| `validate` | Function | `noop` | A function that is passed a list of all values and should return an `errors` object with error response |
-| `touched` | Object | `{}` | This prop helps determine whether or not a field has received focus |
-| `onChange` | Function | `null` | A function that receives the value of the input; called when selected items change, whether added, edited, or removed |
+| Name | Type | Default | Description |
+| --------------- | -------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `children` | \* | `null` | A renderable component in which to pass this component's state and helpers. Generally a number of input or other form elements |
+| `errors` | Object | `{}` | Object of all initial errors to store in state |
+| `initialValues` | Object | `{}` | Object key:value pair list of all initial field values |
+| `onSubmit` | Function | `noop` | Function to call when a form is submitted with valid fields |
+| `validate` | Function | `noop` | A function that is passed a list of all values and should return an `errors` object with error response |
+| `touched` | Object | `{}` | This prop helps determine whether or not a field has received focus |
+| `onChange` | Function | `noop` | Called once for each entry a value call wrote. Receives `( { name, value }, values, isValid )`. See [Change notifications](#change-notifications) |
+| `onChanges` | Function | `noop` | Called once per value call, with every entry that call wrote. Receives `( [ { name, value } ], values, isValid )`. See [Change notifications](#change-notifications) |
+
+### Change notifications
+
+`onChange` and `onChanges` report only the entries a call wrote. The complete next form state is always the second argument, so a consumer that just needs the new values can read that and ignore the reported entries entirely. A consumer that needs to react to fields it did not write should read `values` rather than wait for a notification about them.
+
+- `setValue( name, value )` reports exactly one entry: the top-level key it wrote. For a flat name that is the name you passed. Sibling fields are never reported, even when the form holds other values.
+- A nested write, in dot or bracket notation, is reported under its top-level key with the updated subtree. With `dimensions: { width: 1, height: 2 }` in the form, `setValue( 'dimensions.width', 5 )` reports `{ name: 'dimensions', value: { width: 5, height: 2 } }`. Paths resolve the way lodash `set` resolves them, so a form that holds a literal key such as `'a.b'` has that key written and reported as is.
+- `setValue( name, value )` is a no-op when the path steps through `__proto__`, `constructor` or `prototype`. lodash refuses to write those keys, so the state does not change and nothing is reported. A literal key that merely contains one of them, such as `'a.constructor'`, is written normally, since lodash writes an existing literal key in place rather than as a path.
+- `setValues( patch )` shallow-merges `patch` into the form state and reports the patch's own keys in JavaScript key order. A key `patch` inherits from its prototype is neither merged nor reported.
+- `resetForm()` replaces the state silently and reports nothing.
+- Every call is a distinct logical change. Repeated writes to one field, and writes that set a field to the value it already holds, are each reported in call order. Values are never compared for equality and changes are never deduplicated.
+- Callbacks are synchronous and ordered: validation errors are enqueued first, then `onChange` fires once per written entry in order, then `onChanges` fires once for the call.
+- Several value calls made in the same synchronous stack, such as one event handler or one effect, accumulate. Each call builds on the state left by the one before it, and each callback receives the complete state as of its own call.
diff --git a/packages/js/components/src/form/form.tsx b/packages/js/components/src/form/form.tsx
index 413c03e65dc..777d614e355 100644
--- a/packages/js/components/src/form/form.tsx
+++ b/packages/js/components/src/form/form.tsx
@@ -16,6 +16,7 @@ import { ChangeEvent, useRef } from 'react';
import _setWith from 'lodash/setWith';
import _get from 'lodash/get';
import _clone from 'lodash/clone';
+import _toPath from 'lodash/toPath';
import _isEqual from 'lodash/isEqual';
import _omit from 'lodash/omit';
@@ -41,6 +42,9 @@ function isChangeEvent< T >(
return ( value as ChangeEvent< HTMLInputElement > ).target !== undefined;
}
+// Path segments lodash refuses to write through.
+const UNWRITABLE_KEYS = [ '__proto__', 'constructor', 'prototype' ];
+
/**
* A form component to handle form state and provide input helper props.
*/
@@ -49,6 +53,9 @@ function FormComponent< Values extends Record< string, any > = any >(
{
children,
onSubmit = () => {},
+ // Keep these defaults inline: setValues depends on them, so hoisting them
+ // to module constants would make setValue/setValues referentially stable
+ // for consumers that omit the props and change when dependent effects run.
onChange = () => {},
onChanges = () => {},
...props
@@ -59,8 +66,11 @@ function FormComponent< Values extends Record< string, any > = any >(
ref: React.Ref< FormRef< Values > >
): React.ReactElement | null {
const initialValues = useRef( props.initialValues ?? ( {} as Values ) );
+ // The latest logical values, advanced synchronously on every write so
+ // same-stack writes build on each other instead of on the last render.
+ const pendingValuesRef = useRef( initialValues.current );
const [ values, setValuesInternal ] = useState< Values >(
- props.initialValues ?? ( {} as Values )
+ initialValues.current
);
const [ errors, setErrors ] = useState< FormErrors< Values > >(
props.errors || {}
@@ -94,6 +104,7 @@ function FormComponent< Values extends Record< string, any > = any >(
) => void = ( newInitialValues, newTouchedFields = {}, newErrors = {} ) => {
const newValues = newInitialValues ?? initialValues.current ?? {};
initialValues.current = newValues;
+ pendingValuesRef.current = newValues;
setValuesInternal( newValues );
setTouched( newTouchedFields );
setErrors( newErrors );
@@ -110,7 +121,8 @@ function FormComponent< Values extends Record< string, any > = any >(
const setValues = useCallback(
( valuesToSet: Values ) => {
- const newValues = { ...values, ...valuesToSet };
+ const newValues = { ...pendingValuesRef.current, ...valuesToSet };
+ pendingValuesRef.current = newValues;
setValuesInternal( newValues );
validate( newValues, ( newErrors ) => {
@@ -136,7 +148,12 @@ function FormComponent< Values extends Record< string, any > = any >(
const isValid = ! Object.keys( newErrors || {} ).length;
const nameValuePairs = [];
- for ( const key in valuesToSet ) {
+ // Report the keys the merge above actually took, which is the
+ // own enumerable ones. A `for...in` here would also walk the
+ // prototype chain and report fields the form never stored.
+ // The `|| {}` leaves a nullish patch a no-op, which is what
+ // the spread above and the previous `for...in` both did.
+ for ( const key of Object.keys( valuesToSet || {} ) ) {
const nameValuePair = {
name: key,
value: valuesToSet[ key ],
@@ -158,15 +175,45 @@ function FormComponent< Values extends Record< string, any > = any >(
}
} );
},
- [ values, validate, onChange, props.onChangeCallback ]
+ [ validate, onChange, onChanges, props.onChangeCallback ]
);
const setValue = useCallback(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
( name: keyof Values, value: any ) => {
- setValues( _setWith( { ...values }, name, value, _clone ) );
+ // lodash writes an existing literal key such as 'a.b' in place rather
+ // than as a path, so only split a name the form does not already hold.
+ const segments = Object.prototype.hasOwnProperty.call(
+ pendingValuesRef.current,
+ name
+ )
+ ? [ String( name ) ]
+ : _toPath( name );
+
+ // lodash drops a write whose path steps through one of these keys.
+ // Drop it here too: otherwise the entry picked below reads an
+ // inherited value and setValues adds it to the form as an own key.
+ if (
+ segments.some( ( segment ) =>
+ UNWRITABLE_KEYS.includes( segment )
+ )
+ ) {
+ return;
+ }
+
+ const newValues = _setWith(
+ { ...pendingValuesRef.current },
+ name,
+ value,
+ _clone
+ );
+ // Hand setValues only the entry this write touched so it reports one
+ // change: a literal key is its own only segment, and a path reports
+ // under its top-level key.
+ const key = segments[ 0 ];
+ setValues( { [ key ]: newValues[ key ] } as Values );
},
- [ values, validate, onChange, props.onChangeCallback ]
+ [ setValues ]
);
const handleChange = useCallback(
@@ -177,7 +224,7 @@ function FormComponent< Values extends Record< string, any > = any >(
// Handle native events.
if ( isChangeEvent( value ) && value.target ) {
if ( value.target.type === 'checkbox' ) {
- setValue( name, ! _get( values, name ) );
+ setValue( name, ! _get( pendingValuesRef.current, name ) );
} else {
setValue( name, value.target.value );
}
diff --git a/packages/js/components/src/form/test/state-updates.tsx b/packages/js/components/src/form/test/state-updates.tsx
new file mode 100644
index 00000000000..c9a6c0cf8fc
--- /dev/null
+++ b/packages/js/components/src/form/test/state-updates.tsx
@@ -0,0 +1,608 @@
+/**
+ * External dependencies
+ */
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { ChangeEvent, ReactNode, startTransition, StrictMode } from 'react';
+
+/**
+ * Internal dependencies
+ */
+import { Form } from '../';
+import { FormContextType } from '../types';
+
+type NameValues = {
+ firstName: string;
+ lastName: string;
+ email: string;
+};
+
+const initialNameValues = (): NameValues => ( {
+ firstName: 'Initial',
+ lastName: 'Person',
+ email: 'initial@example.com',
+} );
+
+/**
+ * Renders a Form with mocked callbacks, an output of the current values, and the
+ * mount-time validation call already cleared.
+ */
+function renderForm< Values extends Record< string, unknown > >(
+ initialValues: Values,
+ children: ( context: FormContextType< Values > ) => ReactNode,
+ { strict = false } = {}
+) {
+ const validate = jest.fn( () => ( {} ) );
+ const onChange = jest.fn();
+ const onChanges = jest.fn();
+ const form = (
+ <Form< Values >
+ initialValues={ initialValues }
+ validate={ validate }
+ onChange={ onChange }
+ onChanges={ onChanges }
+ >
+ { ( context ) => (
+ <>
+ { children( context ) }
+ <output aria-label="Form values">
+ { JSON.stringify( context.values ) }
+ </output>
+ </>
+ ) }
+ </Form>
+ );
+
+ render( strict ? <StrictMode>{ form }</StrictMode> : form );
+ validate.mockClear();
+
+ return { validate, onChange, onChanges };
+}
+
+const renderedValues = () =>
+ screen.getByRole( 'status', { name: 'Form values' } ).textContent;
+
+const validatedValues = ( validate: jest.Mock ) =>
+ validate.mock.calls.map( ( [ values ] ) => values );
+
+describe( 'Form state updates', () => {
+ it( 'reports one entry per setValue with the complete next values', () => {
+ const { onChange, onChanges } = renderForm(
+ initialNameValues(),
+ ( { setValue } ) => (
+ <button onClick={ () => setValue( 'firstName', 'Updated' ) }>
+ Update first name
+ </button>
+ )
+ );
+
+ userEvent.click(
+ screen.getByRole( 'button', { name: 'Update first name' } )
+ );
+
+ const nextValues = { ...initialNameValues(), firstName: 'Updated' };
+ expect( onChange.mock.calls ).toEqual( [
+ [ { name: 'firstName', value: 'Updated' }, nextValues, true ],
+ ] );
+ expect( onChanges.mock.calls ).toEqual( [
+ [ [ { name: 'firstName', value: 'Updated' } ], nextValues, true ],
+ ] );
+ } );
+
+ it( 'applies three same-stack writes in order and reports each with its own snapshot', () => {
+ const { validate, onChange, onChanges } = renderForm(
+ { firstName: '', lastName: '', email: '' },
+ ( { setValue } ) => (
+ <button
+ onClick={ () => {
+ setValue( 'firstName', 'A' );
+ setValue( 'lastName', 'B' );
+ setValue( 'email', 'C' );
+ } }
+ >
+ Update all fields
+ </button>
+ )
+ );
+
+ userEvent.click(
+ screen.getByRole( 'button', { name: 'Update all fields' } )
+ );
+
+ const snapshots = [
+ { firstName: 'A', lastName: '', email: '' },
+ { firstName: 'A', lastName: 'B', email: '' },
+ { firstName: 'A', lastName: 'B', email: 'C' },
+ ];
+ expect( renderedValues() ).toBe( JSON.stringify( snapshots[ 2 ] ) );
+ expect( validatedValues( validate ) ).toEqual( snapshots );
+ expect( onChange.mock.calls ).toEqual( [
+ [ { name: 'firstName', value: 'A' }, snapshots[ 0 ], true ],
+ [ { name: 'lastName', value: 'B' }, snapshots[ 1 ], true ],
+ [ { name: 'email', value: 'C' }, snapshots[ 2 ], true ],
+ ] );
+ expect( onChanges.mock.calls ).toEqual( [
+ [ [ { name: 'firstName', value: 'A' } ], snapshots[ 0 ], true ],
+ [ [ { name: 'lastName', value: 'B' } ], snapshots[ 1 ], true ],
+ [ [ { name: 'email', value: 'C' } ], snapshots[ 2 ], true ],
+ ] );
+ } );
+
+ it( 'reports repeated and same-value writes to one field without deduplicating', () => {
+ const { validate, onChange, onChanges } = renderForm(
+ initialNameValues(),
+ ( { setValue } ) => (
+ <button
+ onClick={ () => {
+ setValue( 'firstName', 'First' );
+ setValue( 'firstName', 'Second' );
+ setValue( 'firstName', 'Second' );
+ } }
+ >
+ Rewrite first name
+ </button>
+ )
+ );
+
+ userEvent.click(
+ screen.getByRole( 'button', { name: 'Rewrite first name' } )
+ );
+
+ const snapshots = [ 'First', 'Second', 'Second' ].map(
+ ( firstName ) => ( { ...initialNameValues(), firstName } )
+ );
+ expect( renderedValues() ).toBe( JSON.stringify( snapshots[ 2 ] ) );
+ expect( validatedValues( validate ) ).toEqual( snapshots );
+ expect( onChange.mock.calls ).toEqual(
+ snapshots.map( ( snapshot ) => [
+ { name: 'firstName', value: snapshot.firstName },
+ snapshot,
+ true,
+ ] )
+ );
+ expect( onChanges.mock.calls ).toEqual(
+ snapshots.map( ( snapshot ) => [
+ [ { name: 'firstName', value: snapshot.firstName } ],
+ snapshot,
+ true,
+ ] )
+ );
+ } );
+
+ it( 'reports a nested write under its top-level key and keeps its siblings', () => {
+ const { onChange, onChanges } = renderForm(
+ {
+ profile: { firstName: 'Initial', lastName: 'Person' },
+ status: 'active',
+ },
+ ( { setValue } ) => (
+ <button
+ onClick={ () => setValue( 'profile.firstName', 'Updated' ) }
+ >
+ Update profile
+ </button>
+ )
+ );
+
+ userEvent.click(
+ screen.getByRole( 'button', { name: 'Update profile' } )
+ );
+
+ const nextValues = {
+ profile: { firstName: 'Updated', lastName: 'Person' },
+ status: 'active',
+ };
+ expect( renderedValues() ).toBe( JSON.stringify( nextValues ) );
+ expect( onChange.mock.calls ).toEqual( [
+ [
+ { name: 'profile', value: nextValues.profile },
+ nextValues,
+ true,
+ ],
+ ] );
+ expect( onChanges.mock.calls ).toEqual( [
+ [
+ [ { name: 'profile', value: nextValues.profile } ],
+ nextValues,
+ true,
+ ],
+ ] );
+ } );
+
+ it( 'merges a setValues batch onto a same-stack write and reports its keys in order', () => {
+ const patch = {
+ email: 'updated@example.com',
+ firstName: 'Updated',
+ } as NameValues;
+ const { validate, onChange, onChanges } = renderForm(
+ initialNameValues(),
+ ( { setValue, setValues } ) => (
+ <button
+ onClick={ () => {
+ setValue( 'lastName', 'Same stack' );
+ setValues( patch );
+ } }
+ >
+ Apply values
+ </button>
+ )
+ );
+
+ userEvent.click(
+ screen.getByRole( 'button', { name: 'Apply values' } )
+ );
+
+ const firstValues = { ...initialNameValues(), lastName: 'Same stack' };
+ const nextValues = {
+ firstName: 'Updated',
+ lastName: 'Same stack',
+ email: 'updated@example.com',
+ };
+ expect( renderedValues() ).toBe( JSON.stringify( nextValues ) );
+ expect( validatedValues( validate ) ).toEqual( [
+ firstValues,
+ nextValues,
+ ] );
+ expect( onChange.mock.calls ).toEqual( [
+ [ { name: 'lastName', value: 'Same stack' }, firstValues, true ],
+ [
+ { name: 'email', value: 'updated@example.com' },
+ nextValues,
+ true,
+ ],
+ [ { name: 'firstName', value: 'Updated' }, nextValues, true ],
+ ] );
+ expect( onChanges.mock.calls ).toEqual( [
+ [
+ [ { name: 'lastName', value: 'Same stack' } ],
+ firstValues,
+ true,
+ ],
+ [
+ [
+ { name: 'email', value: 'updated@example.com' },
+ { name: 'firstName', value: 'Updated' },
+ ],
+ nextValues,
+ true,
+ ],
+ ] );
+ } );
+
+ it( 'builds a same-stack write on reset values and does not report the reset', () => {
+ const resetValues: NameValues = {
+ firstName: 'Reset',
+ lastName: 'Values',
+ email: 'reset@example.com',
+ };
+ const { validate, onChange, onChanges } = renderForm(
+ initialNameValues(),
+ ( { resetForm, setValue } ) => (
+ <button
+ onClick={ () => {
+ resetForm( resetValues );
+ setValue( 'firstName', 'After reset' );
+ } }
+ >
+ Reset and update
+ </button>
+ )
+ );
+
+ userEvent.click(
+ screen.getByRole( 'button', { name: 'Reset and update' } )
+ );
+
+ const nextValues = { ...resetValues, firstName: 'After reset' };
+ expect( renderedValues() ).toBe( JSON.stringify( nextValues ) );
+ expect( validatedValues( validate ) ).toEqual( [ nextValues ] );
+ expect( onChange.mock.calls ).toEqual( [
+ [ { name: 'firstName', value: 'After reset' }, nextValues, true ],
+ ] );
+ expect( onChanges.mock.calls ).toEqual( [
+ [
+ [ { name: 'firstName', value: 'After reset' } ],
+ nextValues,
+ true,
+ ],
+ ] );
+ } );
+
+ it( 'applies a write issued from inside onChange on top of the outer write and notifies depth-first', () => {
+ const notifications: string[] = [];
+ let writeFromCallback = () => {};
+ const { validate, onChange, onChanges } = renderForm(
+ initialNameValues(),
+ ( { setValue } ) => {
+ writeFromCallback = () => setValue( 'lastName', 'Nested' );
+ return (
+ <button onClick={ () => setValue( 'firstName', 'Outer' ) }>
+ Update with nested change
+ </button>
+ );
+ }
+ );
+ onChange.mockImplementation(
+ ( change: { name: string; value: string } ) => {
+ notifications.push( `onChange:${ change.name }` );
+ if ( change.name === 'firstName' && change.value === 'Outer' ) {
+ writeFromCallback();
+ }
+ }
+ );
+ onChanges.mockImplementation( ( changes: { name: string }[] ) => {
+ notifications.push( `onChanges:${ changes[ 0 ].name }` );
+ } );
+
+ userEvent.click(
+ screen.getByRole( 'button', { name: 'Update with nested change' } )
+ );
+
+ const outerValues = { ...initialNameValues(), firstName: 'Outer' };
+ const nestedValues = { ...outerValues, lastName: 'Nested' };
+ expect( renderedValues() ).toBe( JSON.stringify( nestedValues ) );
+ expect( validatedValues( validate ) ).toEqual( [
+ outerValues,
+ nestedValues,
+ ] );
+ expect( onChange.mock.calls ).toEqual( [
+ [ { name: 'firstName', value: 'Outer' }, outerValues, true ],
+ [ { name: 'lastName', value: 'Nested' }, nestedValues, true ],
+ ] );
+ expect( onChanges.mock.calls ).toEqual( [
+ [ [ { name: 'lastName', value: 'Nested' } ], nestedValues, true ],
+ [ [ { name: 'firstName', value: 'Outer' } ], outerValues, true ],
+ ] );
+ expect( notifications ).toEqual( [
+ 'onChange:firstName',
+ 'onChange:lastName',
+ 'onChanges:lastName',
+ 'onChanges:firstName',
+ ] );
+ } );
+
+ it( 'reports each write once under StrictMode', () => {
+ const { validate, onChange, onChanges } = renderForm(
+ initialNameValues(),
+ ( { setValue } ) => (
+ <button onClick={ () => setValue( 'firstName', 'Updated' ) }>
+ Update in StrictMode
+ </button>
+ ),
+ { strict: true }
+ );
+
+ userEvent.click(
+ screen.getByRole( 'button', { name: 'Update in StrictMode' } )
+ );
+
+ const nextValues = { ...initialNameValues(), firstName: 'Updated' };
+ expect( validatedValues( validate ) ).toEqual( [ nextValues ] );
+ expect( onChange.mock.calls ).toEqual( [
+ [ { name: 'firstName', value: 'Updated' }, nextValues, true ],
+ ] );
+ expect( onChanges.mock.calls ).toEqual( [
+ [ [ { name: 'firstName', value: 'Updated' } ], nextValues, true ],
+ ] );
+ } );
+
+ it( 'applies same-stack writes issued inside startTransition', async () => {
+ const { onChange, onChanges } = renderForm(
+ initialNameValues(),
+ ( { setValue } ) => (
+ <button
+ onClick={ () =>
+ startTransition( () => {
+ setValue( 'firstName', 'Transition' );
+ setValue( 'lastName', 'Action' );
+ } )
+ }
+ >
+ Update in transition
+ </button>
+ )
+ );
+
+ userEvent.click(
+ screen.getByRole( 'button', { name: 'Update in transition' } )
+ );
+
+ const firstValues = { ...initialNameValues(), firstName: 'Transition' };
+ const secondValues = { ...firstValues, lastName: 'Action' };
+ await waitFor( () =>
+ expect( renderedValues() ).toBe( JSON.stringify( secondValues ) )
+ );
+ expect( onChange.mock.calls ).toEqual( [
+ [ { name: 'firstName', value: 'Transition' }, firstValues, true ],
+ [ { name: 'lastName', value: 'Action' }, secondValues, true ],
+ ] );
+ expect( onChanges.mock.calls ).toEqual( [
+ [
+ [ { name: 'firstName', value: 'Transition' } ],
+ firstValues,
+ true,
+ ],
+ [ [ { name: 'lastName', value: 'Action' } ], secondValues, true ],
+ ] );
+ } );
+
+ it( 'applies two same-stack checkbox toggles in order', () => {
+ const checkboxEvent = {
+ target: { type: 'checkbox' },
+ } as unknown as ChangeEvent< HTMLInputElement >;
+ const { validate, onChange, onChanges } = renderForm(
+ { enabled: false, label: 'Stable' },
+ ( { getCheckboxControlProps } ) => {
+ const checkboxProps = getCheckboxControlProps( 'enabled' );
+ return (
+ <>
+ <input
+ type="checkbox"
+ aria-label="Enabled"
+ { ...checkboxProps }
+ />
+ <button
+ onClick={ () => {
+ checkboxProps.onChange( checkboxEvent );
+ checkboxProps.onChange( checkboxEvent );
+ } }
+ >
+ Toggle twice
+ </button>
+ </>
+ );
+ }
+ );
+
+ userEvent.click(
+ screen.getByRole( 'button', { name: 'Toggle twice' } )
+ );
+
+ const enabledValues = { enabled: true, label: 'Stable' };
+ const disabledValues = { enabled: false, label: 'Stable' };
+ expect(
+ screen.getByRole( 'checkbox', { name: 'Enabled' } )
+ ).not.toBeChecked();
+ expect( renderedValues() ).toBe( JSON.stringify( disabledValues ) );
+ expect( validatedValues( validate ) ).toEqual( [
+ enabledValues,
+ disabledValues,
+ ] );
+ expect( onChange.mock.calls ).toEqual( [
+ [ { name: 'enabled', value: true }, enabledValues, true ],
+ [ { name: 'enabled', value: false }, disabledValues, true ],
+ ] );
+ expect( onChanges.mock.calls ).toEqual( [
+ [ [ { name: 'enabled', value: true } ], enabledValues, true ],
+ [ [ { name: 'enabled', value: false } ], disabledValues, true ],
+ ] );
+ } );
+
+ it( 'reports a literal dotted key as written rather than as a path', () => {
+ const { onChange, onChanges } = renderForm(
+ { 'a.b': 1, other: 2 },
+ ( { setValue } ) => (
+ <button onClick={ () => setValue( 'a.b', 2 ) }>
+ Update dotted key
+ </button>
+ )
+ );
+
+ userEvent.click(
+ screen.getByRole( 'button', { name: 'Update dotted key' } )
+ );
+
+ const nextValues = { 'a.b': 2, other: 2 };
+ expect( renderedValues() ).toBe( JSON.stringify( nextValues ) );
+ expect( onChange.mock.calls ).toEqual( [
+ [ { name: 'a.b', value: 2 }, nextValues, true ],
+ ] );
+ expect( onChanges.mock.calls ).toEqual( [
+ [ [ { name: 'a.b', value: 2 } ], nextValues, true ],
+ ] );
+ } );
+
+ it( 'writes a literal key holding a segment lodash refuses in a path', () => {
+ const { onChange, onChanges } = renderForm(
+ { 'a.constructor': 1, other: 2 },
+ ( { setValue } ) => (
+ <button onClick={ () => setValue( 'a.constructor', 2 ) }>
+ Update literal key
+ </button>
+ )
+ );
+
+ userEvent.click(
+ screen.getByRole( 'button', { name: 'Update literal key' } )
+ );
+
+ const nextValues = { 'a.constructor': 2, other: 2 };
+ expect( renderedValues() ).toBe( JSON.stringify( nextValues ) );
+ expect( onChange.mock.calls ).toEqual( [
+ [ { name: 'a.constructor', value: 2 }, nextValues, true ],
+ ] );
+ expect( onChanges.mock.calls ).toEqual( [
+ [ [ { name: 'a.constructor', value: 2 } ], nextValues, true ],
+ ] );
+ } );
+
+ it.each( [ 'constructor', 'prototype', '__proto__', 'a.constructor' ] )(
+ 'drops a %s write that lodash refuses to make',
+ ( name ) => {
+ const initialValues: Record< string, unknown > = {
+ a: { b: 1 },
+ other: 2,
+ };
+ const { validate, onChange, onChanges } = renderForm(
+ initialValues,
+ ( { setValue } ) => (
+ <button onClick={ () => setValue( name, 'Updated' ) }>
+ Write refused key
+ </button>
+ )
+ );
+
+ userEvent.click(
+ screen.getByRole( 'button', { name: 'Write refused key' } )
+ );
+
+ expect( renderedValues() ).toBe( JSON.stringify( initialValues ) );
+ expect( validate ).not.toHaveBeenCalled();
+ expect( onChange ).not.toHaveBeenCalled();
+ expect( onChanges ).not.toHaveBeenCalled();
+ }
+ );
+
+ it( 'reports only the own keys a setValues patch merges', () => {
+ // The merge takes own keys only, so an inherited one must stay unreported.
+ const patch = Object.create( {
+ inherited: 'From the prototype',
+ } ) as NameValues;
+ patch.firstName = 'Updated';
+
+ const { onChange, onChanges } = renderForm(
+ initialNameValues(),
+ ( { setValues } ) => (
+ <button onClick={ () => setValues( patch ) }>
+ Apply patch
+ </button>
+ )
+ );
+
+ userEvent.click( screen.getByRole( 'button', { name: 'Apply patch' } ) );
+
+ const nextValues = { ...initialNameValues(), firstName: 'Updated' };
+ expect( renderedValues() ).toBe( JSON.stringify( nextValues ) );
+ expect( onChange.mock.calls ).toEqual( [
+ [ { name: 'firstName', value: 'Updated' }, nextValues, true ],
+ ] );
+ expect( onChanges.mock.calls ).toEqual( [
+ [ [ { name: 'firstName', value: 'Updated' } ], nextValues, true ],
+ ] );
+ } );
+
+ it.each( [
+ [ 'null', null ],
+ [ 'undefined', undefined ],
+ ] )( 'ignores a %s patch rather than throwing', ( _label, patch ) => {
+ const initialValues = initialNameValues();
+ const { onChange, onChanges } = renderForm(
+ initialValues,
+ ( { setValues } ) => (
+ <button
+ onClick={ () => setValues( patch as unknown as NameValues ) }
+ >
+ Apply nullish patch
+ </button>
+ )
+ );
+
+ userEvent.click(
+ screen.getByRole( 'button', { name: 'Apply nullish patch' } )
+ );
+
+ expect( renderedValues() ).toBe( JSON.stringify( initialValues ) );
+ expect( onChange ).not.toHaveBeenCalled();
+ expect( onChanges.mock.calls ).toEqual( [ [ [], initialValues, true ] ] );
+ } );
+} );