diff --git a/apps/docs/src/lib/storybook/test-utils.ts b/apps/docs/src/lib/storybook/test-utils.ts index 1ad942e..5e311f5 100644 --- a/apps/docs/src/lib/storybook/test-utils.ts +++ b/apps/docs/src/lib/storybook/test-utils.ts @@ -46,4 +46,8 @@ export async function selectRadixOption( const listbox = screen.queryByRole('listbox'); if (listbox) throw new Error('Listbox still visible'); }); + + // 6. Blur the trigger to ensure any onBlur events are fired (simulating "clicking off") + // This helps when interactions depend on the field losing focus (like updating dirty/touched states) + await userEvent.click(document.body); } diff --git a/apps/docs/src/remix-hook-form/use-on-form-value-change.stories.tsx b/apps/docs/src/remix-hook-form/use-on-form-value-change.stories.tsx index 492d233..85c3dc0 100644 --- a/apps/docs/src/remix-hook-form/use-on-form-value-change.stories.tsx +++ b/apps/docs/src/remix-hook-form/use-on-form-value-change.stories.tsx @@ -8,6 +8,7 @@ import { expect, userEvent, within, waitFor } from '@storybook/test'; import { useState, useMemo, useCallback } from 'react'; import { useFetcher } from 'react-router'; import { useRemixForm, RemixFormProvider, getValidatedFormData } from 'remix-hook-form'; +import { useFormContext } from 'react-hook-form'; import { z } from 'zod'; import type { ActionFunctionArgs } from 'react-router'; import { selectRadixOption } from '../lib/storybook/test-utils'; @@ -219,59 +220,99 @@ const orderSchema = z.object({ type OrderFormData = z.infer; -const AutoCalculationExample = () => { - const fetcher = useFetcher<{ message: string }>(); - - const rawMethods = useRemixForm({ - resolver: zodResolver(orderSchema), - defaultValues: { - quantity: '1', - pricePerUnit: '100', - discount: '0', - total: '100.00', - }, - fetcher, - submitConfig: { - action: '/', - method: 'post', - }, - }); - - // Memoize methods to prevent unnecessary re-renders of the story tree - // which can disrupt interaction tests using Portals - const methods = useMemo(() => rawMethods, [rawMethods]); +// biome-ignore lint/suspicious/noExplicitAny: simple story example +const AutoCalculationForm = ({ fetcher }: { fetcher: any }) => { + const { setValue, getValues, handleSubmit } = useFormContext(); const calculateTotal = useCallback(() => { - const quantity = Number.parseFloat(methods.getValues('quantity') || '0'); - const pricePerUnit = Number.parseFloat(methods.getValues('pricePerUnit') || '0'); - const discount = Number.parseFloat(methods.getValues('discount') || '0'); + const quantity = Number.parseFloat(getValues('quantity') || '0'); + const pricePerUnit = Number.parseFloat(getValues('pricePerUnit') || '0'); + const discount = Number.parseFloat(getValues('discount') || '0'); const subtotal = quantity * pricePerUnit; const total = subtotal - subtotal * (discount / 100); - methods.setValue('total', total.toFixed(2)); - }, [methods]); + setValue('total', total.toFixed(2)); + }, [setValue, getValues]); // Recalculate when quantity changes useOnFormValueChange({ name: 'quantity', - methods, onChange: calculateTotal, }); // Recalculate when price changes useOnFormValueChange({ name: 'pricePerUnit', - methods, onChange: calculateTotal, }); // Recalculate when discount changes useOnFormValueChange({ name: 'discount', - methods, onChange: calculateTotal, }); + return ( +
+
+ + + + + + + + + + {fetcher.data?.message &&

{fetcher.data.message}

} +
+
+ ); +}; + +const AutoCalculationExample = () => { + const fetcher = useFetcher<{ message: string }>(); + + const methods = useRemixForm({ + resolver: zodResolver(orderSchema), + defaultValues: { + quantity: '1', + pricePerUnit: '100', + discount: '0', + total: '100.00', + }, + fetcher, + submitConfig: { + action: '/', + method: 'post', + }, + }); + // Don't render if methods is not ready if (!methods || !methods.handleSubmit) { return
Loading...
; @@ -279,45 +320,7 @@ const AutoCalculationExample = () => { return ( -
-
- - - - - - - - - - {fetcher.data?.message &&

{fetcher.data.message}

} -
-
+
); }; @@ -347,8 +350,7 @@ export const AutoCalculation: Story = { await userEvent.type(quantityInput, '2'); // Total should update to 200.00 - await new Promise((resolve) => setTimeout(resolve, 100)); - expect(totalInput).toHaveValue('200.00'); + await waitFor(() => expect(totalInput).toHaveValue('200.00')); // Add discount const discountInput = canvas.getByLabelText(/discount/i); @@ -356,8 +358,7 @@ export const AutoCalculation: Story = { await userEvent.type(discountInput, '10'); // Total should update to 180.00 (200 - 10%) - await new Promise((resolve) => setTimeout(resolve, 100)); - expect(totalInput).toHaveValue('180.00'); + await waitFor(() => expect(totalInput).toHaveValue('180.00')); // Submit form const submitButton = canvas.getByRole('button', { name: /submit order/i }); diff --git a/packages/components/src/remix-hook-form/hooks/use-on-form-value-change.ts b/packages/components/src/remix-hook-form/hooks/use-on-form-value-change.ts index e6334d2..4b2707a 100644 --- a/packages/components/src/remix-hook-form/hooks/use-on-form-value-change.ts +++ b/packages/components/src/remix-hook-form/hooks/use-on-form-value-change.ts @@ -1,4 +1,4 @@ -import { useEffect } from 'react'; +import { useEffect, useRef } from 'react'; import { useFormContext, type FieldPath, @@ -84,26 +84,35 @@ export const useOnFormValueChange = < const contextMethods = useFormContext(); const formMethods = (providedMethods || contextMethods) as WatchableFormMethods | null; + // Store previous value in a ref + const prevValueRef = useRef | undefined>( + formMethods?.getValues ? formMethods.getValues(name) : undefined, + ); + + const watch = formMethods?.watch; + const getValues = formMethods?.getValues; + useEffect(() => { // Early return if no form methods are available or hook is disabled - if (!enabled || !formMethods || !formMethods.watch || !formMethods.getValues) return; - - const { watch, getValues } = formMethods; + if (!enabled || !watch || !getValues) return; // Subscribe to the field value changes const subscription = watch(((value, { name: changedFieldName }) => { // Only trigger onChange if the watched field changed if (changedFieldName === name) { const currentValue = value[name] as PathValue; - // Get previous value from the form state - const prevValue = getValues(name); + // Get previous value from the ref + const prevValue = prevValueRef.current as PathValue; onChange(currentValue, prevValue); + + // Update ref with new value + prevValueRef.current = currentValue; } }) as WatchObserver); // Cleanup subscription on unmount return () => subscription.unsubscribe(); - }, [name, onChange, enabled, formMethods]); + }, [name, onChange, enabled, watch, getValues]); };