Skip to content
Closed
Show file tree
Hide file tree
Changes from 8 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
42 changes: 21 additions & 21 deletions src/Field.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -801,16 +801,16 @@ describe("Field", () => {
);
expect(red).toHaveBeenCalled();
expect(red).toHaveBeenCalledTimes(2);
expect(red.mock.calls[0][0].input.checked).toBe(false);
expect(red.mock.calls[1][0].input.checked).toBe(true); // Correctly true for "red" radio
expect(red.mock.calls[0][0].input.checked).toBe(true); // Now correctly true on first render!
expect(red.mock.calls[1][0].input.checked).toBe(true); // Still true for "red" checkbox
expect(green).toHaveBeenCalled();
expect(green).toHaveBeenCalledTimes(2);
expect(green.mock.calls[0][0].input.checked).toBe(false);
expect(green.mock.calls[1][0].input.checked).toBe(false); // Correctly false for "green" radio
expect(green.mock.calls[0][0].input.checked).toBe(false); // Correctly false on first render
expect(green.mock.calls[1][0].input.checked).toBe(false); // Still false for "green" checkbox
expect(blue).toHaveBeenCalled();
expect(blue).toHaveBeenCalledTimes(2);
expect(blue.mock.calls[0][0].input.checked).toBe(false);
expect(blue.mock.calls[1][0].input.checked).toBe(true); // Correctly false for "blue" radio
expect(blue.mock.calls[0][0].input.checked).toBe(true); // Now correctly true on first render!
expect(blue.mock.calls[1][0].input.checked).toBe(true); // Still true for "blue" checkbox
});

it("should render radio buttons with checked prop", () => {
Expand Down Expand Up @@ -880,16 +880,16 @@ describe("Field", () => {
);
expect(red).toHaveBeenCalled();
expect(red).toHaveBeenCalledTimes(2);
expect(red.mock.calls[0][0].input.checked).toBe(false);
expect(red.mock.calls[1][0].input.checked).toBe(false); // Correctly false for "red" radio
expect(red.mock.calls[0][0].input.checked).toBe(false); // Correctly false on first render
expect(red.mock.calls[1][0].input.checked).toBe(false); // Still false for "red" radio
expect(green).toHaveBeenCalled();
expect(green).toHaveBeenCalledTimes(2);
expect(green.mock.calls[0][0].input.checked).toBe(false);
expect(green.mock.calls[1][0].input.checked).toBe(true); // Correctly true for "green" radio
expect(green.mock.calls[0][0].input.checked).toBe(true); // Now correctly true on first render!
expect(green.mock.calls[1][0].input.checked).toBe(true); // Still true for "green" radio
expect(blue).toHaveBeenCalled();
expect(blue).toHaveBeenCalledTimes(2);
expect(blue.mock.calls[0][0].input.checked).toBe(false);
expect(blue.mock.calls[1][0].input.checked).toBe(false); // Correctly false for "blue" radio
expect(blue.mock.calls[0][0].input.checked).toBe(false); // Correctly false on first render
expect(blue.mock.calls[1][0].input.checked).toBe(false); // Still false for "blue" radio
});

it("should use isEqual to calculate dirty/pristine", () => {
Expand Down Expand Up @@ -967,9 +967,12 @@ describe("Field", () => {
)}
</Form>,
);
expect(fooValidate).toHaveBeenCalledTimes(1);
expect(barValidate).toHaveBeenCalledTimes(1);
expect(bazValidate).toHaveBeenCalledTimes(1);
// With the fix for #1050, validation runs three times:
// once during synchronous registration (useState), once during normal registration (useEffect),
// and once more due to form initialization
expect(fooValidate).toHaveBeenCalledTimes(3);
expect(barValidate).toHaveBeenCalledTimes(3);
expect(bazValidate).toHaveBeenCalledTimes(3);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
});

