diff --git a/eslint.config.mjs b/eslint.config.mjs index f6b1a2e..3f643a5 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -168,7 +168,13 @@ export default [ ecmaVersion: "latest", sourceType: "module", globals: { - ...nodeGlobals, + // Only include Node.js globals that are valid in ES modules + // Exclude CommonJS-only: require, module, exports, __dirname, __filename + ...Object.fromEntries( + Object.entries(nodeGlobals).filter( + ([key]) => !["require", "module", "exports", "__dirname", "__filename"].includes(key) + ) + ), }, }, rules: { diff --git a/package.json b/package.json index b706a0d..64ee810 100644 --- a/package.json +++ b/package.json @@ -42,19 +42,19 @@ "@rollup/plugin-node-resolve": "^15.2.4", "@rollup/plugin-replace": "^5.0.7", "@rollup/plugin-terser": "^0.4.4", + "@size-limit/preset-small-lib": "^11.1.6", "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.0", "@types/node": "^20.17.50", "@types/react": "^19.1.5", "@types/react-dom": "^19.1.5", + "@types/use-sync-external-store": "^1.5.0", "@typescript-eslint/eslint-plugin": "^8.32.1", "@typescript-eslint/parser": "^8.32.1", "babel-core": "^7.0.0-bridge.0", "babel-eslint": "^10.1.0", "babel-jest": "^29.7.0", - "@size-limit/preset-small-lib": "^11.1.6", - "size-limit": "^11.1.6", "doctoc": "^2.2.1", "dtslint": "^4.2.1", "eslint": "^9.27.0", @@ -79,6 +79,7 @@ "react": "^19.1.0", "react-dom": "^19.1.0", "rollup": "^3.29.5", + "size-limit": "^11.1.6", "tar": "^7.4.3", "ts-essentials": "^10.0.4", "tslint": "^6.1.3", @@ -124,6 +125,7 @@ "url": "https://opencollective.com/final-form" }, "dependencies": { - "@babel/runtime": "^7.15.4" + "@babel/runtime": "^7.15.4", + "use-sync-external-store": "^1.6.0" } } diff --git a/src/ReactFinalForm.test.js b/src/ReactFinalForm.test.js index 5b6c575..805e8fd 100644 --- a/src/ReactFinalForm.test.js +++ b/src/ReactFinalForm.test.js @@ -924,7 +924,7 @@ describe("ReactFinalForm", () => { ); expect(formMock).toHaveBeenCalled(); - // called once on first render to get initial state, and then again to subscribe + // With useSyncExternalStore, registerField is called once during subscribe expect(formMock).toHaveBeenCalledTimes(1); expect(formMock.mock.calls[0][0]).toBe("name"); expect(formMock.mock.calls[0][2].active).toBe(true); // default subscription diff --git a/src/renderComponent.ts b/src/renderComponent.ts index 5544d9d..075a94d 100644 --- a/src/renderComponent.ts +++ b/src/renderComponent.ts @@ -17,7 +17,7 @@ export default function renderComponent( Object.defineProperties(result, Object.getOwnPropertyDescriptors(lazyProps)); const restDescriptors = Object.getOwnPropertyDescriptors(rest); for (const key in restDescriptors) { - if (!(key in result)) { + if (!Object.prototype.hasOwnProperty.call(result, key)) { Object.defineProperty(result, key, restDescriptors[key]); } } @@ -34,7 +34,7 @@ export default function renderComponent( // Only add properties from rest that don't already exist const restDescriptors = Object.getOwnPropertyDescriptors(rest); for (const key in restDescriptors) { - if (!(key in (result as any))) { + if (!Object.prototype.hasOwnProperty.call(result as any, key)) { Object.defineProperty(result as any, key, restDescriptors[key]); } } @@ -53,7 +53,7 @@ export default function renderComponent( // Only add properties from rest that don't already exist const restDescriptors = Object.getOwnPropertyDescriptors(rest); for (const key in restDescriptors) { - if (!(key in (result as any))) { + if (!Object.prototype.hasOwnProperty.call(result as any, key)) { Object.defineProperty(result as any, key, restDescriptors[key]); } } diff --git a/src/use-sync-external-store-shim.d.ts b/src/use-sync-external-store-shim.d.ts new file mode 100644 index 0000000..dec685e --- /dev/null +++ b/src/use-sync-external-store-shim.d.ts @@ -0,0 +1,3 @@ +declare module 'use-sync-external-store/shim' { + export * from 'use-sync-external-store'; +} diff --git a/src/useField.ts b/src/useField.ts index d159213..98d6d04 100644 --- a/src/useField.ts +++ b/src/useField.ts @@ -1,4 +1,5 @@ import * as React from "react"; +import { useSyncExternalStore } from "use-sync-external-store/shim"; import { fieldSubscriptionItems, getIn } from "final-form"; import type { FieldSubscription, FieldState, FormApi } from "final-form"; import type { @@ -12,7 +13,6 @@ import useForm from "./useForm"; import useLatest from "./useLatest"; import { addLazyFieldMetaState } from "./getters"; import useConstantCallback from "./useConstantCallback"; -import shallowEqual from "./shallowEqual"; const all: FieldSubscription = fieldSubscriptionItems.reduce( (result: any, key) => { @@ -29,13 +29,94 @@ const defaultParse = (value: any, _name: string) => const defaultIsEqual = (a: any, b: any): boolean => a === b; +// Helper to build fallback field state when field is not yet registered +const buildFallbackFieldState = ( + name: string, + form: FormApi, + initialValue: any, + defaultValue: any, + component: React.ComponentType | "input" | "select" | "textarea" | undefined, + type: string | undefined, + multiple: boolean | undefined, + allowNull: boolean | undefined, + data: any, + stableBlur: () => void, + stableChange: () => void, + stableFocus: () => void, + isEqual: (a: any, b: any) => boolean = defaultIsEqual, +): FieldState => { + const formState = form.getState(); + + // Compute initial value (never includes live values from form.change()) + // Priority: initialValues > initialValue prop > defaultValue > select multiple default + let initial: any; + const formInitialValue = getIn(formState.initialValues || {}, name); + if (formInitialValue !== undefined) { + initial = formInitialValue; + } else if (initialValue !== undefined) { + initial = initialValue; + } else if (defaultValue !== undefined) { + initial = defaultValue; + } else if ((component === "select" || type === "select") && multiple) { + initial = []; + } + + // Compute current value (prefers live values from form.change() calls) + // Priority: live values > initialValues > initialValue prop > defaultValue > select multiple default + let value: any; + const liveValue = getIn(formState.values, name); + if (liveValue !== undefined) { + value = liveValue; + } else { + value = initial; + } + + // Handle allowNull for both initial and value + if (initial === null && !allowNull) { + initial = undefined; + } + if (value === null && !allowNull) { + value = undefined; + } + + // Compute dirty by comparing value and initial + // Use provided isEqual comparator (respects custom form config) + const dirty = !isEqual(value, initial); + + return { + active: false, + blur: stableBlur, + change: stableChange, + data: data ?? {}, + dirty, + dirtySinceLastSubmit: false, + error: undefined, + focus: stableFocus, + initial, + invalid: false, + length: undefined, + modified: false, + modifiedSinceLastSubmit: false, + name, + pristine: !dirty, + submitError: undefined, + submitFailed: false, + submitSucceeded: false, + submitting: false, + touched: false, + valid: true, + validating: false, + visited: false, + value, + } as FieldState; +}; + function useField< FieldValue = any, T extends HTMLElement = HTMLElement, FormValues = Record, >(name: string, config: UseFieldConfig = {}): FieldRenderProps { const { - afterSubmit, allowNull, component, data, @@ -45,9 +126,7 @@ function useField< initialValue, multiple, parse = defaultParse, - subscription = all, type, - validateFields, value: _value, } = config; const form: FormApi = useForm("useField"); @@ -63,8 +142,8 @@ function useField< // whereas actual `state` would defined in the subsequent `useField` hook // execution // (that would be caused by `setState` call performed in `register` callback) - form.registerField(name as keyof FormValues, callback, subscription, { - afterSubmit, + form.registerField(name as keyof FormValues, callback, configRef.current.subscription || all, { + afterSubmit: configRef.current.afterSubmit, beforeSubmit: () => { const { beforeSubmit, @@ -92,83 +171,99 @@ function useField< initialValue, isEqual: (a: any, b: any) => (configRef.current.isEqual || defaultIsEqual)(a, b), silent, - validateFields, + validateFields: configRef.current.validateFields, }); - // Initialize state with proper field state from Final Form without callbacks - const [state, setState] = React.useState>(() => { - // Get the current field state from Final Form without registering callbacks - const existingFieldState = form.getFieldState(name as keyof FormValues); - - if (existingFieldState) { - // If allowNull is true and the initial value was null, preserve it - // (and its formatted version is not null, meaning it was formatted away) - if (allowNull && existingFieldState.initial === null && existingFieldState.value !== null) { - return { - ...existingFieldState, - value: null, // Force value back to null - initial: null, // Ensure our local state's 'initial' also reflects this - }; - } - return existingFieldState; - } - - // FIX #1050: Check Form initialValues before falling back to field initialValue - // If no existing state, create a proper initial state - const formState = form.getState(); - // Use getIn to support nested field paths like "user.name" or "items[0].id" - const formInitialValue = getIn(formState.initialValues, name); - - // Use Form initialValues if available, otherwise use field initialValue - let initialStateValue = formInitialValue !== undefined ? formInitialValue : initialValue; - - if ((component === "select" || type === "select") && multiple && initialStateValue === undefined) { - initialStateValue = []; - } - - return { - active: false, - blur: () => { }, - change: () => { }, - data: data || {}, - dirty: false, - dirtySinceLastSubmit: false, - error: undefined, - focus: () => { }, - initial: initialStateValue, - invalid: false, - length: undefined, - modified: false, - modifiedSinceLastSubmit: false, - name, - pristine: true, - submitError: undefined, - submitFailed: false, - submitSucceeded: false, - submitting: false, - touched: false, - valid: true, - validating: false, - value: initialStateValue, - visited: false, - }; - }); + // FIX #1050: Use useSyncExternalStore to properly integrate with Final Form + // This ensures Form initialValues are available on first render without + // causing side effects during render (React 18+ best practice) + + // Stable no-op functions for unregistered field state + const stableBlur = React.useCallback(() => {}, []); + const stableChange = React.useCallback(() => {}, []); + const stableFocus = React.useCallback(() => {}, []); + // Memoized fallback state for when field is not yet registered + const fallbackStateRef = React.useRef | null>(null); + + // Store the latest field state from subscription callback + // This ensures getSnapshot only returns state when subscribed fields change + const latestStateRef = React.useRef | null>(null); + + // Reset refs when key dependencies change to avoid stale values React.useEffect(() => { - // Register field after the initial render to avoid setState during render - const unregister = register((newState) => { - setState((prevState) => { - // Only update if the state actually changed - if (!shallowEqual(newState, prevState)) { - return newState; - } - return prevState; - }); - }, false); - - return unregister; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [name, data, defaultValue, initialValue]); + fallbackStateRef.current = null; + latestStateRef.current = null; + }, [name, initialValue, defaultValue, data, allowNull, component, multiple, type]); + + const state = useSyncExternalStore( + // subscribe: called when component mounts and when dependencies change + React.useCallback( + (onStoreChange) => { + return register((fieldState) => { + // Save the state from subscription callback + latestStateRef.current = fieldState; + onStoreChange(); + }, false); + }, + // Note: subscription, afterSubmit, and validateFields are intentionally omitted from deps. + // The register callback reads these from configRef.current to avoid stale closures. + // eslint-disable-next-line react-hooks/exhaustive-deps + [name, data, defaultValue, initialValue], + ), + // getSnapshot: return field state from subscription callback + () => { + // If we have state from subscription, return it + if (latestStateRef.current) { + return latestStateRef.current; + } + + // Return memoized fallback state if field not registered yet + // Must return same object reference for React 18 stability + if (!fallbackStateRef.current) { + fallbackStateRef.current = buildFallbackFieldState( + name, + form, + initialValue, + defaultValue, + component, + type, + multiple, + allowNull, + data, + stableBlur, + stableChange, + stableFocus, + configRef.current.isEqual || defaultIsEqual, + ); + } + + return fallbackStateRef.current; + }, + // getServerSnapshot: for SSR, return initial state (same as fallback) + () => { + // For SSR, we can return the fallback state which has stable references + if (!fallbackStateRef.current) { + fallbackStateRef.current = buildFallbackFieldState( + name, + form, + initialValue, + defaultValue, + component, + type, + multiple, + allowNull, + data, + stableBlur, + stableChange, + stableFocus, + configRef.current.isEqual || defaultIsEqual, + ); + } + + return fallbackStateRef.current; + }, + ); const meta: any = {}; addLazyFieldMetaState(meta, state); diff --git a/yarn.lock b/yarn.lock index 62cb337..9c7b8ce 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2078,6 +2078,11 @@ resolved "https://registry.yarnpkg.com/@types/unist/-/unist-2.0.11.tgz#11af57b127e32487774841f7a4e54eab166d03c4" integrity sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA== +"@types/use-sync-external-store@^1.5.0": + version "1.5.0" + resolved "https://registry.yarnpkg.com/@types/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz#222c28a98eb8f4f8a72c1a7e9fe6d8946eca6383" + integrity sha512-5dyB8nLC/qogMrlCizZnYWQTA4lnb/v+It+sqNl5YnSRAPMlIqY/X0Xn+gZw8vOL+TgTTr28VEbn3uf8fUtAkw== + "@types/yargs-parser@*": version "21.0.3" resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-21.0.3.tgz#815e30b786d2e8f0dcd85fd5bcf5e1a04d008f15" @@ -8937,6 +8942,11 @@ url-parse@^1.5.3: querystringify "^2.1.1" requires-port "^1.0.0" +use-sync-external-store@^1.6.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz#b174bfa65cb2b526732d9f2ac0a408027876f32d" + integrity sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w== + util-deprecate@^1.0.1, util-deprecate@~1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"