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 ] ] );
+	} );
+} );