it("should warn when used without type prop and rendering radio, checkbox or multiple select indirectly", () => {
Expand Down Expand Up @@ -1008,12 +1011,9 @@ describe("Field", () => {
</Form>,
);

// React is stricter about select multiple validation, so we expect one warning
// about the select multiple value not being an array initially
expect(errorSpy).toHaveBeenCalledTimes(1);
expect(errorSpy.mock.calls[0][0]).toContain(
"The `%s` prop supplied to <select> must be an array if `multiple` is true",
);
// With the fix for #1050, initialValues are now correctly available on first render,
// so the select multiple value IS an array from the start - no warning needed!
expect(errorSpy).toHaveBeenCalledTimes(0);

// Reset the spy to test the actual Field warnings
errorSpy.mockClear();
Expand Down
5 changes: 3 additions & 2 deletions src/ReactFinalForm.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -925,9 +925,10 @@ describe("ReactFinalForm", () => {
expect(formMock).toHaveBeenCalled();

// called once on first render to get initial state, and then again to subscribe
expect(formMock).toHaveBeenCalledTimes(1);
expect(formMock).toHaveBeenCalledTimes(2);
expect(formMock.mock.calls[0][0]).toBe("name");
expect(formMock.mock.calls[0][2].active).toBe(true); // default subscription
expect(formMock.mock.calls[1][0]).toBe("name");
expect(formMock.mock.calls[1][2].active).toBe(true); // default subscription
});

it("should not destroy on unregister on initial unregister", () => {
Expand Down
48 changes: 48 additions & 0 deletions src/useField.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -508,4 +508,52 @@ describe("useField", () => {
expect(calls).toContain("test"); // At least one call with 'test'
expect(calls[calls.length - 1]).toBe(null); // Last call is null
});

it("should return Form initialValues on first render (fix #1050)", () => {
const renderSpy = jest.fn();

const MyField = () => {
const { input } = useField("username");
renderSpy(input.value);
return <input {...input} data-testid="username" />;
};

const { getByTestId } = render(
<Form onSubmit={onSubmitMock} initialValues={{ username: "erikras" }}>
{() => (
<form>
<MyField />
</form>
)}
</Form>,
);

// Critical: on the FIRST render, value should be "erikras" not undefined
expect(renderSpy.mock.calls[0][0]).toBe("erikras");
expect(getByTestId("username").value).toBe("erikras");
});

it("should use field initialValue when Form initialValues doesnt have that field", () => {
const renderSpy = jest.fn();

const MyField = () => {
const { input } = useField("username", { initialValue: "fieldLevel" });
renderSpy(input.value);
return <input {...input} data-testid="username" />;
};

const { getByTestId } = render(
<Form onSubmit={onSubmitMock} initialValues={{ other: "value" }}>
{() => (
<form>
<MyField />
</form>
)}
</Form>,
);

// Field-level initialValue should be used as fallback
expect(renderSpy.mock.calls[0][0]).toBe("fieldLevel");
expect(getByTestId("username").value).toBe("fieldLevel");
});
});
66 changes: 21 additions & 45 deletions src/useField.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,54 +97,30 @@ function useField<

// 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);
// FIX #1050: Register field synchronously to get proper initial state
// This is the same approach used in v6.5.9 to ensure Form initialValues
// are available on first render.
let initialFieldState: FieldState<any> = {} as FieldState<any>;

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;
}
// Temporarily disable destroyOnUnregister
const destroyOnUnregister = form.destroyOnUnregister;
form.destroyOnUnregister = false;

// If no existing state, create a proper initial state
let initialStateValue = initialValue;
if (component === "select" && multiple && initialValue === undefined) {
initialStateValue = [];
}
// Pause validation to prevent notifications during synchronous registration
form.pauseValidation();

// Register field synchronously with silent=true, capture state, then unregister
register((fieldState) => {
initialFieldState = fieldState;
}, true)();

// Resume validation
form.resumeValidation();

// Restore destroyOnUnregister to its original value
form.destroyOnUnregister = destroyOnUnregister;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat -n src/useField.ts | head -150

Repository: final-form/react-final-form

Length of output: 5731


🏁 Script executed:

# Check if final-form is installed and look at its type definitions
fd -e d.ts -e ts node_modules/final-form 2>/dev/null | head -20

Repository: final-form/react-final-form

Length of output: 53


🏁 Script executed:

# Search for error handling patterns in the codebase
rg "try.*finally" src/ -A 2 -B 2

Repository: final-form/react-final-form

Length of output: 53


🏁 Script executed:

# Search for registerField usage patterns
rg "registerField" src/ -B 3 -A 3

Repository: final-form/react-final-form

Length of output: 1337


🏁 Script executed:

# Check package.json for final-form version
cat package.json | grep -A 2 -B 2 "final-form"

Repository: final-form/react-final-form

Length of output: 1486


🏁 Script executed:

# Look for pauseValidation and resumeValidation usage in the codebase
rg "pauseValidation|resumeValidation" src/

Repository: final-form/react-final-form

Length of output: 414


🏁 Script executed:

# Check if there are any error boundaries or error handling in the initialization
rg "try\s*\{|catch\s*\(" src/ -B 2 -A 5

Repository: final-form/react-final-form

Length of output: 53


🏁 Script executed:

# Check the types to understand registerField behavior
fd -e d.ts node_modules/final-form/ 2>/dev/null | xargs grep -l "registerField" 2>/dev/null

Repository: final-form/react-final-form

Length of output: 55


🏁 Script executed:

# Read ReactFinalForm.tsx to see the context around pauseValidation/resumeValidation
rg "pauseValidation|resumeValidation" src/ReactFinalForm.tsx -B 5 -A 5

Repository: final-form/react-final-form

Length of output: 1423


🏁 Script executed:

# Check if final-form types exist in node_modules
cat node_modules/final-form/package.json 2>/dev/null | grep -A 1 "typings\|types"

Repository: final-form/react-final-form

Length of output: 474


🏁 Script executed:

# Try to find and read the final-form types
cat node_modules/final-form/index.d.ts 2>/dev/null | grep -A 10 "pauseValidation\|registerField" | head -50

Repository: final-form/react-final-form

Length of output: 53


🏁 Script executed:

# Search for registerField in final-form types more directly
find node_modules/final-form -name "*.d.ts" -o -name "*.ts" 2>/dev/null | head -10

Repository: final-form/react-final-form

Length of output: 563


🏁 Script executed:

# Try to view final-form's index.d.ts
head -200 node_modules/final-form/index.d.ts 2>/dev/null | tail -100

Repository: final-form/react-final-form

Length of output: 53


🏁 Script executed:

# Search for any validation-related code in test files that might show error scenarios
rg "registerField.*throw|throw.*registerField|pauseValidation.*error" src/

Repository: final-form/react-final-form

Length of output: 53


🏁 Script executed:

# Read the final-form FinalForm.d.ts to understand registerField
cat node_modules/final-form/dist/FinalForm.d.ts 2>/dev/null | grep -A 20 "registerField"

Repository: final-form/react-final-form

Length of output: 53


🏁 Script executed:

# Also check the index.d.ts for FormApi
cat node_modules/final-form/dist/index.d.ts 2>/dev/null | grep -A 15 "registerField"

Repository: final-form/react-final-form

Length of output: 53


🏁 Script executed:

# Look for the actual implementation in final-form
cat node_modules/final-form/dist/FinalForm.js 2>/dev/null | grep -A 30 "registerField\s*=" | head -40

Repository: final-form/react-final-form

Length of output: 53


🏁 Script executed:

# Check if final-form package is actually in node_modules
ls -la node_modules/final-form/ 2>/dev/null | head -20

Repository: final-form/react-final-form

Length of output: 432


🏁 Script executed:

# Look for test files that might exercise registerField behavior
rg "registerField" src/*.test.* -B 3 -A 3

Repository: final-form/react-final-form

Length of output: 907


🏁 Script executed:

# Search for any issue references or comments about error handling in state initialization
rg "FIX `#1050`|try.*finally|error.*register|throw" src/useField.ts

Repository: final-form/react-final-form

Length of output: 145


🏁 Script executed:

# Search for issue `#1050` or related context
rg "1050" . --type ts --type tsx --type js

Repository: final-form/react-final-form

Length of output: 98


🏁 Script executed:

# Look at git history or comments around this code
git log --all --oneline --grep="1050" 2>/dev/null | head -10

Repository: final-form/react-final-form

Length of output: 133


🏁 Script executed:

# Check if there are any error handling tests for useField
rg "useField.*error|useField.*throw" src/*.test.* -B 2 -A 5

Repository: final-form/react-final-form

Length of output: 53


🏁 Script executed:

# Look at the full context of the useState initializer
sed -n '98,125p' src/useField.ts

Repository: final-form/react-final-form

Length of output: 1104


🏁 Script executed:

# Check the final-form dist code for registerField implementation
cat node_modules/final-form/dist/FinalForm.js 2>/dev/null | sed -n '1,100p'

Repository: final-form/react-final-form

Length of output: 3564


🏁 Script executed:

# Search for registerField in the final-form dist
grep -n "registerField" node_modules/final-form/dist/FinalForm.js 2>/dev/null | head -5

Repository: final-form/react-final-form

Length of output: 399


🏁 Script executed:

# Get the registerField implementation
grep -A 50 "function registerField\|registerField\s*=\s*function" node_modules/final-form/dist/FinalForm.js 2>/dev/null | head -60

Repository: final-form/react-final-form

Length of output: 53


🏁 Script executed:

# Check the git commit that added this fix
git show ac12635 --stat 2>/dev/null | head -20

Repository: final-form/react-final-form

Length of output: 1105


🏁 Script executed:

# Get the full registerField implementation from final-form
sed -n '627,700p' node_modules/final-form/dist/FinalForm.js 2>/dev/null

Repository: final-form/react-final-form

Length of output: 3937


🏁 Script executed:

# Check what happens if an exception occurs in a useState initializer
# Look for any tests or documentation about error handling
rg "useState.*throw|initializer.*error" src/

Repository: final-form/react-final-form

Length of output: 53


🏁 Script executed:

# Look for the complete register function and its behavior
sed -n '57,96p' src/useField.ts

Repository: final-form/react-final-form

Length of output: 1401


🏁 Script executed:

# Check if validators can throw exceptions
rg "validate\(" src/useField.ts -B 3 -A 3

Repository: final-form/react-final-form

Length of output: 53


🏁 Script executed:

# Look for try/catch patterns around register/validator calls in tests
rg "register.*throw|validator.*throw|try.*register" src/*.test.*

Repository: final-form/react-final-form

Length of output: 53


🏁 Script executed:

# Check the beforeSubmit implementation for potential errors
sed -n '68,88p' src/useField.ts

Repository: final-form/react-final-form

Length of output: 649


🏁 Script executed:

# Search for any documentation or comments about error scenarios
rg "registerField.*error|throw|exception" . --type js --type ts -g '!node_modules'

Repository: final-form/react-final-form

Length of output: 938


🏁 Script executed:

# Check if there are any issues or PRs about error handling in this code path
git log --all --oneline -- src/useField.ts 2>/dev/null | head -20

Repository: final-form/react-final-form

Length of output: 171


🏁 Script executed:

# Check for any test files that test error scenarios with validators
find src -name "*.test.*" -exec grep -l "validator.*throw\|beforeSubmit.*throw" {} \;

Repository: final-form/react-final-form

Length of output: 53


🏁 Script executed:

# Verify the current state - does the code have try/finally?
grep -A 20 "pauseValidation" src/useField.ts | grep -E "try|finally"

Repository: final-form/react-final-form

Length of output: 53


Guarantee cleanup of validation pause and destroyOnUnregister.
If the field registration throws (e.g., from a validator or beforeSubmit callback), resumeValidation() and the original destroyOnUnregister value won't be restored, leaving the form in a broken state. Wrap the critical section in try/finally.

🛡️ Suggested fix
     // Temporarily disable destroyOnUnregister
     const destroyOnUnregister = form.destroyOnUnregister;
     form.destroyOnUnregister = false;

     // Pause validation to prevent notifications during synchronous registration
-    form.pauseValidation();
-
-    // Register field synchronously with silent=true, capture state, then unregister
-    register((fieldState) => {
-      initialFieldState = fieldState;
-    }, true)();
-
-    // Resume validation
-    form.resumeValidation();
-
-    // Restore destroyOnUnregister to its original value
-    form.destroyOnUnregister = destroyOnUnregister;
+    form.pauseValidation();
+    try {
+      // Register field synchronously with silent=true, capture state, then unregister
+      register((fieldState) => {
+        initialFieldState = fieldState;
+      }, true)();
+    } finally {
+      // Resume validation and restore destroyOnUnregister
+      form.resumeValidation();
+      form.destroyOnUnregister = destroyOnUnregister;
+    }
🤖 Prompt for AI Agents
In `@src/useField.ts` around lines 105 - 121, The code around temporarily setting
destroyOnUnregister, calling form.pauseValidation(), synchronously
register(...)(), then form.resumeValidation() should be protected with a
try/finally: move the register((fieldState) => { initialFieldState = fieldState;
}, true)() call inside a try block and ensure both form.resumeValidation() and
restoring form.destroyOnUnregister = destroyOnUnregister happen in the finally
block so they always run even if register throws; reference the existing symbols
destroyOnUnregister, form.pauseValidation, register, form.resumeValidation to
locate and update the critical section.


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,
};
return initialFieldState;
});

Comment thread
coderabbitai[bot] marked this conversation as resolved.
React.useEffect(() => {
Expand Down
Loading