import type { JSX } from "@solidjs/web"; import { type Accessor, onCleanup, untrack } from "solid-js"; import { DEFAULT_VALIDITY_STATE, getCombinedFieldValidityData, } from "../field-constants"; import type { FieldRootState, FieldValidateFn, FieldValidationProps, FieldValidityData, MutableRef, } from "../field-context"; import { useFormContext } from "../form-context"; import { mergePropsN, type PropsLike } from "../merge-props-n"; import { ariaInvalid } from "../seed-aria-attr"; import { createStableCallback } from "../stable-callback"; import { useTimeout } from "../use-timeout"; type ValidityKey = keyof ValidityState; const validityKeys = Object.keys(DEFAULT_VALIDITY_STATE) as Array; /** * Native ValidityState may only report `valueMissing` (i.e. the control is * empty) — we don't want to "scold" the user for empty fields if other * native errors aren't present. This helper returns `true` when the only * thing wrong is `valueMissing`. */ function isOnlyValueMissing( state: Record | undefined, ): boolean { if (!state || state.valid || !state.valueMissing) { return false; } let onlyValueMissing = false; for (const key of validityKeys) { if (key === "valid") continue; if (key === "valueMissing") { onlyValueMissing = state[key]; continue; } if (state[key]) { onlyValueMissing = false; } } return onlyValueMissing; } export interface UseFieldValidationParameters { setValidityData: (data: FieldValidityData) => void; validate: Accessor; validityData: Accessor; validationDebounceTime: Accessor; invalid: Accessor; markedDirtyRef: MutableRef; state: Accessor; name: Accessor; /** ``-level id (for Form registration) — used to key the * fields map when updating the form's combined validity snapshot. */ controlId: Accessor; shouldValidateOnChange: Accessor; } /** * Port of Base UI React's `useFieldValidation` * (`packages/react/src/field/root/useFieldValidation.ts`). Produces the * props-factories and the imperative `commit` function that the Field root * exposes to the rest of the field parts. * * Solid 2 notes: * * - Every parameter except the plain refs is an `Accessor`. Callers read * them through the stable-callback trampolines below, so invocation-time * values always win. * - `commit` wraps a setValidityData write; the parent root is expected to * route that through its own `createStableCallback`-wrapped setter so * writes inside reactive scope are legal. * - React used `ReactDOM.flushSync` for synchronous submit validation; the * Solid port calls `flush()` is NOT needed here because consumers do it * themselves after invoking `validate`. The `flushSync` parameter is * accepted for API parity with the React entry handled. */ export function useFieldValidation( params: UseFieldValidationParameters, ): FieldValidationProps { const { setValidityData, validate, validityData, validationDebounceTime, invalid, markedDirtyRef, state, name, controlId, shouldValidateOnChange, } = params; const { formRef, clearErrors } = useFormContext(); const timeout = useTimeout(); const inputRef: MutableRef = { current: null }; // Monotonic generation counter — bumped on each commit entry so any // later-started commit can invalidate the writes of an in-flight // predecessor. Last-start-wins: an await in commit#1 cannot land its // validity write if commit#2 has been started in the meantime. This // matches React's pattern (used in combination with React's mount-guard) // and prevents intermediate states from leaking between two racing // commits. let commitGeneration = 0; // Owner-dispose guard. `commit` is async and can outlive the owning // scope: if the consumer awaits a slow validate and the root disposes // before it resolves, we must not land a late `setValidityData` write. // `useFieldValidation` itself lives in the component body, so // `onCleanup` fires when the owner disposes. let disposed = false; onCleanup(() => { disposed = true; }); // Core commit routine — wrapped below via `createStableCallback` so the // returned reference is referentially stable for consumers that capture // it in refs / closures. const commitImpl = async ( value: unknown, revalidate = false, ): Promise => { const myGeneration = ++commitGeneration; const isStale = () => disposed || myGeneration !== commitGeneration; if (disposed) return; const element = inputRef.current; if (!element) { return; } // `untrack` the reactive-state snapshot reads — `commitImpl` is an // imperative side-effect path called from event handlers and other // callers that may sit in unowned/effect-apply scope. The reads // here are point-in-time snapshots, not subscriptions. const currentValidity = untrack(validityData); const currentState = untrack(state); if (revalidate) { if (currentState.valid !== false) { return; } const currentNativeValidity = element.validity; if (!currentNativeValidity.valueMissing) { // The 'valueMissing' (required) condition has been resolved // by the user typing. Temporarily mark the field as valid // for this onChange event. Other native errors (e.g. // `typeMismatch`) will be caught by full validation on blur // or submit. const nextValidityData: FieldValidityData = { value, state: { ...DEFAULT_VALIDITY_STATE, valid: true }, error: "", errors: [], initialValue: currentValidity.initialValue, }; element.setCustomValidity(""); const id = controlId(); if (id) { const currentFieldData = formRef.current.fields.get(id); if (currentFieldData) { // `getCombinedFieldValidityData` returns a `FieldValidityData` // (top-level: state/errors/error/value/initialValue). It must // land under the entry's `validityData` key, NOT spread onto // the entry — otherwise Form's submit path, which reads // `entry.validityData.state.valid`, sees the stale snapshot // captured at registration time. formRef.current.fields.set(id, { ...currentFieldData, validityData: getCombinedFieldValidityData( nextValidityData, false, ), }); } } setValidityData(nextValidityData); return; } // Value is still missing, or other conditions apply. Build a // plain object representation of the native validity so // `isOnlyValueMissing` can inspect it without the live // `ValidityState` getter overhead. const currentNativeValidityObject = validityKeys.reduce( (acc, key) => { acc[key] = currentNativeValidity[key]; return acc; }, {} as Record, ); // If it's (still) natively invalid due to something other than // just valueMissing, bail from this revalidation-on-change to // avoid "scolding" the user for unrelated errors mid-typing. if ( !currentNativeValidityObject.valid && !isOnlyValueMissing(currentNativeValidityObject) ) { return; } // Otherwise fall through to the full validation path below. } function getState(el: HTMLInputElement) { const computedState = validityKeys.reduce( (acc, key) => { acc[key] = el.validity[key]; return acc; }, {} as Record, ); let hasOnlyValueMissingError = false; for (const key of validityKeys) { if (key === "valid") continue; if (key === "valueMissing" && computedState[key]) { hasOnlyValueMissingError = true; } else if (computedState[key]) { return computedState; } } // Only let `valueMissing` mark the field invalid once the user // has touched it; otherwise every pristine required field // would render as "invalid" on mount. if (hasOnlyValueMissingError && !markedDirtyRef.current) { computedState.valid = true; computedState.valueMissing = false; } return computedState; } timeout.clear(); let result: null | string | string[] = null; let validationErrors: string[] = []; const nextState = getState(element); let defaultValidationMessage: string | undefined; const validateOnChange = shouldValidateOnChange(); if (element.validationMessage && !validateOnChange) { // Not validating on change: if there's a native // `validationMessage`, surface it directly and skip the // consumer-supplied validate fn. defaultValidationMessage = element.validationMessage; validationErrors = [element.validationMessage]; } else { // Either validating on change, or native constraint validations // passed — call the custom validate fn next. const formValues = Array.from(formRef.current.fields.values()).reduce( (acc, field) => { if (field.name) { acc[field.name] = field.getValue(); } return acc; }, {} as Record, ); const validateFn = validate(); if (validateFn) { const resultOrPromise = validateFn(value, formValues); if ( typeof resultOrPromise === "object" && resultOrPromise !== null && "then" in resultOrPromise ) { result = await resultOrPromise; // A newer commit started while we were awaiting — bail so // the later call's result (or lack thereof) wins. No // intermediate state gets published. if (isStale()) return; } else { result = resultOrPromise as string | string[] | null; } } if (result !== null) { nextState.valid = false; nextState.customError = true; if (Array.isArray(result)) { validationErrors = result; element.setCustomValidity(result.join("\n")); } else if (result) { validationErrors = [result]; element.setCustomValidity(result); } } else if (validateOnChange) { // Custom validate returned no errors; if validating on // change we need to clear the previously-set custom // validity so the next native check reads clean. element.setCustomValidity(""); nextState.customError = false; if (element.validationMessage) { defaultValidationMessage = element.validationMessage; validationErrors = [element.validationMessage]; } else if (element.validity.valid && !nextState.valid) { nextState.valid = true; } } } const nextValidityData: FieldValidityData = { value, state: nextState, error: defaultValidationMessage ?? (Array.isArray(result) ? (result[0] ?? "") : (result ?? "")), errors: validationErrors, initialValue: currentValidity.initialValue, }; const id = controlId(); if (id) { const currentFieldData = formRef.current.fields.get(id); if (currentFieldData) { // Land the combined snapshot under `validityData` (NOT spread // onto the entry's top-level keys) so Form's `finishSubmit`, // which reads `entry.validityData.state.valid`, sees the // fresh result. See sibling fix in the revalidate branch. formRef.current.fields.set(id, { ...currentFieldData, validityData: getCombinedFieldValidityData( nextValidityData, invalid(), ), }); } } setValidityData(nextValidityData); }; const commit = createStableCallback(() => commitImpl); const getValidationProps = ( externalProps: JSX.HTMLAttributes = {}, ): JSX.HTMLAttributes => { // `mergePropsN` is the reactive analog to React's `mergeProps` — it // layers getters on top of each other so `aria-invalid` updates with // `state()`. return mergePropsN([ ariaInvalid(() => state().valid === false), externalProps as PropsLike, ]) as JSX.HTMLAttributes; }; const getInputValidationProps = ( externalProps: JSX.InputHTMLAttributes = {}, ): JSX.InputHTMLAttributes => { const onInput = ( event: InputEvent & { currentTarget: HTMLInputElement }, ) => { // The native event handler for React's `onChange` is `onInput` in // Solid — match React's behavior (which explicitly bails on // `defaultPrevented` due to a React bug; we preserve the guard). if (event.defaultPrevented) { return; } clearErrors(name()); if (!shouldValidateOnChange()) { void commit(event.currentTarget.value, true); return; } const element = event.currentTarget; if (element.value === "") { // Empty values bypass the debounce so "required" errors clear // immediately when the user clears the field. void commit(element.value); return; } timeout.clear(); const debounce = validationDebounceTime(); if (debounce) { timeout.start(debounce, () => { void commit(element.value); }); } else { void commit(element.value); } }; return mergePropsN([ { onInput } as PropsLike, getValidationProps( externalProps as JSX.HTMLAttributes, ) as PropsLike, ]) as JSX.InputHTMLAttributes; }; return { getValidationProps, getInputValidationProps, inputRef, commit, }; }