Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
138dd5a
Fix #1050: useField returns Form initialValues on first render
Feb 3, 2026
03ec690
Fix #1055: Prevent overwriting getter-only properties in renderCompon…
erikras Feb 4, 2026
dcf1964
DEBUG: Add logging to diagnose initialValues issue
Feb 4, 2026
b114fd1
Fix #1050: Register field synchronously to capture Form initialValues
Feb 4, 2026
6631137
Fix #1050: Use v6.5.9 synchronous registration approach
Feb 4, 2026
31bc98b
Remove precedence test - not part of original issue
Feb 4, 2026
69c3fbb
Fix failing tests: Update test expectations and pause validation
Feb 4, 2026
ac12635
Update Field tests for correct initial render behavior
Feb 4, 2026
6a763c7
Fix validation count: expect 3 calls instead of 2
Feb 4, 2026
e7eee71
Fix validation count: back to 2 (pauseValidation prevents sync call)
Feb 4, 2026
e3837b5
Fix #1055: Remove spreading of lazy state in FormSpy renderProps (#1059)
erikras-richard-agent Feb 4, 2026
76616e6
Fix #1050: useField returns Form initialValues on first render (#1060)
erikras-richard-agent Feb 4, 2026
19cbe2c
Fix: Support type='select' for multiple select defaulting to [] (#1061)
erikras-richard-agent Feb 6, 2026
c8e8fbc
Fix: Update test expectation for validation call count
erikras-dinesh-agent Feb 6, 2026
d8e1a56
Refactor: Use useSyncExternalStore for #1050 fix
erikras-dinesh-agent Feb 6, 2026
b27453b
Fix: Address CodeRabbit issues with useSyncExternalStore
erikras-dinesh-agent Feb 6, 2026
759d95c
Fix: Address CodeRabbit DRY, stale state, and nested path issues
erikras-dinesh-agent Feb 6, 2026
d7a8d90
Fix: Use Final Form's getIn and support defaultValue + live values
erikras-dinesh-agent Feb 6, 2026
5bc2c21
Fix: Use subscription callback state in getSnapshot
erikras-dinesh-agent Feb 6, 2026
e08e6ea
Fix: Default data to {} and update test for useSyncExternalStore
erikras-dinesh-agent Feb 6, 2026
31ad254
Add use-sync-external-store shim for React 16.8+ compatibility
erikras-dinesh-agent Feb 6, 2026
a708212
Fix: Separate initial from value in fallback state
erikras-dinesh-agent Feb 6, 2026
c52d897
Merge main into fix/issue-1050-usefield-undefined-initial
Feb 10, 2026
2e9a798
Fix select multiple handling for both component and type props
Feb 10, 2026
3cffe3e
Remove package-lock.json (using yarn, not npm)
Feb 10, 2026
01e23c5
Merge main - keep useSyncExternalStore implementation
Feb 10, 2026
aceb1b8
Fix CodeRabbit review comments
Feb 10, 2026
b4c1801
Remove unused imports and variables
Feb 10, 2026
093a2f0
Apply hasOwnProperty fix consistently and improve comment
Feb 10, 2026
0061dd8
Fix: Compute dirty in fallback FieldState by comparing value and initial
Feb 13, 2026
ef48a8d
Fix: Use custom isEqual comparator in buildFallbackFieldState
Feb 13, 2026
6d0f2eb
Fix: Add type to useEffect dependency array
Feb 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
8 changes: 5 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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"
}
}
2 changes: 1 addition & 1 deletion src/ReactFinalForm.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions src/renderComponent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export default function renderComponent<T>(
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]);
}
}
Expand All @@ -34,7 +34,7 @@ export default function renderComponent<T>(
// 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]);
}
}
Expand All @@ -53,7 +53,7 @@ export default function renderComponent<T>(
// 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]);
}
}
Expand Down
3 changes: 3 additions & 0 deletions src/use-sync-external-store-shim.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
declare module 'use-sync-external-store/shim' {
export * from 'use-sync-external-store';
}
Comment thread
erikras marked this conversation as resolved.
253 changes: 174 additions & 79 deletions src/useField.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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) => {
Expand All @@ -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<any>,
initialValue: any,
defaultValue: any,
component: React.ComponentType<any> | "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<any> => {
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;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Handle allowNull for both initial and value
if (initial === null && !allowNull) {
initial = undefined;
}
if (value === null && !allowNull) {
value = undefined;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// 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,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
submitFailed: false,
submitSucceeded: false,
submitting: false,
touched: false,
valid: true,
validating: false,
visited: false,
value,
} as FieldState<any>;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
};

function useField<
FieldValue = any,
T extends HTMLElement = HTMLElement,
FormValues = Record<string, any>,
>(name: string, config: UseFieldConfig = {}): FieldRenderProps<FieldValue, T> {
const {
afterSubmit,
allowNull,
component,
data,
Expand All @@ -45,9 +126,7 @@ function useField<
initialValue,
multiple,
parse = defaultParse,
subscription = all,
type,
validateFields,
value: _value,
} = config;
const form: FormApi<FormValues> = useForm<FormValues>("useField");
Expand All @@ -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,
Expand Down Expand Up @@ -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<FieldState<any>>(() => {
// 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(() => {}, []);

Comment thread
coderabbitai[bot] marked this conversation as resolved.
// Memoized fallback state for when field is not yet registered
const fallbackStateRef = React.useRef<FieldState<any> | null>(null);

// Store the latest field state from subscription callback
// This ensures getSnapshot only returns state when subscribed fields change
const latestStateRef = React.useRef<FieldState<any> | 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]);

Comment thread
coderabbitai[bot] marked this conversation as resolved.
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],
Comment thread
coderabbitai[bot] marked this conversation as resolved.
),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
// 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,
);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return fallbackStateRef.current;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
},
// 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);
Expand Down
Loading