Skip to content

Commit 8afbfc3

Browse files
authored
fix: react compiler should now work in all edgecases (#1893)
* fix: edgecases with React compiler now work better * chore: fix type annotations * chore: WIP address Dom's feedback * chore: fix broken test * chore: fix issues with Form * chore: add changeset
1 parent 73eeabb commit 8afbfc3

File tree

5 files changed

+149
-46
lines changed

5 files changed

+149
-46
lines changed

.changeset/social-candies-count.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@tanstack/react-form': patch
3+
'@tanstack/form-core': patch
4+
---
5+
6+
Fixed issues with React Compiler

packages/form-core/src/FormApi.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -979,7 +979,7 @@ export class FormApi<
979979
/**
980980
* @private
981981
*/
982-
private _formId: string
982+
_formId: string
983983
/**
984984
* @private
985985
*/

packages/react-form/src/useField.tsx

Lines changed: 110 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
'use client'
22

3-
import { useMemo, useRef } from 'react'
3+
import { useMemo, useRef, useState } from 'react'
44
import { useStore } from '@tanstack/react-store'
55
import { FieldApi, functionalUpdate } from '@tanstack/form-core'
66
import { useIsomorphicLayoutEffect } from './useIsomorphicLayoutEffect'
77
import type {
8+
AnyFieldApi,
9+
AnyFieldMeta,
810
DeepKeys,
911
DeepValue,
1012
FieldAsyncValidateOrFn,
@@ -195,17 +197,103 @@ export function useField<
195197
) {
196198
// Keep a snapshot of options so that React Compiler doesn't
197199
// wrongly optimize fieldApi.
198-
const optsRef = useRef(opts)
199-
optsRef.current = opts
200+
const [prevOptions, setPrevOptions] = useState(() => ({
201+
form: opts.form,
202+
name: opts.name,
203+
}))
200204

201-
const fieldApi = useMemo(() => {
202-
const api = new FieldApi({
203-
...optsRef.current,
204-
form: opts.form,
205-
name: opts.name,
205+
const [fieldApi, setFieldApi] = useState(() => {
206+
return new FieldApi({
207+
...opts,
206208
})
209+
})
210+
211+
// We only want to
212+
// update on name changes since those are at risk of becoming stale. The field
213+
// state must be up to date for the internal JSX render.
214+
// The other options can freely be in `fieldApi.update`
215+
if (prevOptions.form !== opts.form || prevOptions.name !== opts.name) {
216+
setFieldApi(
217+
new FieldApi({
218+
...opts,
219+
}),
220+
)
221+
setPrevOptions({ form: opts.form, name: opts.name })
222+
}
223+
224+
const reactiveStateValue = useStore(fieldApi.store, (state) => state.value)
225+
const reactiveMetaIsTouched = useStore(
226+
fieldApi.store,
227+
(state) => state.meta.isTouched,
228+
)
229+
const reactiveMetaIsBlurred = useStore(
230+
fieldApi.store,
231+
(state) => state.meta.isBlurred,
232+
)
233+
const reactiveMetaIsDirty = useStore(
234+
fieldApi.store,
235+
(state) => state.meta.isDirty,
236+
)
237+
const reactiveMetaErrorMap = useStore(
238+
fieldApi.store,
239+
(state) => state.meta.errorMap,
240+
)
241+
const reactiveMetaErrorSourceMap = useStore(
242+
fieldApi.store,
243+
(state) => state.meta.errorSourceMap,
244+
)
245+
const reactiveMetaIsValidating = useStore(
246+
fieldApi.store,
247+
(state) => state.meta.isValidating,
248+
)
249+
250+
// This makes me sad, but if I understand correctly, this is what we have to do for reactivity to work properly with React compiler.
251+
const extendedFieldApi = useMemo(() => {
252+
const reactiveFieldApi = {
253+
...fieldApi,
254+
get state() {
255+
return {
256+
value: reactiveStateValue,
257+
get meta() {
258+
return {
259+
...fieldApi.state.meta,
260+
isTouched: reactiveMetaIsTouched,
261+
isBlurred: reactiveMetaIsBlurred,
262+
isDirty: reactiveMetaIsDirty,
263+
errorMap: reactiveMetaErrorMap,
264+
errorSourceMap: reactiveMetaErrorSourceMap,
265+
isValidating: reactiveMetaIsValidating,
266+
} satisfies AnyFieldMeta
267+
},
268+
} satisfies AnyFieldApi['state']
269+
},
270+
}
207271

208-
const extendedApi: typeof api &
272+
const extendedApi: FieldApi<
273+
TParentData,
274+
TName,
275+
TData,
276+
TOnMount,
277+
TOnChange,
278+
TOnChangeAsync,
279+
TOnBlur,
280+
TOnBlurAsync,
281+
TOnSubmit,
282+
TOnSubmitAsync,
283+
TOnDynamic,
284+
TOnDynamicAsync,
285+
TFormOnMount,
286+
TFormOnChange,
287+
TFormOnChangeAsync,
288+
TFormOnBlur,
289+
TFormOnBlurAsync,
290+
TFormOnSubmit,
291+
TFormOnSubmitAsync,
292+
TFormOnDynamic,
293+
TFormOnDynamicAsync,
294+
TFormOnServer,
295+
TPatentSubmitMeta
296+
> &
209297
ReactFieldApi<
210298
TParentData,
211299
TFormOnMount,
@@ -219,16 +307,21 @@ export function useField<
219307
TFormOnDynamicAsync,
220308
TFormOnServer,
221309
TPatentSubmitMeta
222-
> = api as never
310+
> = reactiveFieldApi as never
223311

224312
extendedApi.Field = Field as never
225313

226314
return extendedApi
227-
// We only want to
228-
// update on name changes since those are at risk of becoming stale. The field
229-
// state must be up to date for the internal JSX render.
230-
// The other options can freely be in `fieldApi.update`
231-
}, [opts.form, opts.name])
315+
}, [
316+
fieldApi,
317+
reactiveStateValue,
318+
reactiveMetaIsTouched,
319+
reactiveMetaIsBlurred,
320+
reactiveMetaIsDirty,
321+
reactiveMetaErrorMap,
322+
reactiveMetaErrorSourceMap,
323+
reactiveMetaIsValidating,
324+
])
232325

233326
useIsomorphicLayoutEffect(fieldApi.mount, [fieldApi])
234327

@@ -252,7 +345,7 @@ export function useField<
252345
: undefined,
253346
)
254347

255-
return fieldApi
348+
return extendedFieldApi
256349
}
257350

258351
/**
@@ -655,13 +748,7 @@ export const Field = (<
655748

656749
const jsxToDisplay = useMemo(
657750
() => functionalUpdate(children, fieldApi as any),
658-
/**
659-
* The reason this exists is to fix an issue with the React Compiler.
660-
* Namely, functionalUpdate is memoized where it checks for `fieldApi`, which is a static type.
661-
* This means that when `state.value` changes, it does not trigger a re-render. The useMemo explicitly fixes this problem
662-
*/
663-
// eslint-disable-next-line react-hooks/exhaustive-deps
664-
[children, fieldApi, fieldApi.state.value, fieldApi.state.meta],
751+
[children, fieldApi],
665752
)
666753
return (<>{jsxToDisplay}</>) as never
667754
}) satisfies FunctionComponent<

