Skip to content

Commit cb2ad34

Browse files
fix(solid-form): fix props reactivity in withForm and withFieldGroupInitialized Repository (#2058)
* fix(solid-form): fix props reactivity in withForm and withFieldGroup - Replace object spread with mergeProps() to preserve reactive getters - Call render via createComponent() to maintain SolidJS reactive context - Add regression tests for withForm and withFieldGroup reactivity * fix(solid-form): fix props reactivity in withForm and withFieldGroup - Replace object spread with mergeProps() to preserve reactive getters - Call render via createComponent() to maintain SolidJS reactive context - Add regression tests for withForm and withFieldGroup reactivity Fixes #2054 * ci: apply automated fixes and generate docs --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent ba32b7f commit cb2ad34

File tree

3 files changed

+142
-4
lines changed

3 files changed

+142
-4
lines changed
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@tanstack/solid-form': patch
3+
---
4+
5+
Fix props passed to `withForm` and `withFieldGroup` not being reactive.
6+
7+
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.

packages/solid-form/src/createFormHook.tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {
22
createComponent,
33
createContext,
4+
mergeProps,
45
splitProps,
56
useContext,
67
} from 'solid-js'
@@ -468,7 +469,11 @@ export function createFormHook<
468469
UnwrapOrAny<TFormComponents>,
469470
UnwrapOrAny<TRenderProps>
470471
>['render'] {
471-
return (innerProps) => render({ ...props, ...innerProps })
472+
return (innerProps) =>
473+
createComponent(
474+
render as Component<any>,
475+
mergeProps(props ?? {}, innerProps),
476+
)
472477
}
473478

474479
function withFieldGroup<
@@ -553,8 +558,10 @@ export function createFormHook<
553558
formComponents: opts.formComponents,
554559
}
555560
const fieldGroupApi = createFieldGroup(() => fieldGroupProps)
556-
557-
return render({ ...props, ...innerProps, group: fieldGroupApi as any })
561+
return createComponent(
562+
render as Component<any>,
563+
mergeProps(props ?? {}, innerProps, { group: fieldGroupApi as any }),
564+
)
558565
}
559566
}
560567

packages/solid-form/tests/createFormHook.test.tsx

Lines changed: 125 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
import { describe, expect, it } from 'vitest'
1+
import { describe, expect, it, vi } from 'vitest'
22
import { render } from '@solidjs/testing-library'
33
import { formOptions } from '@tanstack/form-core'
44
import userEvent from '@testing-library/user-event'
5+
import { createEffect, createSignal } from 'solid-js'
56
import { createFormHook, createFormHookContexts, useStore } from '../src'
67

78
const user = userEvent.setup()
@@ -535,6 +536,129 @@ describe('createFormHook', () => {
535536
render(() => <Parent />)
536537
})
537538

539+
it('should keep props reactive in JSX when passed to withForm component', async () => {
540+
const formOpts = formOptions({ defaultValues: { name: '' } })
541+
542+
const StatusForm = withForm({
543+
...formOpts,
544+
props: {
545+
status: 'idle' as 'idle' | 'loading',
546+
count: 0,
547+
},
548+
render: (props) => (
549+
<div>
550+
<span data-testid="status">{props.status}</span>
551+
<span data-testid="count">{props.count}</span>
552+
</div>
553+
),
554+
})
555+
556+
const Parent = () => {
557+
const form = useAppForm(() => formOpts)
558+
const [status, setStatus] = createSignal<'idle' | 'loading'>('idle')
559+
const [count, setCount] = createSignal(0)
560+
return (
561+
<div>
562+
<StatusForm form={form} status={status()} count={count()} />
563+
<button data-testid="btn-status" onClick={() => setStatus('loading')}>
564+
change
565+
</button>
566+
<button
567+
data-testid="btn-count"
568+
onClick={() => setCount((c) => c + 1)}
569+
>
570+
inc
571+
</button>
572+
</div>
573+
)
574+
}
575+
576+
const { getByTestId } = render(() => <Parent />)
577+
578+
expect(getByTestId('status')).toHaveTextContent('idle')
579+
expect(getByTestId('count')).toHaveTextContent('0')
580+
581+
await user.click(getByTestId('btn-status'))
582+
expect(getByTestId('status')).toHaveTextContent('loading')
583+
584+
await user.click(getByTestId('btn-count'))
585+
expect(getByTestId('count')).toHaveTextContent('1')
586+
})
587+
588+
it('should re-run createEffect when reactive props change in withForm render', async () => {
589+
const formOpts = formOptions({ defaultValues: { name: '' } })
590+
const spy = vi.fn()
591+
592+
const StatusForm = withForm({
593+
...formOpts,
594+
props: { status: 'idle' as 'idle' | 'loading' },
595+
render: (props) => {
596+
createEffect(() => {
597+
spy(props.status)
598+
})
599+
return <div data-testid="status">{props.status}</div>
600+
},
601+
})
602+
603+
const Parent = () => {
604+
const form = useAppForm(() => formOpts)
605+
const [status, setStatus] = createSignal<'idle' | 'loading'>('idle')
606+
return (
607+
<div>
608+
<StatusForm form={form} status={status()} />
609+
<button data-testid="btn" onClick={() => setStatus('loading')}>
610+
change
611+
</button>
612+
</div>
613+
)
614+
}
615+
616+
const { getByTestId } = render(() => <Parent />)
617+
618+
expect(spy).toHaveBeenCalledTimes(1)
619+
expect(spy).toHaveBeenLastCalledWith('idle')
620+
621+
await user.click(getByTestId('btn'))
622+
expect(spy).toHaveBeenCalledTimes(2)
623+
expect(spy).toHaveBeenLastCalledWith('loading')
624+
})
625+
626+
it('should keep props reactive in withFieldGroup component', async () => {
627+
const formOpts = formOptions({
628+
defaultValues: { person: { firstName: 'John' } },
629+
})
630+
631+
const PersonGroup = withFieldGroup({
632+
defaultValues: formOpts.defaultValues.person,
633+
props: { label: 'default' },
634+
render: (props) => (
635+
<div>
636+
<span data-testid="label">{props.label}</span>
637+
</div>
638+
),
639+
})
640+
641+
const Parent = () => {
642+
const form = useAppForm(() => formOpts)
643+
const [label, setLabel] = createSignal('initial')
644+
return (
645+
<div>
646+
<PersonGroup form={form} fields="person" label={label()} />
647+
<button data-testid="btn" onClick={() => setLabel('updated')}>
648+
change
649+
</button>
650+
</div>
651+
)
652+
}
653+
654+
const { getByTestId } = render(() => <Parent />)
655+
656+
expect(getByTestId('label')).toHaveTextContent('initial')
657+
658+
await user.click(getByTestId('btn'))
659+
expect(getByTestId('label')).toHaveTextContent('updated')
660+
})
661+
538662
it('should accept formId and return it', async () => {
539663
function Submit() {
540664
const form = useFormContext()

0 commit comments

Comments
 (0)