diff --git a/packages/conform-dom/form.ts b/packages/conform-dom/form.ts index 66717aa0..9ecfa491 100644 --- a/packages/conform-dom/form.ts +++ b/packages/conform-dom/form.ts @@ -117,6 +117,7 @@ export type Constraint = { export type FormMeta = { formId: string; isValueUpdated: boolean; + pendingIntents: Intent[]; submissionStatus?: 'error' | 'success'; defaultValue: Record; initialValue: Record; @@ -199,6 +200,7 @@ export type SubscriptionSubject = { } & { formId?: boolean; status?: boolean; + lastIntent?: boolean; }; export type SubscriptionScope = { @@ -231,6 +233,7 @@ export type FormContext< onBlur(event: Event): void; onUpdate(options: Partial>): void; observe(): () => void; + runSideEffect(intents: Intent[]): void; subscribe( callback: () => void, getSubject?: () => SubscriptionSubject | undefined, @@ -260,15 +263,16 @@ export type FormContext< function createFormMeta( options: FormOptions, - initialized?: boolean, + isResetting?: boolean, ): FormMeta { - const lastResult = !initialized ? options.lastResult : undefined; + const lastResult = !isResetting ? options.lastResult : undefined; const defaultValue = options.defaultValue ? (serialize(options.defaultValue) as Record) : {}; const initialValue = lastResult?.initialValue ?? defaultValue; const result: FormMeta = { formId: options.formId, + pendingIntents: isResetting ? [{ type: 'reset', payload: {} }] : [], isValueUpdated: false, submissionStatus: lastResult?.status, defaultValue, @@ -276,7 +280,7 @@ function createFormMeta( value: initialValue, constraint: options.constraint ?? {}, validated: lastResult?.state?.validated ?? {}, - key: !initialized + key: !isResetting ? getDefaultKey(defaultValue) : { '': generateId(), @@ -296,9 +300,9 @@ function getDefaultKey( defaultValue: Record | Array, prefix?: string, ): Record { - return Object.entries(flatten(defaultValue, { prefix })).reduce< - Record - >((result, [key, value]) => { + return Object.entries( + flatten(defaultValue, { resolve: normalize, prefix }), + ).reduce>((result, [key, value]) => { if (Array.isArray(value)) { for (let i = 0; i < value.length; i++) { result[formatName(key, i)] = generateId(); @@ -436,8 +440,10 @@ function updateValue( value: unknown, ): void { if (name === '') { - meta.initialValue = value as Record; - meta.value = value as Record; + meta.value = Object.assign({}, meta.value, value) as Record< + string, + unknown + >; meta.key = { ...getDefaultKey(value as Record), '': generateId(), @@ -445,12 +451,16 @@ function updateValue( return; } - meta.initialValue = clone(meta.initialValue); meta.value = clone(meta.value); meta.key = clone(meta.key); - setValue(meta.initialValue, name, () => value); - setValue(meta.value, name, () => value); + setValue(meta.value, name, (currentValue) => { + if (isPlainObject(currentValue)) { + return Object.assign({}, currentValue, value); + } + + return value; + }); if (isPlainObject(value) || Array.isArray(value)) { setState(meta.key, name, () => undefined); @@ -638,6 +648,7 @@ export function createFormContext< getSubject?: () => SubscriptionSubject | undefined; }> = []; const latestOptions = options; + const processedIntents = new Set(); let meta = createFormMeta(options); let state = createFormState(meta); @@ -669,6 +680,7 @@ export function createFormContext< return { submissionStatus: next.submissionStatus, + pendingIntents: next.pendingIntents, defaultValue, initialValue, value, @@ -707,7 +719,7 @@ export function createFormContext< state = nextState; const cache: Record< - Exclude, + Exclude, Record > = { value: {}, @@ -726,6 +738,8 @@ export function createFormContext< (subject.formId && prevMeta.formId !== nextMeta.formId) || (subject.status && prevState.submissionStatus !== nextState.submissionStatus) || + (subject.lastIntent && + prevMeta.pendingIntents !== nextMeta.pendingIntents) || shouldNotify( prevState.error, nextState.error, @@ -900,6 +914,7 @@ export function createFormContext< } function reset() { + processedIntents.clear(); updateFormMeta(createFormMeta(latestOptions, true)); } @@ -936,8 +951,16 @@ export function createFormContext< return result; }, {}); + const pendingIntents = + result.intent || + meta.pendingIntents.some((intent) => processedIntents.has(intent)) + ? meta.pendingIntents + .filter((intent) => !processedIntents.has(intent)) + .concat(result.intent ? [result.intent] : []) + : meta.pendingIntents; const update: FormMeta = { ...meta, + pendingIntents, isValueUpdated: false, submissionStatus: result.status, value: result.initialValue, @@ -1038,7 +1061,22 @@ export function createFormContext< }); } + function getFieldElements(node: Node, form: HTMLFormElement): FieldElement[] { + if (isFieldElement(node) && node.form === form) { + return [node]; + } + + if (node instanceof HTMLElement) { + return Array.from( + node.querySelectorAll('input,select,textarea'), + ).filter((element) => element.form === form); + } + + return []; + } + function observe() { + const initializedElements = new Set(); const observer = new MutationObserver((mutations) => { const form = getFormElement(); @@ -1046,27 +1084,80 @@ export function createFormContext< return; } + let shouldUpdateFormValue = false; + 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; + switch (mutation.type) { + case 'childList': { + for (const node of mutation.addedNodes) { + const elements = getFieldElements(node, form); + + for (const element of elements) { + const value = getValue(meta.initialValue, element.name); + const defaultValue = + typeof value === 'string' || + (Array.isArray(value) && + value.every((item) => typeof item === 'string')) + ? value + : undefined; + + if (!initializedElements.has(element)) { + updateField(element, { + value: defaultValue, + defaultValue, + constraint: meta.constraint[element.name], + }); + initializedElements.add(element); + } + + shouldUpdateFormValue = true; + } + } + for (const node of mutation.removedNodes) { + const elements = getFieldElements(node, form); + + if (elements.length > 0) { + shouldUpdateFormValue = true; + } + } + break; + } + default: { + const elements = getFieldElements(mutation.target, form); + + if (elements.length > 0) { + shouldUpdateFormValue = true; + } + break; } } } + + if (shouldUpdateFormValue) { + updateFormValue(form); + } }); + for (const element of getFormElement()?.elements ?? []) { + if (isFieldElement(element)) { + const value = getValue(meta.defaultValue, element.name); + const defaultValue = + typeof value === 'string' || + (Array.isArray(value) && + value.every((item) => typeof item === 'string')) + ? value + : undefined; + + updateField(element, { + value: defaultValue, + defaultValue, + constraint: meta.constraint[element.name], + }); + + initializedElements.add(element); + } + } + observer.observe(document, { subtree: true, childList: true, @@ -1079,6 +1170,62 @@ export function createFormContext< }; } + function runSideEffect(intents: Intent[]) { + const formElement = getFormElement(); + + if (!formElement) { + return; + } + + for (const intent of intents) { + switch (intent.type) { + case 'update': { + const flattenedValue = flatten(intent.payload.value, { + prefix: formatName(intent.payload.name, intent.payload.index), + }); + + for (const element of formElement.elements) { + if (isFieldElement(element)) { + const value = flattenedValue[element.name]; + + updateField(element, { + value: + typeof value === 'string' || + (Array.isArray(value) && + value.every((item) => typeof item === 'string')) + ? value + : undefined, + }); + } + } + break; + } + case 'reset': { + const prefix = formatName(intent.payload.name, intent.payload.index); + + for (const element of formElement.elements) { + if (isFieldElement(element) && isPrefix(element.name, prefix)) { + const value = getValue(meta.defaultValue, element.name); + + updateField(element, { + defaultValue: + typeof value === 'string' || + (Array.isArray(value) && + value.every((item) => typeof item === 'string')) + ? value + : undefined, + }); + resetField(element); + } + } + break; + } + } + + processedIntents.add(intent); + } + } + return { getFormId() { return meta.formId; @@ -1094,9 +1241,149 @@ export function createFormContext< insert: createFormControl('insert'), remove: createFormControl('remove'), reorder: createFormControl('reorder'), + runSideEffect, subscribe, getState, getSerializedState, observe, }; } + +export function resetField( + element: HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement, +): void { + if (element instanceof HTMLInputElement) { + switch (element.type) { + case 'checkbox': + case 'radio': + element.checked = element.defaultChecked; + break; + case 'file': + element.value = ''; + break; + default: + element.value = element.defaultValue; + break; + } + } else if (element instanceof HTMLSelectElement) { + for (const option of element.options) { + option.selected = option.defaultSelected; + } + } else { + element.value = element.defaultValue; + } +} + +export function updateField( + element: HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement, + options: { + value?: string | string[]; + defaultValue?: string | string[]; + constraint?: { + required?: boolean; + minLength?: number; + maxLength?: number; + min?: string | number; + max?: string | number; + step?: string | number; + multiple?: boolean; + pattern?: string; + }; + }, +) { + const value = + typeof options.value === 'undefined' + ? null + : Array.isArray(options.value) + ? options.value + : [options.value]; + const defaultValue = + typeof options.defaultValue === 'undefined' + ? null + : Array.isArray(options.defaultValue) + ? options.defaultValue + : [options.defaultValue]; + + if (options.constraint) { + const { constraint } = options; + + if ( + typeof constraint.required !== 'undefined' && + // If the element is a part of the checkbox group, it is unclear whether all checkboxes are required or only one. + !( + element.type === 'checkbox' && + element.form?.elements.namedItem(element.name) instanceof RadioNodeList + ) + ) { + element.required = constraint.required; + } + + if (typeof constraint.multiple !== 'undefined' && 'multiple' in element) { + element.multiple = constraint.multiple; + } + + if (typeof constraint.minLength !== 'undefined' && 'minLength' in element) { + element.minLength = constraint.minLength; + } + + if (typeof constraint.maxLength !== 'undefined' && 'maxLength' in element) { + element.maxLength = constraint.maxLength; + } + if (typeof constraint.min !== 'undefined' && 'min' in element) { + element.min = `${constraint.min}`; + } + + if (typeof constraint.max !== 'undefined' && 'max' in element) { + element.max = `${constraint.max}`; + } + + if (typeof constraint.step !== 'undefined' && 'step' in element) { + element.step = `${constraint.step}`; + } + + if (typeof constraint.pattern !== 'undefined' && 'pattern' in element) { + element.pattern = constraint.pattern; + } + } + + if (element instanceof HTMLInputElement) { + switch (element.type) { + case 'checkbox': + case 'radio': + if (value) { + element.checked = value.includes(element.value); + } + if (defaultValue) { + element.defaultChecked = defaultValue.includes(element.value); + } + break; + case 'file': + // Do nothing for now + break; + default: + if (value) { + element.value = value[0] ?? ''; + } + if (defaultValue) { + element.defaultValue = defaultValue[0] ?? ''; + } + break; + } + } else if (element instanceof HTMLSelectElement) { + for (const option of element.options) { + if (value) { + option.selected = value.includes(option.value); + } + if (defaultValue) { + option.defaultSelected = defaultValue.includes(option.value); + } + } + } else { + if (value) { + element.value = value[0] ?? ''; + } + if (defaultValue) { + element.defaultValue = defaultValue[0] ?? ''; + } + } +} diff --git a/packages/conform-dom/formdata.ts b/packages/conform-dom/formdata.ts index 30aa5111..aacaeb20 100644 --- a/packages/conform-dom/formdata.ts +++ b/packages/conform-dom/formdata.ts @@ -259,7 +259,7 @@ export function flatten( const resolve = options.resolve ?? ((data) => data); function process(data: unknown, prefix: string) { - const value = normalize(resolve(data)); + const value = resolve(data); if (typeof value !== 'undefined') { result[prefix] = value; @@ -276,7 +276,7 @@ export function flatten( } } - if (data) { + if (typeof data !== 'undefined') { process(data, options.prefix ?? ''); } diff --git a/packages/conform-dom/submission.ts b/packages/conform-dom/submission.ts index c13ec74b..3be75ffb 100644 --- a/packages/conform-dom/submission.ts +++ b/packages/conform-dom/submission.ts @@ -175,9 +175,18 @@ export function parse( if (typeof intent.payload.value !== 'undefined') { if (name) { - setValue(context.payload, name, () => value); + setValue(context.payload, name, (currentValue) => { + if (isPlainObject(currentValue)) { + return Object.assign({}, currentValue, value); + } + + return value; + }); } else { - context.payload = value; + context.payload = { + ...context.payload, + ...value, + }; } } break; @@ -505,10 +514,10 @@ export function setState( resolve(data) { if (isPlainObject(data) || Array.isArray(data)) { // @ts-expect-error - return data[root] ?? null; + return normalize(data[root] ?? null); } - return data; + return normalize(data); }, prefix: name, }), diff --git a/packages/conform-react/context.tsx b/packages/conform-react/context.tsx index 69ece9c4..76d9e89f 100644 --- a/packages/conform-react/context.tsx +++ b/packages/conform-react/context.tsx @@ -210,7 +210,11 @@ export function updateSubjectRef( scope?: keyof SubscriptionScope, name?: string, ): void { - if (subject === 'status' || subject === 'formId') { + if ( + subject === 'status' || + subject === 'formId' || + subject === 'lastIntent' + ) { ref.current[subject] = true; } else if (typeof scope !== 'undefined' && typeof name !== 'undefined') { ref.current[subject] = { diff --git a/packages/conform-react/helpers.ts b/packages/conform-react/helpers.ts index bebe699a..1f7734d9 100644 --- a/packages/conform-react/helpers.ts +++ b/packages/conform-react/helpers.ts @@ -214,7 +214,7 @@ export function getFormControlProps( options?: FormControlOptions, ): FormControlProps { return simplify({ - key: metadata.key, + key: undefined, required: metadata.required || undefined, ...getFieldsetProps(metadata, options), }); diff --git a/packages/conform-react/hooks.ts b/packages/conform-react/hooks.ts index 24161525..684fd0dc 100644 --- a/packages/conform-react/hooks.ts +++ b/packages/conform-react/hooks.ts @@ -91,11 +91,17 @@ export function useForm< context.onUpdate({ ...formConfig, formId }); }); - const subjectRef = useSubjectRef(); + const subjectRef = useSubjectRef({ + lastIntent: true, + }); const stateSnapshot = useFormState(context, subjectRef); const noValidate = useNoValidate(options.defaultNoValidate); const form = getFormMetadata(context, subjectRef, stateSnapshot, noValidate); + useEffect(() => { + context.runSideEffect(stateSnapshot.pendingIntents); + }, [context, stateSnapshot.pendingIntents]); + return [form, form.getFieldset()]; } diff --git a/playground/app/routes/form-control.tsx b/playground/app/routes/form-control.tsx index dad98b68..d552b9bd 100644 --- a/playground/app/routes/form-control.tsx +++ b/playground/app/routes/form-control.tsx @@ -104,6 +104,37 @@ export default function FormControl() { > Update number + +