packages/react-form/src/useForm.tsx

Lines changed: 29 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import { FormApi, functionalUpdate, uuid } from '@tanstack/form-core'
44
import { useStore } from '@tanstack/react-store'
5-
import { useRef, useState } from 'react'
5+
import { useMemo, useState } from 'react'
66
import { Field } from './useField'
77
import { useIsomorphicLayoutEffect } from './useIsomorphicLayoutEffect'
88
import type {
@@ -13,12 +13,7 @@ import type {
1313
FormState,
1414
FormValidateOrFn,
1515
} from '@tanstack/form-core'
16-
import type {
17-
FunctionComponent,
18-
PropsWithChildren,
19-
ReactElement,
20-
ReactNode,
21-
} from 'react'
16+
import type { FunctionComponent, PropsWithChildren, ReactNode } from 'react'
2217
import type { FieldComponent } from './useField'
2318
import type { NoInfer } from '@tanstack/react-store'
2419

@@ -189,14 +184,11 @@ export function useForm<
189184
TSubmitMeta
190185
>,
191186
) {
192-
const formId = useRef<string>(opts?.formId as never)
193-
194-
if (!formId.current) {
195-
formId.current = uuid()
196-
}
187+
const fallbackFormId = useState(() => uuid())[0]
188+
const [prevFormId, setPrevFormId] = useState<string>(opts?.formId as never)
197189

198-
const [formApi] = useState(() => {
199-
const api = new FormApi<
190+
const [formApi, setFormApi] = useState(() => {
191+
return new FormApi<
200192
TFormData,
201193
TOnMount,
202194
TOnChange,
@@ -209,8 +201,16 @@ export function useForm<
209201
TOnDynamicAsync,
210202
TOnServer,
211203
TSubmitMeta
212-
>({ ...opts, formId: formId.current })
204+
>({ ...opts, formId: opts?.formId ?? fallbackFormId })
205+
})
213206

207+
if (prevFormId !== opts?.formId) {
208+
const formId = opts?.formId ?? fallbackFormId
209+
setFormApi(new FormApi({ ...opts, formId }))
210+
setPrevFormId(formId)
211+
}
212+
213+
const extendedFormApi = useMemo(() => {
214214
const extendedApi: ReactFormExtendedApi<
215215
TFormData,
216216
TOnMount,
@@ -224,24 +224,33 @@ export function useForm<
224224
TOnDynamicAsync,
225225
TOnServer,
226226
TSubmitMeta
227-
> = api as never
227+
> = {
228+
...formApi,
229+
// We must add all `get`ters from `core`'s `FormApi` here, as otherwise the spread operator won't catch those
230+
get formId(): string {
231+
return formApi._formId
232+
},
233+
get state() {
234+
return formApi.store.state
235+
},
236+
} as never
228237

229238
extendedApi.Field = function APIField(props) {
230-
return <Field {...props} form={api} />
239+
return <Field {...props} form={formApi} />
231240
}
232241

233242
extendedApi.Subscribe = function Subscribe(props: any) {
234243
return (
235244
<LocalSubscribe
236-
form={api}
245+
form={formApi}
237246
selector={props.selector}
238247
children={props.children}
239248
/>
240249
)
241250
}
242251

243252
return extendedApi
244-
})
253+
}, [formApi])
245254

246255
useIsomorphicLayoutEffect(formApi.mount, [])
247256

@@ -253,5 +262,5 @@ export function useForm<
253262
formApi.update(opts)
254263
})
255264

256-
return formApi
265+
return extendedFormApi
257266
}

packages/react-form/tests/useForm.test.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -909,8 +909,9 @@ describe('useForm', () => {
909909
<>
910910
<form.Field name="foo" mode="array">
911911
{(arrayField) =>
912-
arrayField.state.value.map((row, i) => (
913-
<form.Field key={row.id} name={`foo[${i}].name`}>
912+
arrayField.state.value.map((_, i) => (
913+
// eslint-disable-next-line @eslint-react/no-array-index-key
914+
<form.Field key={i} name={`foo[${i}].name`}>
914915
{(field) => {
915916
expect(field.name).toBe(`foo[${i}].name`)
916917
expect(field.state.value).not.toBeUndefined()

0 commit comments

Comments
 (0)