From 1d7d9889d3ac9a67e43ddfe4dd0937f42fb09c73 Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Tue, 8 Jul 2025 18:05:51 +0200 Subject: [PATCH 1/2] feat(react-form): implement branded field components --- packages/react-form/src/createFormHook.tsx | 59 ++++++++++++++++++---- packages/react-form/src/types.ts | 48 ++++++++++++++++++ packages/react-form/src/useField.tsx | 49 ++++++++++-------- 3 files changed, 124 insertions(+), 32 deletions(-) diff --git a/packages/react-form/src/createFormHook.tsx b/packages/react-form/src/createFormHook.tsx index 64dcc38e7..19c7bed5f 100644 --- a/packages/react-form/src/createFormHook.tsx +++ b/packages/react-form/src/createFormHook.tsx @@ -4,7 +4,6 @@ import type { AnyFieldApi, AnyFormApi, FieldApi, - FormApi, FormAsyncValidateOrFn, FormOptions, FormValidateOrFn, @@ -12,6 +11,7 @@ import type { import type { ComponentType, Context, JSX, PropsWithChildren } from 'react' import type { FieldComponent } from './useField' import type { ReactFormExtendedApi } from './useForm' +import type { AppFieldComponents, AppFormComponents, DataTag } from './types' /** * TypeScript inferencing is weird. @@ -50,6 +50,39 @@ type UnwrapDefaultOrAny = [DefaultT] extends [T] : T : T +/** + * Create a field component based on a provided React component. + * If `TFieldValue` is provided, it will restrict its use to AppFields + * that extend that value. + * + * @example + * ```tsx + * interface TextFieldProps { + * label: string; + * } + * function TextField(props: TextFieldProps) { + * const field = useFieldContext(); + * // ... + * return <> + * } + * // create a TextField component that may only be used in string AppFields + * const TextFieldComponent = createFieldComponent(TextField); + * + * // in your form hook + * createFormHook({ + * // ... + * fieldComponents: { + * TextField: TextFieldComponent + * } + * }) + * ``` + */ +function createFieldComponent( + component: ComponentType, +): DataTag, TFieldValue> { + return component as never +} + export function createFormHookContexts() { // We should never hit the `null` case here const fieldContext = createContext(null as never) @@ -115,12 +148,18 @@ export function createFormHookContexts() { > } - return { fieldContext, useFieldContext, useFormContext, formContext } + return { + fieldContext, + useFieldContext, + useFormContext, + formContext, + createFieldComponent, + } } interface CreateFormHookProps< - TFieldComponents extends Record>, - TFormComponents extends Record>, + TFieldComponents extends AppFieldComponents, + TFormComponents extends AppFormComponents, > { fieldComponents: TFieldComponents fieldContext: Context @@ -139,8 +178,8 @@ type AppFieldExtendedReactFormApi< TOnSubmitAsync extends undefined | FormAsyncValidateOrFn, TOnServer extends undefined | FormAsyncValidateOrFn, TSubmitMeta, - TFieldComponents extends Record>, - TFormComponents extends Record>, + TFieldComponents extends AppFieldComponents, + TFormComponents extends AppFormComponents, > = ReactFormExtendedApi< TFormData, TOnMount, @@ -181,8 +220,8 @@ export interface WithFormProps< TOnSubmitAsync extends undefined | FormAsyncValidateOrFn, TOnServer extends undefined | FormAsyncValidateOrFn, TSubmitMeta, - TFieldComponents extends Record>, - TFormComponents extends Record>, + TFieldComponents extends AppFieldComponents, + TFormComponents extends AppFormComponents, TRenderProps extends object = Record, > extends FormOptions< TFormData, @@ -221,8 +260,8 @@ export interface WithFormProps< } export function createFormHook< - const TComponents extends Record>, - const TFormComponents extends Record>, + const TComponents extends AppFieldComponents, + const TFormComponents extends AppFormComponents, >({ fieldComponents, fieldContext, diff --git a/packages/react-form/src/types.ts b/packages/react-form/src/types.ts index 1bb935946..7876a438f 100644 --- a/packages/react-form/src/types.ts +++ b/packages/react-form/src/types.ts @@ -8,6 +8,54 @@ import type { FormAsyncValidateOrFn, FormValidateOrFn, } from '@tanstack/form-core' +import type { ComponentType } from 'react' + +declare const dataTagFieldValueSymbol: unique symbol + +/** + * @private + */ +export type AnyDataTag = { + [dataTagFieldValueSymbol]: any +} + +/** + * @private + */ +export type DataTag = TType extends AnyDataTag + ? TType + : TType & { + [dataTagFieldValueSymbol]: TFieldValue + } +/** + * @private + */ +export type AppFieldComponents = Record< + string, + ComponentType | DataTag, any> +> + +/** + * @private + */ +export type AppFieldComponentsOfType< + TFieldValue, + TRecord extends AppFieldComponents, +> = { + [K in keyof TRecord as TRecord[K] extends DataTag< + unknown, + infer TaggedFieldValue + > + ? TaggedFieldValue extends TFieldValue // does the brand match? + ? K + : never // brand doesn't match + : K]: TRecord[K] +} + +/** + * @private + */ +export type AppFormComponents = Record> interface FieldOptionsMode { mode?: 'value' | 'array' diff --git a/packages/react-form/src/useField.tsx b/packages/react-form/src/useField.tsx index 38e771a1e..b6b45f6ac 100644 --- a/packages/react-form/src/useField.tsx +++ b/packages/react-form/src/useField.tsx @@ -11,7 +11,12 @@ import type { FormValidateOrFn, } from '@tanstack/form-core' import type { FunctionComponent, ReactNode } from 'react' -import type { UseFieldOptions, UseFieldOptionsBound } from './types' +import type { + AppFieldComponents, + AppFieldComponentsOfType, + UseFieldOptions, + UseFieldOptionsBound, +} from './types' interface ReactFieldApi< TParentData, @@ -23,7 +28,7 @@ interface ReactFieldApi< TFormOnSubmit extends undefined | FormValidateOrFn, TFormOnSubmitAsync extends undefined | FormAsyncValidateOrFn, TFormOnServer extends undefined | FormAsyncValidateOrFn, - TPatentSubmitMeta, + TParentSubmitMeta, > { /** * A pre-bound and type-safe sub-field component using this field as a root. @@ -38,7 +43,7 @@ interface ReactFieldApi< TFormOnSubmit, TFormOnSubmitAsync, TFormOnServer, - TPatentSubmitMeta + TParentSubmitMeta > } @@ -57,7 +62,7 @@ export type UseField< TFormOnSubmit extends undefined | FormValidateOrFn, TFormOnSubmitAsync extends undefined | FormAsyncValidateOrFn, TFormOnServer extends undefined | FormAsyncValidateOrFn, - TPatentSubmitMeta, + TParentSubmitMeta, > = < TName extends DeepKeys, TData extends DeepValue, @@ -106,7 +111,7 @@ export type UseField< TFormOnSubmit, TFormOnSubmitAsync, TFormOnServer, - TPatentSubmitMeta + TParentSubmitMeta > /** @@ -140,7 +145,7 @@ export function useField< TFormOnSubmit extends undefined | FormValidateOrFn, TFormOnSubmitAsync extends undefined | FormAsyncValidateOrFn, TFormOnServer extends undefined | FormAsyncValidateOrFn, - TPatentSubmitMeta, + TParentSubmitMeta, >( opts: UseFieldOptions< TParentData, @@ -161,7 +166,7 @@ export function useField< TFormOnSubmit, TFormOnSubmitAsync, TFormOnServer, - TPatentSubmitMeta + TParentSubmitMeta >, ) { const [fieldApi] = useState(() => { @@ -182,7 +187,7 @@ export function useField< TFormOnSubmit, TFormOnSubmitAsync, TFormOnServer, - TPatentSubmitMeta + TParentSubmitMeta > = api as never extendedApi.Field = Field as never @@ -243,8 +248,8 @@ interface FieldComponentProps< TFormOnSubmit extends undefined | FormValidateOrFn, TFormOnSubmitAsync extends undefined | FormAsyncValidateOrFn, TFormOnServer extends undefined | FormAsyncValidateOrFn, - TPatentSubmitMeta, - ExtendedApi = {}, + TParentSubmitMeta, + ExtendedApi extends AppFieldComponents = {}, > extends UseFieldOptions< TParentData, TName, @@ -264,7 +269,7 @@ interface FieldComponentProps< TFormOnSubmit, TFormOnSubmitAsync, TFormOnServer, - TPatentSubmitMeta + TParentSubmitMeta > { children: ( fieldApi: FieldApi< @@ -286,9 +291,9 @@ interface FieldComponentProps< TFormOnSubmit, TFormOnSubmitAsync, TFormOnServer, - TPatentSubmitMeta + TParentSubmitMeta > & - ExtendedApi, + AppFieldComponentsOfType, ) => ReactNode } @@ -317,8 +322,8 @@ interface FieldComponentBoundProps< TFormOnSubmit extends undefined | FormValidateOrFn, TFormOnSubmitAsync extends undefined | FormAsyncValidateOrFn, TFormOnServer extends undefined | FormAsyncValidateOrFn, - TPatentSubmitMeta, - ExtendedApi = {}, + TParentSubmitMeta, + ExtendedApi extends AppFieldComponents = {}, > extends UseFieldOptionsBound< TParentData, TName, @@ -351,9 +356,9 @@ interface FieldComponentBoundProps< TFormOnSubmit, TFormOnSubmitAsync, TFormOnServer, - TPatentSubmitMeta + TParentSubmitMeta > & - ExtendedApi, + AppFieldComponentsOfType, ) => ReactNode } @@ -376,8 +381,8 @@ export type FieldComponent< | undefined | FormAsyncValidateOrFn, in out TFormOnServer extends undefined | FormAsyncValidateOrFn, - in out TPatentSubmitMeta, - in out ExtendedApi = {}, + in out TParentSubmitMeta, + in out ExtendedApi extends AppFieldComponents = {}, > = < const TName extends DeepKeys, TData extends DeepValue, @@ -416,7 +421,7 @@ export type FieldComponent< TFormOnSubmit, TFormOnSubmitAsync, TFormOnServer, - TPatentSubmitMeta, + TParentSubmitMeta, ExtendedApi >) => ReactNode @@ -450,7 +455,7 @@ export const Field = (< TFormOnSubmit extends undefined | FormValidateOrFn, TFormOnSubmitAsync extends undefined | FormAsyncValidateOrFn, TFormOnServer extends undefined | FormAsyncValidateOrFn, - TPatentSubmitMeta, + TParentSubmitMeta, >({ children, ...fieldOptions @@ -473,7 +478,7 @@ export const Field = (< TFormOnSubmit, TFormOnSubmitAsync, TFormOnServer, - TPatentSubmitMeta + TParentSubmitMeta >): ReactNode => { const fieldApi = useField(fieldOptions as any) From 24f9ef32d32212fea11c8d530346d754499cc215 Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Wed, 9 Jul 2025 07:08:20 +0200 Subject: [PATCH 2/2] chore(react-form): remove unused export --- packages/react-form/src/types.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/react-form/src/types.ts b/packages/react-form/src/types.ts index 7876a438f..ba63e2bc7 100644 --- a/packages/react-form/src/types.ts +++ b/packages/react-form/src/types.ts @@ -12,10 +12,7 @@ import type { ComponentType } from 'react' declare const dataTagFieldValueSymbol: unique symbol -/** - * @private - */ -export type AnyDataTag = { +type AnyDataTag = { [dataTagFieldValueSymbol]: any }