diff --git a/packages/conform-dom/form.ts b/packages/conform-dom/form.ts index fd09f064..486c567c 100644 --- a/packages/conform-dom/form.ts +++ b/packages/conform-dom/form.ts @@ -220,6 +220,7 @@ export type FormContext< onInput(event: Event): void; onBlur(event: Event): void; onUpdate(options: Partial>): void; + observe(): () => void; subscribe( callback: () => void, getSubject?: () => SubscriptionSubject | undefined, @@ -825,6 +826,16 @@ export function createFormContext< : shouldValidate === eventName; } + function updateFormValue(form: HTMLFormElement) { + const formData = new FormData(form); + const result = getSubmissionContext(formData); + + updateFormMeta({ + ...meta, + value: result.payload, + }); + } + function onInput(event: Event) { const element = resolveTarget(event); @@ -833,13 +844,7 @@ export function createFormContext< } if (event.defaultPrevented || !willValidate(element, 'onInput')) { - const formData = new FormData(element.form); - const result = getSubmissionContext(formData); - - updateFormMeta({ - ...meta, - value: result.payload, - }); + updateFormValue(element.form); } else { dispatch({ type: 'validate', @@ -999,6 +1004,47 @@ export function createFormContext< }); } + function observe() { + const observer = new MutationObserver((mutations) => { + const form = getFormElement(); + + if (!form) { + return; + } + + for (const mutation of mutations) { + const nodes = + mutation.type === 'childList' + ? [...mutation.addedNodes, ...mutation.removedNodes] + : [mutation.target]; + + for (const node of nodes) { + const element = isFieldElement(node) + ? node + : node instanceof HTMLElement + ? node.querySelector('input,select,textarea') + : null; + + if (element?.form === form) { + updateFormValue(form); + return; + } + } + } + }); + + observer.observe(document, { + subtree: true, + childList: true, + attributes: true, + attributeFilter: ['form', 'name'], + }); + + return () => { + observer.disconnect(); + }; + } + return { get formId() { return latestOptions.formId; @@ -1017,5 +1063,6 @@ export function createFormContext< subscribe, getState, getSerializedState, + observe, }; } diff --git a/packages/conform-react/hooks.ts b/packages/conform-react/hooks.ts index c42dcf61..594dfa53 100644 --- a/packages/conform-react/hooks.ts +++ b/packages/conform-react/hooks.ts @@ -74,11 +74,13 @@ export function useForm< ); useSafeLayoutEffect(() => { + const disconnect = context.observe(); document.addEventListener('input', context.onInput); document.addEventListener('focusout', context.onBlur); document.addEventListener('reset', context.onReset); return () => { + disconnect(); document.removeEventListener('input', context.onInput); document.removeEventListener('focusout', context.onBlur); document.removeEventListener('reset', context.onReset); diff --git a/playground/app/routes/dom-value.tsx b/playground/app/routes/dom-value.tsx new file mode 100644 index 00000000..275da037 --- /dev/null +++ b/playground/app/routes/dom-value.tsx @@ -0,0 +1,80 @@ +import { + getCollectionProps, + getFormProps, + getInputProps, + useForm, +} from '@conform-to/react'; +import { parseWithZod } from '@conform-to/zod'; +import type { ActionArgs, LoaderArgs } from '@remix-run/node'; +import { json } from '@remix-run/node'; +import { Form, useActionData, useLoaderData } from '@remix-run/react'; +import { z } from 'zod'; +import { Playground, Field } from '~/components'; + +const schema = z.discriminatedUnion('type', [ + z.object({ + type: z.literal('message'), + message: z.string(), + }), + z.object({ + type: z.literal('title'), + title: z.string(), + }), +]); + +export async function loader({ request }: LoaderArgs) { + const url = new URL(request.url); + + return { + noClientValidate: url.searchParams.get('noClientValidate') === 'yes', + }; +} + +export async function action({ request }: ActionArgs) { + const formData = await request.formData(); + const submission = parseWithZod(formData, { schema }); + + return json(submission.reply()); +} + +export default function Example() { + const { noClientValidate } = useLoaderData(); + const lastResult = useActionData(); + const [form, fields] = useForm({ + lastResult, + defaultValue: { + type: 'message', + message: 'Hello', + }, + onValidate: !noClientValidate + ? ({ formData }) => parseWithZod(formData, { schema }) + : undefined, + }); + + return ( +
+ + + {getCollectionProps(fields.type, { + type: 'radio', + options: ['title', 'message'], + }).map((props) => ( + + ))} + + {fields.type.value === 'message' ? ( + + + + ) : fields.type.value === 'title' ? ( + + + + ) : null} + +
+ ); +} diff --git a/tests/integrations/dom-value.spec.ts b/tests/integrations/dom-value.spec.ts new file mode 100644 index 00000000..b4a05fae --- /dev/null +++ b/tests/integrations/dom-value.spec.ts @@ -0,0 +1,66 @@ +import { type Page, type Locator, test, expect } from '@playwright/test'; +import { getPlayground } from '../helpers'; + +function getFieldset(form: Locator) { + return { + messageType: form.getByLabel('message', { exact: true }), + titleType: form.getByLabel('title', { exact: true }), + message: form.getByLabel('Message', { exact: true }), + title: form.getByLabel('Title', { exact: true }), + }; +} + +async function runTest(page: Page) { + const playground = getPlayground(page); + const fieldset = getFieldset(playground.container); + + await expect.poll(playground.result).toEqual({ + value: { + type: 'message', + message: 'Hello', + }, + }); + + await fieldset.message.fill('Test'); + await expect.poll(playground.result).toEqual({ + value: { + type: 'message', + message: 'Test', + }, + }); + + await fieldset.titleType.click(); + await expect.poll(playground.result).toEqual({ + value: { + type: 'title', + }, + }); + + await fieldset.title.pressSequentially('foobar'); + await expect.poll(playground.result).toEqual({ + value: { + type: 'title', + title: 'foobar', + }, + }); + + await fieldset.messageType.click(); + await expect.poll(playground.result).toEqual({ + value: { + type: 'message', + message: 'Hello', + }, + }); +} + +test.describe('With JS', () => { + test('Client Validation', async ({ page }) => { + await page.goto('/dom-value'); + await runTest(page); + }); + + test('Server Validation', async ({ page }) => { + await page.goto('/dom-value?noClientValidate=yes'); + await runTest(page); + }); +});