diff --git a/.changeset/fix-withform-props-reactivity.md b/.changeset/fix-withform-props-reactivity.md new file mode 100644 index 000000000..98dcb6574 --- /dev/null +++ b/.changeset/fix-withform-props-reactivity.md @@ -0,0 +1,7 @@ +--- +'@tanstack/solid-form': patch +--- + +Fix props passed to `withForm` and `withFieldGroup` not being reactive. + +Object spread (`{ ...props, ...innerProps }`) was eagerly evaluating SolidJS reactive getters, producing a static snapshot that broke signal tracking. Replaced with `mergeProps()` to preserve getter descriptors and `createComponent()` to maintain the correct reactive context. diff --git a/packages/solid-form/src/createFormHook.tsx b/packages/solid-form/src/createFormHook.tsx index c94b365c8..a86afab24 100644 --- a/packages/solid-form/src/createFormHook.tsx +++ b/packages/solid-form/src/createFormHook.tsx @@ -1,6 +1,7 @@ import { createComponent, createContext, + mergeProps, splitProps, useContext, } from 'solid-js' @@ -468,7 +469,11 @@ export function createFormHook< UnwrapOrAny, UnwrapOrAny >['render'] { - return (innerProps) => render({ ...props, ...innerProps }) + return (innerProps) => + createComponent( + render as Component, + mergeProps(props ?? {}, innerProps), + ) } function withFieldGroup< @@ -553,8 +558,10 @@ export function createFormHook< formComponents: opts.formComponents, } const fieldGroupApi = createFieldGroup(() => fieldGroupProps) - - return render({ ...props, ...innerProps, group: fieldGroupApi as any }) + return createComponent( + render as Component, + mergeProps(props ?? {}, innerProps, { group: fieldGroupApi as any }), + ) } } diff --git a/packages/solid-form/tests/createFormHook.test.tsx b/packages/solid-form/tests/createFormHook.test.tsx index d6eea7b98..b329e71d3 100644 --- a/packages/solid-form/tests/createFormHook.test.tsx +++ b/packages/solid-form/tests/createFormHook.test.tsx @@ -1,7 +1,8 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { render } from '@solidjs/testing-library' import { formOptions } from '@tanstack/form-core' import userEvent from '@testing-library/user-event' +import { createEffect, createSignal } from 'solid-js' import { createFormHook, createFormHookContexts, useStore } from '../src' const user = userEvent.setup() @@ -535,6 +536,129 @@ describe('createFormHook', () => { render(() => ) }) + it('should keep props reactive in JSX when passed to withForm component', async () => { + const formOpts = formOptions({ defaultValues: { name: '' } }) + + const StatusForm = withForm({ + ...formOpts, + props: { + status: 'idle' as 'idle' | 'loading', + count: 0, + }, + render: (props) => ( +
+ {props.status} + {props.count} +
+ ), + }) + + const Parent = () => { + const form = useAppForm(() => formOpts) + const [status, setStatus] = createSignal<'idle' | 'loading'>('idle') + const [count, setCount] = createSignal(0) + return ( +
+ + + +
+ ) + } + + const { getByTestId } = render(() => ) + + expect(getByTestId('status')).toHaveTextContent('idle') + expect(getByTestId('count')).toHaveTextContent('0') + + await user.click(getByTestId('btn-status')) + expect(getByTestId('status')).toHaveTextContent('loading') + + await user.click(getByTestId('btn-count')) + expect(getByTestId('count')).toHaveTextContent('1') + }) + + it('should re-run createEffect when reactive props change in withForm render', async () => { + const formOpts = formOptions({ defaultValues: { name: '' } }) + const spy = vi.fn() + + const StatusForm = withForm({ + ...formOpts, + props: { status: 'idle' as 'idle' | 'loading' }, + render: (props) => { + createEffect(() => { + spy(props.status) + }) + return
{props.status}
+ }, + }) + + const Parent = () => { + const form = useAppForm(() => formOpts) + const [status, setStatus] = createSignal<'idle' | 'loading'>('idle') + return ( +
+ + +
+ ) + } + + const { getByTestId } = render(() => ) + + expect(spy).toHaveBeenCalledTimes(1) + expect(spy).toHaveBeenLastCalledWith('idle') + + await user.click(getByTestId('btn')) + expect(spy).toHaveBeenCalledTimes(2) + expect(spy).toHaveBeenLastCalledWith('loading') + }) + + it('should keep props reactive in withFieldGroup component', async () => { + const formOpts = formOptions({ + defaultValues: { person: { firstName: 'John' } }, + }) + + const PersonGroup = withFieldGroup({ + defaultValues: formOpts.defaultValues.person, + props: { label: 'default' }, + render: (props) => ( +
+ {props.label} +
+ ), + }) + + const Parent = () => { + const form = useAppForm(() => formOpts) + const [label, setLabel] = createSignal('initial') + return ( +
+ + +
+ ) + } + + const { getByTestId } = render(() => ) + + expect(getByTestId('label')).toHaveTextContent('initial') + + await user.click(getByTestId('btn')) + expect(getByTestId('label')).toHaveTextContent('updated') + }) + it('should accept formId and return it', async () => { function Submit() { const form = useFormContext()