From 3830c596dab58b5757d6da15d40996da70854ed7 Mon Sep 17 00:00:00 2001 From: Edmund Hung Date: Sat, 3 Aug 2024 16:44:11 +0100 Subject: [PATCH 1/3] feat: update field value with useEffect --- packages/conform-dom/form.ts | 32 ++++++------ packages/conform-react/context.tsx | 70 +++++++++++++++++++++++++- packages/conform-react/helpers.ts | 3 +- packages/conform-react/integrations.ts | 24 ++------- 4 files changed, 87 insertions(+), 42 deletions(-) diff --git a/packages/conform-dom/form.ts b/packages/conform-dom/form.ts index 06320bb5..9ef1648e 100644 --- a/packages/conform-dom/form.ts +++ b/packages/conform-dom/form.ts @@ -279,12 +279,7 @@ function createFormMeta( value: initialValue, constraint: options.constraint ?? {}, validated: lastResult?.state?.validated ?? {}, - key: !initialized - ? getDefaultKey(defaultValue) - : { - '': generateId(), - ...getDefaultKey(defaultValue), - }, + key: getDefaultKey(defaultValue), // The `lastResult` should comes from the server which we won't expect the error to be null // We can consider adding a warning if it happens error: (lastResult?.error as Record) ?? {}, @@ -301,15 +296,20 @@ function getDefaultKey( ): Record { return Object.entries(flatten(defaultValue, { prefix })).reduce< Record - >((result, [key, value]) => { - if (Array.isArray(value)) { - for (let i = 0; i < value.length; i++) { - result[formatName(key, i)] = generateId(); + >( + (result, [key, value]) => { + if (Array.isArray(value)) { + for (let i = 0; i < value.length; i++) { + result[formatName(key, i)] = generateId(); + } } - } - return result; - }, {}); + return result; + }, + { + [prefix ?? '']: generateId(), + }, + ); } function setFieldsValidated( @@ -441,10 +441,8 @@ function updateValue( if (name === '') { meta.initialValue = value as Record; meta.value = value as Record; - meta.key = { - ...getDefaultKey(value as Record), - '': generateId(), - }; + meta.key = getDefaultKey(value as Record); + return; } diff --git a/packages/conform-react/context.tsx b/packages/conform-react/context.tsx index 69ece9c4..a73aac07 100644 --- a/packages/conform-react/context.tsx +++ b/packages/conform-react/context.tsx @@ -28,6 +28,7 @@ import { useContext, useSyncExternalStore, useRef, + useEffect, } from 'react'; export type Pretty = { [K in keyof T]: T[K] } & {}; @@ -152,7 +153,67 @@ export function useFormState( [form, subjectRef], ); - return useSyncExternalStore(subscribe, form.getState, form.getState); + const state = useSyncExternalStore(subscribe, form.getState, form.getState); + + useEffect(() => { + const formId = form.getFormId(); + const formElement = document.forms.namedItem(formId); + const scope = subjectRef?.current.key; + + if (!formElement || !scope) { + return; + } + + const getAll = (value: unknown) => { + if (typeof value === 'string') { + return [value]; + } + + if ( + Array.isArray(value) && + value.every((item) => typeof item === 'string') + ) { + return value; + } + + return undefined; + }; + const get = (value: unknown) => getAll(value)?.[0]; + + for (const element of formElement.elements) { + if ( + (element instanceof HTMLInputElement || + element instanceof HTMLTextAreaElement || + element instanceof HTMLSelectElement) && + scope.name?.includes(element.name) + ) { + const prev = element.dataset.conform; + const next = state.key[element.name]; + const defaultValue = state.initialValue[element.name]; + + if (typeof prev === 'undefined' || prev !== next) { + element.dataset.conform = next; + + if ('options' in element) { + const value = getAll(defaultValue) ?? []; + + for (const option of element.options) { + option.selected = value.includes(option.value); + } + } else if ( + 'checked' in element && + (element.type === 'checkbox' || element.type === 'radio') + ) { + element.checked = get(defaultValue) === element.value; + } else { + element.value = get(defaultValue) ?? ''; + } + } + } + } + }, [form, state, subjectRef]); + + return state; } export function FormProvider(props: { @@ -307,9 +368,14 @@ export function getMetadata< case 'initialValue': case 'value': case 'valid': - case 'dirty': + case 'dirty': { + if (key === 'initialValue') { + // If `initialValue` is subscribed, it's likely that we will want the field to be updated + updateSubjectRef(subjectRef, 'key', 'name', name); + } updateSubjectRef(subjectRef, key, 'name', name); break; + } case 'errors': case 'allErrors': updateSubjectRef( diff --git a/packages/conform-react/helpers.ts b/packages/conform-react/helpers.ts index bebe699a..bfc668b9 100644 --- a/packages/conform-react/helpers.ts +++ b/packages/conform-react/helpers.ts @@ -1,7 +1,7 @@ import type { FormMetadata, FieldMetadata, Metadata, Pretty } from './context'; type FormControlProps = { - key: string | undefined; + key?: string; id: string; name: string; form: string; @@ -214,7 +214,6 @@ export function getFormControlProps( options?: FormControlOptions, ): FormControlProps { return simplify({ - key: metadata.key, required: metadata.required || undefined, ...getFieldsetProps(metadata, options), }); diff --git a/packages/conform-react/integrations.ts b/packages/conform-react/integrations.ts index cb5c58b6..df326640 100644 --- a/packages/conform-react/integrations.ts +++ b/packages/conform-react/integrations.ts @@ -19,8 +19,8 @@ export function getFieldElements( const elements = !field ? [] : field instanceof Element - ? [field] - : Array.from(field.values()); + ? [field] + : Array.from(field.values()); return elements.filter( ( @@ -306,26 +306,8 @@ export function useControl< change(value); }; - const refCallback: RefCallback< - HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement | undefined - > = (element) => { - register(element); - - if (!element) { - return; - } - - const prevKey = element.dataset.conform; - const nextKey = `${meta.key ?? ''}`; - - if (prevKey !== nextKey) { - element.dataset.conform = nextKey; - updateFieldValue(element, value ?? ''); - } - }; - return { - register: refCallback, + register, value, change: handleChange, focus, From 045e40f2181cc6c4d4bd029658a42afaebfa6ec0 Mon Sep 17 00:00:00 2001 From: Edmund Hung Date: Sat, 3 Aug 2024 17:59:19 +0100 Subject: [PATCH 2/3] fix: dummy select should be updated by the side effect as well --- packages/conform-react/integrations.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/conform-react/integrations.ts b/packages/conform-react/integrations.ts index df326640..e85f6135 100644 --- a/packages/conform-react/integrations.ts +++ b/packages/conform-react/integrations.ts @@ -63,6 +63,7 @@ export function getEventTarget( export function createDummySelect( form: HTMLFormElement, + key: Key | null | undefined, name: string, value?: string | string[] | undefined, ): HTMLSelectElement { @@ -71,7 +72,7 @@ export function createDummySelect( select.name = name; select.multiple = true; - select.dataset.conform = 'true'; + select.dataset.conform = `${key ?? 'true'}`; // To make sure the input is hidden but still focusable select.setAttribute('aria-hidden', 'true'); @@ -98,7 +99,7 @@ export function createDummySelect( export function isDummySelect( element: HTMLElement, ): element is HTMLSelectElement { - return element.dataset.conform === 'true'; + return typeof element.dataset.conform !== 'undefined'; } export function updateFieldValue( @@ -345,7 +346,7 @@ export function useInputControl< typeof value !== 'undefined' && (!Array.isArray(value) || value.length > 0) ) { - element = createDummySelect(form, meta.name, value); + element = createDummySelect(form, meta.key, meta.name, value); } register(element); From 3f72eba6ba894a916ffe938294583f83b5d2a970 Mon Sep 17 00:00:00 2001 From: Edmund Hung Date: Sat, 3 Aug 2024 18:00:20 +0100 Subject: [PATCH 3/3] test: subscribing initialValue will also subscribe key now --- tests/integrations/subscription.spec.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/integrations/subscription.spec.ts b/tests/integrations/subscription.spec.ts index 22ec7104..96701fe9 100644 --- a/tests/integrations/subscription.spec.ts +++ b/tests/integrations/subscription.spec.ts @@ -209,7 +209,7 @@ async function runTest(page: Page) { 'message.formId: 1', 'message.errorId: 1', 'message.descriptionId: 1', - 'message.initialValue: 1', + 'message.initialValue: 2', 'message.value: 14', 'message.key: 2', 'message.dirty: 3', @@ -222,7 +222,7 @@ async function runTest(page: Page) { 'form.id: 1', 'form.errorId: 1', 'form.descriptionId: 1', - 'form.initialValue: 1', + 'form.initialValue: 2', 'form.value: 21', 'form.key: 2', 'form.dirty: 3', @@ -233,7 +233,7 @@ async function runTest(page: Page) { 'name.formId: 1', 'name.errorId: 1', 'name.descriptionId: 1', - 'name.initialValue: 1', + 'name.initialValue: 2', 'name.value: 8', 'name.key: 2', 'name.dirty: 3', @@ -243,7 +243,7 @@ async function runTest(page: Page) { 'message.formId: 1', 'message.errorId: 1', 'message.descriptionId: 1', - 'message.initialValue: 1', + 'message.initialValue: 3', 'message.value: 14', 'message.key: 3', 'message.dirty: 3', @@ -258,7 +258,7 @@ async function runTest(page: Page) { 'form.id: 2', 'form.errorId: 2', 'form.descriptionId: 2', - 'form.initialValue: 1', + 'form.initialValue: 2', 'form.value: 24', 'form.key: 3', 'form.dirty: 5', @@ -269,7 +269,7 @@ async function runTest(page: Page) { 'name.formId: 2', 'name.errorId: 2', 'name.descriptionId: 2', - 'name.initialValue: 1', + 'name.initialValue: 2', 'name.value: 10', 'name.key: 3', 'name.dirty: 5', @@ -279,7 +279,7 @@ async function runTest(page: Page) { 'message.formId: 2', 'message.errorId: 2', 'message.descriptionId: 2', - 'message.initialValue: 1', + 'message.initialValue: 3', 'message.value: 16', 'message.key: 4', 'message.dirty: 5',