diff --git a/.changeset/fair-intents-infer.md b/.changeset/fair-intents-infer.md new file mode 100644 index 000000000..7027d5430 --- /dev/null +++ b/.changeset/fair-intents-infer.md @@ -0,0 +1,5 @@ +--- +'@conform-to/react': minor +--- + +Preserve inferred form and field shapes when passing values to the reset, update, insert, and remove intents. diff --git a/packages/conform-react/future/types.ts b/packages/conform-react/future/types.ts index dd1a480f3..9ed792632 100644 --- a/packages/conform-react/future/types.ts +++ b/packages/conform-react/future/types.ts @@ -691,52 +691,44 @@ export type IntentHandlerPayload< export type SubmitIntent = (value?: string) => void; +type ResetIntentOptions = { + defaultValue?: DefaultValue< + FormShape extends Record ? FormShape : never + >; +}; + export interface ResetIntent extends TypedIntentDefinition { - dispatch< - FormShape extends Record = this['FormShape'] extends Record< - string, - any - > - ? this['FormShape'] - : never, - >(options?: { - defaultValue?: DefaultValue; - }): void; + dispatch(options?: ResetIntentOptions): void; } export type ValidateIntent = (name?: string) => void; -export type IsUntypedFormShape = unknown extends FormShape - ? true - : string extends keyof FormShape - ? true - : false; +type UpdateIntentOptions = + | { + name?: undefined; + index?: undefined; + value: DefaultValue>; + } + | { + name: FieldName; + index?: undefined; + value: DefaultValue>; + } + | { + name: FieldName< + FieldShape extends Array | null | undefined ? FieldShape : never + >; + index: number; + value: unknown extends FieldShape + ? unknown + : NonNullable> extends Array + ? DefaultValue + : never; + }; export interface UpdateIntent extends TypedIntentDefinition { - dispatch( - options: IsUntypedFormShape extends true - ? { - name?: string | undefined; - index?: number | undefined; - value: unknown; - } - : - | { - name?: FieldName; - index?: undefined; - value: DefaultValue; - } - | { - name: FieldName< - FieldShape extends Array | null | undefined - ? FieldShape - : never - >; - index: number; - value: NonNullable extends Array - ? DefaultValue - : never; - }, + dispatch( + options: UpdateIntentOptions, ): void; } @@ -744,7 +736,9 @@ export interface InsertIntent extends TypedIntentDefinition { dispatch | null | undefined>(options: { name: FieldName; index?: number; - defaultValue?: NonNullable extends Array + defaultValue?: NonNullable> extends Array< + infer ItemShape + > ? DefaultValue : never; from?: string; @@ -757,7 +751,9 @@ export interface RemoveIntent extends TypedIntentDefinition { name: FieldName; index: number; onInvalid?: 'revert' | 'insert'; - defaultValue?: NonNullable extends Array + defaultValue?: NonNullable> extends Array< + infer ItemShape + > ? DefaultValue : never; }): void; diff --git a/packages/conform-react/tests/types.test-d.ts b/packages/conform-react/tests/types.test-d.ts index d0865ef18..33c33d21a 100644 --- a/packages/conform-react/tests/types.test-d.ts +++ b/packages/conform-react/tests/types.test-d.ts @@ -168,6 +168,80 @@ test('DefaultValue', () => { assertType>({ intent: 'delete' }); }); +test('literal default values in intents', () => { + type FormShape = { + status: 'draft' | 'published'; + items: Array<{ role: 'admin' | 'member' }>; + }; + + const intent = {} as IntentDispatcher; + const untypedIntent = {} as IntentDispatcher>; + const statusName = 'status' as FieldName; + const itemsName = 'items' as FieldName; + + assertType(intent.reset({ defaultValue: { status: 'published' } })); + // @ts-expect-error programmatic reset should reject unsupported literals + intent.reset({ defaultValue: { status: 'archived' } }); + + assertType(intent.update({ value: { status: 'published' } })); + // @ts-expect-error whole-form updates should reject unsupported literals + intent.update({ value: { status: 'archived' } }); + + assertType(intent.update({ name: statusName, value: 'draft' })); + // @ts-expect-error branded field updates should reject unsupported literals + intent.update({ name: statusName, value: 'archived' }); + + assertType( + intent.update({ name: itemsName, index: 0, value: { role: 'member' } }), + ); + // @ts-expect-error indexed field updates should reject unsupported literals + intent.update({ name: itemsName, index: 0, value: { role: 'owner' } }); + + assertType( + untypedIntent.update({ name: statusName, value: 'published' }), + ); + // @ts-expect-error branded names should remain typed on an untyped form + untypedIntent.update({ name: statusName, value: 'archived' }); + + assertType( + untypedIntent.update({ + name: itemsName, + index: 0, + value: { role: 'admin' }, + }), + ); + // @ts-expect-error indexed branded names should remain typed on an untyped form + untypedIntent.update({ name: itemsName, index: 0, value: { role: 'owner' } }); + + assertType( + intent.insert({ name: itemsName, defaultValue: { role: 'admin' } }), + ); + // @ts-expect-error programmatic inserts should reject unsupported literals + intent.insert({ name: itemsName, defaultValue: { role: 'owner' } }); + + assertType( + intent.remove({ + name: itemsName, + index: 0, + onInvalid: 'insert', + defaultValue: { role: 'member' }, + }), + ); + intent.remove({ + name: itemsName, + index: 0, + onInvalid: 'insert', + // @ts-expect-error programmatic fallback inserts should reject unsupported literals + defaultValue: { role: 'owner' }, + }); + + // DOM-derived metadata remains intentionally broad + const metadata = {} as FieldMetadata; + + assertType(metadata.defaultValue); + assertType(metadata.defaultPayload); +}); + test('FormOptions', () => { type TestSchema = { name: string; email: string };