diff --git a/src/Field.tsx b/src/Field.tsx index 42150e69..47327dba 100644 --- a/src/Field.tsx +++ b/src/Field.tsx @@ -32,8 +32,7 @@ const EMPTY_ERRORS: any[] = []; const EMPTY_WARNINGS: any[] = []; export type ShouldUpdate = - | boolean - | ((prevValues: Values, nextValues: Values, info: { source?: string }) => boolean); + boolean | ((prevValues: Values, nextValues: Values, info: { source?: string }) => boolean); function requireUpdate( shouldUpdate: ShouldUpdate, @@ -98,8 +97,10 @@ export interface InternalFieldProps { fieldContext?: InternalFormInstance; } -export interface FieldProps - extends Omit, 'name' | 'fieldContext'> { +export interface FieldProps extends Omit< + InternalFieldProps, + 'name' | 'fieldContext' +> { name?: NamePath; } @@ -140,6 +141,8 @@ class Field extends React.PureComponent implemen private prevValidating: boolean; + private lastRenderedValue: StoreValue; + private errors: string[] = EMPTY_ERRORS; private warnings: string[] = EMPTY_WARNINGS; @@ -167,8 +170,11 @@ class Field extends React.PureComponent implemen this.cancelRegisterFunc = registerField(this); } - // One more render for component in case fields not ready - if (shouldUpdate === true) { + // One more render if the store changed before mount, including replay recovery. + if ( + shouldUpdate === true || + (this.getNamePath().length && this.getValue() !== this.lastRenderedValue) + ) { this.reRender(); } } @@ -583,6 +589,7 @@ class Field extends React.PureComponent implemen const { getInternalHooks, getFieldsValue }: InternalFormInstance = fieldContext; const { dispatch } = getInternalHooks(HOOK_MARK); const value = this.getValue(); + this.lastRenderedValue = value; const mergedGetValueProps = getValueProps || ((val: StoreValue) => ({ [valuePropName]: val })); const originTriggerFunc = childProps[trigger]; diff --git a/src/FieldContext.ts b/src/FieldContext.ts index d5799a0e..1113cfda 100644 --- a/src/FieldContext.ts +++ b/src/FieldContext.ts @@ -36,6 +36,7 @@ const Context = React.createContext({ useSubscribe: warningFunc, setInitialValues: warningFunc, destroyForm: warningFunc, + backupStore: warningFunc, setCallbacks: warningFunc, registerWatch: warningFunc, getFields: warningFunc, diff --git a/src/Form.tsx b/src/Form.tsx index a07920d7..43c9d1f8 100644 --- a/src/Form.tsx +++ b/src/Form.tsx @@ -19,6 +19,34 @@ type BaseFormProps = Omit, 'onSubmit' type RenderProps = (values: Store, form: FormInstance) => React.ReactNode; +class FormStoreSnapshot extends React.Component<{ + backupStore: () => () => void; + clearOnDestroy?: boolean; +}> { + private restoreStore?: () => void; + private hasMounted = false; + private clearOnDestroy?: boolean; + + public componentDidMount() { + // Match the value captured when Form's unmount effect first mounts. + if (!this.hasMounted) { + this.hasMounted = true; + this.clearOnDestroy = this.props.clearOnDestroy; + } + this.restoreStore?.(); + } + + public componentWillUnmount() { + if (this.clearOnDestroy) { + this.restoreStore = this.props.backupStore(); + } + } + + public render() { + return null; + } +} + export interface FormProps extends BaseFormProps { initialValues?: Store; form?: FormInstance; @@ -70,6 +98,7 @@ const Form: React.ForwardRefRenderFunction = (props, ref) => setValidateMessages, setPreserve, destroyForm, + backupStore, } = (formInstance as InternalFormInstance).getInternalHooks(HOOK_MARK); // Pass ref with form instance @@ -158,6 +187,8 @@ const Form: React.ForwardRefRenderFunction = (props, ref) => const wrapperNode = ( + {/* Restore replayed mounts before fields register or read the store in layout effects. */} + {childrenNode} ); diff --git a/src/hooks/useForm.ts b/src/hooks/useForm.ts index 0385e563..be229567 100644 --- a/src/hooks/useForm.ts +++ b/src/hooks/useForm.ts @@ -63,6 +63,8 @@ export class FormStore { private store: Store = {}; + private explicitValueUpdateVersion = 0; + private fieldEntities: FieldEntity[] = []; private initialValues: Store = {}; @@ -114,6 +116,7 @@ export class FormStore { useSubscribe: this.useSubscribe, setInitialValues: this.setInitialValues, destroyForm: this.destroyForm, + backupStore: this.backupStore, setCallbacks: this.setCallbacks, setValidateMessages: this.setValidateMessages, getFields: this.getFields, @@ -173,6 +176,17 @@ export class FormStore { } }; + private backupStore = () => { + const store = this.store; + const version = this.explicitValueUpdateVersion; + return () => { + // Explicit writes or resets after cleanup take precedence over replay recovery. + if (this.explicitValueUpdateVersion === version) { + this.updateStore(store); + } + }; + }; + private getInitialValue = (namePath: InternalNamePath) => { const initValue = getValue(this.initialValues, namePath); @@ -565,6 +579,7 @@ export class FormStore { private resetFields = (nameList?: NamePath[]) => { this.warningUnhooked(); + this.explicitValueUpdateVersion += 1; const prevStore = this.store; if (!nameList) { @@ -600,6 +615,7 @@ export class FormStore { // Value if ('value' in data) { + this.explicitValueUpdateVersion += 1; this.updateStore(setValue(this.store, namePath, data.value)); } @@ -762,6 +778,7 @@ export class FormStore { private updateValue = (name: NamePath, value: StoreValue) => { const namePath = getNamePath(name); const prevStore = this.store; + this.explicitValueUpdateVersion += 1; this.updateStore(setValue(this.store, namePath, value)); this.notifyObservers(prevStore, [namePath], { @@ -793,6 +810,7 @@ export class FormStore { const prevStore = this.store; if (store) { + this.explicitValueUpdateVersion += 1; const nextStore = merge(this.store, store); this.updateStore(nextStore); } diff --git a/src/interface.ts b/src/interface.ts index 0946160f..0aac7762 100644 --- a/src/interface.ts +++ b/src/interface.ts @@ -235,6 +235,7 @@ export interface InternalHooks { useSubscribe: (subscribable: boolean) => void; setInitialValues: (values: Store, init: boolean) => void; destroyForm: (clearOnDestroy?: boolean) => void; + backupStore: () => () => void; setCallbacks: (callbacks: Callbacks) => void; registerWatch: (callback: WatchCallBack) => () => void; getFields: (namePathList?: InternalNamePath[]) => FieldData[]; diff --git a/tests/clearOnDestroy.test.tsx b/tests/clearOnDestroy.test.tsx index 2f2cdb3c..f9a48130 100644 --- a/tests/clearOnDestroy.test.tsx +++ b/tests/clearOnDestroy.test.tsx @@ -1,5 +1,5 @@ -import { fireEvent, render } from '@testing-library/react'; -import React, { useState } from 'react'; +import { act, fireEvent, render } from '@testing-library/react'; +import React, { useState, useLayoutEffect } from 'react'; import Form, { Field, type FormInstance } from '../src'; import { changeValue, getInput } from './common'; import { Input } from './common/InfoField'; @@ -63,3 +63,654 @@ describe('Form.clearOnDestroy', () => { expect(formCache.getFieldsValue(true)).toEqual({ count: '1' }); }); }); + +describe('clearOnDestroy initialization across mount replay', () => { + it('does not rerender unchanged named fields or report initialization changes on ordinary mount', () => { + let formCache: FormInstance; + const onRender = jest.fn(); + const onValuesChange = jest.fn(); + const onFieldsChange = jest.fn(); + const TrackedInput = (props: React.ComponentProps) => { + onRender(); + return ; + }; + const Demo = () => { + const [form] = Form.useForm(); + formCache = form; + return ( +
+ + + +
+ ); + }; + const { container } = render(); + expect(onRender).toHaveBeenCalledTimes(1); + expect(container.querySelector('input').value).toBe('1'); + expect(formCache.isFieldsTouched()).toBe(false); + expect(onValuesChange).not.toHaveBeenCalled(); + expect(onFieldsChange).not.toHaveBeenCalled(); + }); + + const Activity = ( + React as { + Activity?: React.ComponentType<{ + mode: 'hidden' | 'visible'; + children: React.ReactNode; + }>; + } + ).Activity; + + (Activity ? it.each : it.skip.each)(['initialValue', 'metadata'])( + 'restores saved values after %s updates while Activity is hidden', + update => { + let formCache: FormInstance; + const Demo = ({ mode, fresh = false }: { mode: 'hidden' | 'visible'; fresh?: boolean }) => { + const [form] = Form.useForm(); + formCache = form; + return ( + +
+ + + + {fresh && ( + + + + )} +
+
+ ); + }; + const { container, rerender, unmount } = render(); + act(() => formCache.setFieldsValue({ preloaded: 'saved-preloaded' })); + fireEvent.change(container.querySelector('input'), { target: { value: 'edited' } }); + rerender(); + expect(formCache.getFieldsValue(true)).toEqual({}); + const fresh = update === 'initialValue'; + if (fresh) { + rerender(); + expect(formCache.getFieldsValue(true)).toEqual({ fresh: 'fresh-default' }); + } else { + act(() => formCache.setFields([{ name: 'count', touched: false }])); + expect(formCache.getFieldsValue(true)).toEqual({}); + } + rerender(); + expect(formCache.getFieldsValue(true)).toEqual({ + count: 'edited', + preloaded: 'saved-preloaded', + ...(fresh ? { fresh: 'fresh-default' } : {}), + }); + expect(container.querySelector('input').value).toBe('edited'); + unmount(); + expect(formCache.getFieldsValue(true)).toEqual({}); + }, + ); + + (Activity ? it.each : it.skip.each)([ + 'setFieldsValue', + 'setFieldValue', + 'setFields', + 'resetFields', + ])('keeps explicit %s changes made while Activity is hidden', operation => { + let formCache: FormInstance; + const Demo = ({ mode }: { mode: 'hidden' | 'visible' }) => { + const [form] = Form.useForm(); + formCache = form; + return ( + +
+ + + +
+
+ ); + }; + const { container, rerender, unmount } = render(); + fireEvent.change(container.querySelector('input'), { target: { value: 'edited' } }); + rerender(); + expect(formCache.getFieldsValue(true)).toEqual({}); + const expected = + operation === 'resetFields' + ? { count: '1' } + : operation === 'setFieldsValue' + ? { count: 'external', extra: 'new' } + : { count: 'external' }; + act(() => { + if (operation === 'setFieldsValue') { + formCache.setFieldsValue(expected); + } else if (operation === 'setFieldValue') { + formCache.setFieldValue('count', 'external'); + } else if (operation === 'setFields') { + formCache.setFields([{ name: 'count', value: 'external' }]); + } else { + formCache.resetFields(); + } + }); + expect(formCache.getFieldsValue(true)).toEqual(expected); + rerender(); + expect(formCache.getFieldsValue(true)).toEqual(expected); + expect(container.querySelector('input').value).toBe(expected.count); + unmount(); + expect(formCache.getFieldsValue(true)).toEqual({}); + }); + + (Activity ? it : it.skip)( + 'uses clearOnDestroy at first visible mount after initially hidden rendering', + () => { + let formCache: FormInstance; + const reads: unknown[] = []; + const Probe = ({ form }: { form: FormInstance }) => { + useLayoutEffect(() => { + reads.push(form.getFieldValue('count')); + }, []); + return null; + }; + const Demo = ({ + mode, + clearOnDestroy, + }: { + mode: 'hidden' | 'visible'; + clearOnDestroy: boolean; + }) => { + const [form] = Form.useForm(); + formCache = form; + return ( + +
+ + +
+ ); + }; + const { rerender, unmount } = render( + + + , + ); + expect(formCache.getFieldsValue(true)).toEqual({ count: '1' }); + expect(reads).toEqual([]); + rerender( + + + , + ); + rerender( + + + , + ); + expect(reads).toEqual(['1', '1']); + expect(formCache.getFieldsValue(true)).toEqual({ count: '1' }); + rerender( + + + , + ); + expect(formCache.getFieldsValue(true)).toEqual({}); + rerender( + + + , + ); + expect(reads).toEqual(['1', '1', '1', '1']); + expect(formCache.getFieldsValue(true)).toEqual({ count: '1' }); + unmount(); + expect(formCache.getFieldsValue(true)).toEqual({}); + }, + ); + + it.each(['array', 'fragment'])( + 'keeps edited fields mounted when single children change to %s', + shape => { + const onMount = jest.fn(); + const onUnmount = jest.fn(); + let formCache: FormInstance; + const TrackedInput = (props: React.ComponentProps) => { + React.useEffect(() => { + onMount(); + return onUnmount; + }, []); + return ; + }; + const Demo = ({ multiple }: { multiple?: boolean }) => { + const [form] = Form.useForm(); + formCache = form; + const field = ( + + + + ); + const children = multiple ? shape === 'array' ? [field] : <>{field} : field; + return ( +
+ {children} +
+ ); + }; + const { container, rerender, unmount } = render(); + fireEvent.change(container.querySelector('input'), { target: { value: 'edited' } }); + rerender(); + expect(onMount).toHaveBeenCalledTimes(1); + expect(onUnmount).not.toHaveBeenCalled(); + expect(container.querySelector('input').value).toBe('edited'); + expect(formCache.getFieldsValue(true)).toEqual({ count: 'edited' }); + rerender(); + expect(onMount).toHaveBeenCalledTimes(1); + expect(onUnmount).not.toHaveBeenCalled(); + expect(container.querySelector('input').value).toBe('edited'); + expect(formCache.getFieldsValue(true)).toEqual({ count: 'edited' }); + unmount(); + expect(onUnmount).toHaveBeenCalledTimes(1); + expect(formCache.getFieldsValue(true)).toEqual({}); + }, + ); + + it('keeps preloaded store values across replay and discards them after each real unmount', () => { + let formCache: FormInstance; + const onValuesChange = jest.fn(); + const Demo = ({ load }: { load?: boolean }) => { + const [form] = Form.useForm(); + formCache = form; + return load ? ( +
+ + + +
+ ) : null; + }; + const { container, rerender } = render( + + + , + ); + act(() => formCache.setFieldsValue({ count: 'preloaded', other: { value: 'seeded' } })); + rerender( + + + , + ); + expect(container.querySelector('input').value).toBe('preloaded'); + expect(formCache.getFieldsValue(true)).toEqual({ + count: 'preloaded', + other: { value: 'seeded' }, + }); + expect(formCache.isFieldsTouched()).toBe(false); + expect(onValuesChange).not.toHaveBeenCalled(); + for (let i = 0; i < 3; i += 1) { + fireEvent.change(container.querySelector('input'), { target: { value: `changed-${i}` } }); + expect(onValuesChange).toHaveBeenCalledTimes(i + 1); + expect(formCache.getFieldValue('count')).toBe(`changed-${i}`); + rerender( + + + , + ); + expect(formCache.getFieldsValue(true)).toEqual({}); + rerender( + + + , + ); + expect(container.querySelector('input').value).toBe('1'); + expect(formCache.getFieldsValue(true)).toEqual({ count: '1' }); + expect(formCache.isFieldsTouched()).toBe(false); + } + }); + + it('keeps displayed initialValues after a parent rerender in StrictMode', () => { + const Demo = () => ( +
+ + + +
+ ); + const { container, rerender } = render( + + + , + ); + expect(container.querySelector('input').value).toBe('1'); + rerender( + + + , + ); + expect(container.querySelector('input').value).toBe('1'); + }); + + it.each([false, true])('initializes a delayed field with StrictMode=%s', strict => { + const Wrapper = strict ? React.StrictMode : React.Fragment; + const Demo = ({ field }: { field?: boolean }) => ( +
+ {field && ( + + + + )} +
+ ); + const { container, rerender } = render( + + + , + ); + expect(container.querySelector('input')).toBeNull(); + rerender( + + + , + ); + expect(container.querySelector('input').value).toBe('1'); + }); + + it('preserves initialValues and edits with clearOnDestroy=false in StrictMode', () => { + let formCache: FormInstance; + const Demo = () => { + const [form] = Form.useForm(); + formCache = form; + return ( +
+ + + +
+ ); + }; + const { container, rerender } = render( + + + , + ); + expect(formCache.getFieldsValue(true)).toEqual({ count: '1' }); + fireEvent.change(container.querySelector('input'), { target: { value: 'changed' } }); + expect(formCache.getFieldsValue(true)).toEqual({ count: 'changed' }); + rerender( + + + , + ); + expect(container.querySelector('input').value).toBe('changed'); + expect(formCache.getFieldsValue(true)).toEqual({ count: 'changed' }); + }); + + it.each([false, true])('keeps initialValues with StrictMode=%s', strict => { + let formCache: FormInstance; + const Demo = ({ load }: { load?: boolean }) => { + const [form] = Form.useForm(); + formCache = form; + return load ? ( +
+ + + +
+ ) : null; + }; + const Wrapper = strict ? React.StrictMode : React.Fragment; + const { container, rerender } = render( + + + , + ); + expect(container.querySelector('input').value).toBe('1'); + expect(formCache.getFieldsValue(true)).toEqual({ count: '1' }); + fireEvent.change(container.querySelector('input'), { target: { value: 'changed' } }); + expect(formCache.getFieldsValue(true)).toEqual({ count: 'changed' }); + rerender( + + + , + ); + expect(container.querySelector('input')).toBeNull(); + expect(formCache.getFieldsValue(true)).toEqual({}); + rerender( + + + , + ); + expect(container.querySelector('input').value).toBe('1'); + expect(formCache.getFieldsValue(true)).toEqual({ count: '1' }); + }); + + it.each([false, true])( + 'keeps mount values available to child layout effects with preserve=%s', + preserve => { + const onValuesChange = jest.fn(); + const onFieldsChange = jest.fn(); + const reads: unknown[] = []; + let formCache: FormInstance; + const Probe = ({ form }: { form: FormInstance }) => { + useLayoutEffect(() => { + reads.push(form.getFieldValue('count')); + form.setFieldsValue({ count: 'mounted' }); + }, []); + return null; + }; + const Demo = ({ load = true }: { load?: boolean }) => { + const [form] = Form.useForm(); + formCache = form; + const count = Form.useWatch('count', form); + return ( + <> + {count ?? 'missing'} + {load && ( +
+ + + + + + )} + + ); + }; + const { container, rerender } = render( + + + , + ); + expect(reads).toEqual(['1', 'mounted']); + expect(container.querySelector('input').value).toBe('mounted'); + expect(container.querySelector('span').textContent).toBe('mounted'); + expect(formCache.getFieldsValue(true)).toEqual({ count: 'mounted' }); + expect(formCache.isFieldTouched('count')).toBe(true); + expect(onValuesChange).not.toHaveBeenCalled(); + expect(onFieldsChange).not.toHaveBeenCalled(); + rerender( + + + , + ); + expect(formCache.getFieldsValue(true)).toEqual({}); + rerender( + + + , + ); + expect(reads).toEqual(['1', 'mounted', '1', 'mounted']); + expect(formCache.getFieldsValue(true)).toEqual({ count: 'mounted' }); + }, + ); + + it('does not touch fields or trigger change callbacks during initialization', () => { + let formCache: FormInstance; + const onValuesChange = jest.fn(); + const onFieldsChange = jest.fn(); + const Demo = () => { + const [form] = Form.useForm(); + formCache = form; + return ( +
+ + + +
+ ); + }; + const { container } = render( + + + , + ); + expect(container.querySelector('input').value).toBe('1'); + expect(formCache.getFieldsValue(true)).toEqual({ count: '1' }); + expect(formCache.isFieldsTouched()).toBe(false); + expect(onValuesChange).not.toHaveBeenCalled(); + expect(onFieldsChange).not.toHaveBeenCalled(); + }); + + it.each([false, 'form'])( + 'keeps initialization and real unmount clearing with component=%s and render props', + component => { + let formCache: FormInstance; + const Demo = ({ load = true }: { load?: boolean }) => { + const [form] = Form.useForm(); + formCache = form; + return load ? ( +
+ {values => ( + <> + {values.count} + + + + + )} +
+ ) : null; + }; + const { container, rerender } = render( + + + , + ); + expect(container.querySelector('form') === null).toBe(component === false); + expect(container.querySelector('span').textContent).toBe('1'); + expect(formCache.getFieldsValue(true)).toEqual({ count: '1' }); + fireEvent.change(container.querySelector('input'), { target: { value: 'changed' } }); + expect(container.querySelector('span').textContent).toBe('changed'); + rerender( + + + , + ); + expect(formCache.getFieldsValue(true)).toEqual({}); + rerender( + + + , + ); + expect(container.querySelector('span').textContent).toBe('1'); + expect(formCache.getFieldsValue(true)).toEqual({ count: '1' }); + }, + ); + + it('preserves controlled fields without marking them touched during replay', () => { + let formCache: FormInstance; + const Demo = () => { + const [form] = Form.useForm(); + formCache = form; + return ( +
+ + + +
+ ); + }; + const { container, rerender } = render( + + + , + ); + expect(container.querySelector('input').value).toBe('controlled'); + expect(formCache.getFieldsValue(true)).toEqual({ count: 'controlled' }); + expect(formCache.isFieldsTouched()).toBe(false); + rerender( + + + , + ); + expect(container.querySelector('input').value).toBe('controlled'); + }); + + it('keeps list values across replay and resets them after real unmount with preserve=false', () => { + let formCache: FormInstance; + const Demo = ({ load = true }: { load?: boolean }) => { + const [form] = Form.useForm(); + formCache = form; + return load ? ( +
+ + {fields => + fields.map(field => ( + + + + )) + } + +
+ ) : null; + }; + const { container, rerender } = render( + + + , + ); + expect(formCache.getFieldsValue(true)).toEqual({ rows: ['1', '2'] }); + fireEvent.change(container.querySelector('input'), { target: { value: 'changed' } }); + expect(formCache.getFieldsValue(true)).toEqual({ rows: ['changed', '2'] }); + rerender( + + + , + ); + expect(formCache.getFieldsValue(true)).toEqual({}); + rerender( + + + , + ); + expect(formCache.getFieldsValue(true)).toEqual({ rows: ['1', '2'] }); + expect(Array.from(container.querySelectorAll('input'), input => input.value)).toEqual([ + '1', + '2', + ]); + }); +});