From 3dad09674935e2767cc08838b824d761e0921e5e Mon Sep 17 00:00:00 2001 From: Van Nguyen Date: Fri, 4 Sep 2026 20:50:01 +0700 Subject: [PATCH 01/31] feat(react): expose baselineValues and getDirtyValues from useDynamicForm The dirty baseline was implicit and unreachable, so nothing outside the hook could tell which values a form opened with. reset(newValues) now re-bases it, and getDirtyValues reads it for PATCH-style submits. --- packages/react/src/useDynamicForm.ts | 37 ++++++++++++ packages/react/test/useDynamicForm.test.tsx | 67 +++++++++++++++++++++ 2 files changed, 104 insertions(+) diff --git a/packages/react/src/useDynamicForm.ts b/packages/react/src/useDynamicForm.ts index cd8eaa9..19ead2b 100644 --- a/packages/react/src/useDynamicForm.ts +++ b/packages/react/src/useDynamicForm.ts @@ -24,6 +24,18 @@ export interface UseDynamicFormResult { isValidationComplete: boolean; validationStatus: ValidationResult['status']; isDirty: boolean; + /** + * The values `dirty` is measured against: the `initialValues` option until + * `reset(newValues)` replaces them. Distinct from that option, which never + * changes - pass this to `MultiFieldInput` (or use the `form` shorthand) so + * per-field `dirty` survives a reset. + */ + baselineValues: Properties; + /** + * The entries of `data` that differ from `baselineValues`. Intended for + * PATCH-style submits that should carry only what the user actually edited. + */ + getDirtyValues: () => Properties; isSubmitting: boolean; isSubmitted: boolean; touched: Record; @@ -111,6 +123,15 @@ export function useDynamicForm({ const dataRef = useRef(data); dataRef.current = data; + // The baseline `dirty` is measured against. Kept in both a ref and state: + // `getDirtyValues` needs it synchronously at call time, while + // `MultiFieldInput` needs a render-visible value. Both are written together + // in `reset`, the only place it changes. + const baselineRef = useRef(dataRef.current); + const [baselineValues, setBaselineValues] = useState( + () => baselineRef.current, + ); + const commitSyncResult = useCallback((res: ValidationResult) => { setValidationResult((previous) => sameValidationResult(previous, res) ? previous : res, @@ -204,6 +225,18 @@ export function useDynamicForm({ const resetTouched = useCallback(() => setTouched({}), []); + const getDirtyValues = useCallback((): Properties => { + const baseline = baselineRef.current; + const current = dataRef.current; + const dirty: Properties = {}; + for (const key of Object.keys(current)) { + if (!Object.is(current[key], baseline[key])) { + dirty[key] = current[key]; + } + } + return dirty; + }, []); + const handleBlur = useCallback( (fieldName: string) => { setFieldTouched(fieldName, true); @@ -222,6 +255,8 @@ export function useDynamicForm({ const next = applyComputedValues(fields, seed); setData(next); dataRef.current = next; + baselineRef.current = next; + setBaselineValues(next); setErrors({}); setIsDirty(false); setTouched({}); @@ -310,6 +345,8 @@ export function useDynamicForm({ isValidationComplete: validationResult.complete && !isValidating, validationStatus: isValidating ? 'pending' : validationResult.status, isDirty, + baselineValues, + getDirtyValues, isSubmitting, isSubmitted, touched, diff --git a/packages/react/test/useDynamicForm.test.tsx b/packages/react/test/useDynamicForm.test.tsx index 97c2702..5dfa326 100644 --- a/packages/react/test/useDynamicForm.test.tsx +++ b/packages/react/test/useDynamicForm.test.tsx @@ -208,3 +208,70 @@ describe('useDynamicForm behaviour', () => { expect(result.current.data.name).toBe('Direct'); }); }); + +describe('baselineValues and getDirtyValues', () => { + const baselineFields: FieldDescription[] = [ + { name: 'title', type: 'text', label: 'Title' }, + { name: 'note', type: 'text', label: 'Note' }, + ]; + + it('exposes the initial values as the baseline', () => { + const { result } = renderHook(() => + useDynamicForm({ + fields: baselineFields, + initialValues: { title: 'a', note: 'n' }, + }), + ); + expect(result.current.baselineValues).toEqual({ title: 'a', note: 'n' }); + expect(result.current.getDirtyValues()).toEqual({}); + }); + + it('reports only the changed entries as dirty', () => { + const { result } = renderHook(() => + useDynamicForm({ + fields: baselineFields, + initialValues: { title: 'a', note: 'n' }, + }), + ); + act(() => result.current.setFieldValue('title', 'b')); + expect(result.current.getDirtyValues()).toEqual({ title: 'b' }); + }); + + it('re-bases the baseline on reset(newValues)', () => { + const { result } = renderHook(() => + useDynamicForm({ + fields: baselineFields, + initialValues: { title: 'a', note: 'n' }, + }), + ); + act(() => result.current.setFieldValue('title', 'b')); + act(() => result.current.reset({ title: 'c', note: 'n' })); + + expect(result.current.baselineValues).toEqual({ title: 'c', note: 'n' }); + expect(result.current.getDirtyValues()).toEqual({}); + }); + + it('restores the original baseline on a bare reset()', () => { + const { result } = renderHook(() => + useDynamicForm({ + fields: baselineFields, + initialValues: { title: 'a', note: 'n' }, + }), + ); + act(() => result.current.reset({ title: 'c', note: 'n' })); + act(() => result.current.reset()); + + expect(result.current.baselineValues).toEqual({ title: 'a', note: 'n' }); + }); + + it('counts a key absent from the baseline as dirty', () => { + const { result } = renderHook(() => + useDynamicForm({ + fields: baselineFields, + initialValues: { title: 'a' }, + }), + ); + act(() => result.current.setFieldValue('note', 'added')); + expect(result.current.getDirtyValues()).toEqual({ note: 'added' }); + }); +}); From 94e5d9b124923c501cf18c7945d90811d46c35a2 Mon Sep 17 00:00:00 2001 From: Van Nguyen Date: Fri, 4 Sep 2026 20:52:02 +0700 Subject: [PATCH 02/31] fix(react): re-base per-field dirty on reset and late-arriving values The baseline was captured once with useRef at mount and never reassigned, so every field compared against pre-reset values after reset(newValues), and against {} when properties arrived from a fetch - reporting the whole form dirty forever. It now comes from whoever owns the values: the bound form's baselineValues, an explicit initialProperties prop, or the first non-undefined properties seen. --- .../react/src/components/MultiFieldInput.tsx | 31 +++- packages/react/test/MultiFieldInput.test.tsx | 141 +++++++++++++++++- 2 files changed, 168 insertions(+), 4 deletions(-) diff --git a/packages/react/src/components/MultiFieldInput.tsx b/packages/react/src/components/MultiFieldInput.tsx index 14f4cca..8ea7a77 100644 --- a/packages/react/src/components/MultiFieldInput.tsx +++ b/packages/react/src/components/MultiFieldInput.tsx @@ -27,6 +27,8 @@ export interface DynamicFormBinding { data: Properties; errors: Record; touched: Record; + /** The values per-field `dirty` is measured against. See `useDynamicForm`. */ + baselineValues?: Properties; handleChange: (data: Properties) => void; handleBlur: (fieldName: string) => void; } @@ -57,6 +59,14 @@ interface Props { * restores the pre-1.6 ids), or set `FieldDescription.id` per field. */ idPrefix?: string; + /** + * The values per-field `dirty` is measured against. Defaults to the first + * non-`undefined` `properties` this component sees - which is what an edit + * form wants when its values arrive from a fetch after mount. Supplied + * automatically by the `form` shorthand; pass it explicitly to re-base + * `dirty` without going through a form store. + */ + initialProperties?: Properties; /** * Top-level form data, threaded down through repeatable groups so a nested * field's `appearCondition`/`computeValue` can read the root form. Omitted at @@ -113,6 +123,7 @@ const MultiFieldInputInner = ( onChange, layout, idPrefix, + initialProperties, rootData, onValidityChange, onBlurField, @@ -135,7 +146,23 @@ const MultiFieldInputInner = ( const [internalTouched, setInternalTouched] = useState< Record >({}); - const initialPropertiesRef = useRef(effectiveProperties ?? {}); + // Falls back to the first non-`undefined` properties rather than `{}` at + // mount: an edit form whose values arrive from a fetch would otherwise + // measure `dirty` against an empty object and report every field dirty + // forever. Re-basing on every `properties` change is not an option - in + // controlled mode `form.data` gets a new identity on every keystroke, which + // would pin `dirty` to false instead. + const fallbackBaselineRef = useRef( + effectiveProperties, + ); + if (fallbackBaselineRef.current === undefined) { + fallbackBaselineRef.current = effectiveProperties; + } + const baseline = + initialProperties ?? + form?.baselineValues ?? + fallbackBaselineRef.current ?? + {}; // Unique per component instance. `useId` is SSR-safe (server and client // agree), unlike a module-level counter. Its delimiters vary by React version @@ -272,7 +299,7 @@ const MultiFieldInputInner = ( // gets a new identity on every touch. touchedMap={isFieldGroup(f) ? effectiveTouched : undefined} errors={effectiveErrors} - dirty={data[f.name] !== initialPropertiesRef.current[f.name]} + dirty={!Object.is(data[f.name], baseline[f.name])} onBlurField={handleBlurField} onValueChangeField={handleValueChangeField} /> diff --git a/packages/react/test/MultiFieldInput.test.tsx b/packages/react/test/MultiFieldInput.test.tsx index b31a8b8..52c69a5 100644 --- a/packages/react/test/MultiFieldInput.test.tsx +++ b/packages/react/test/MultiFieldInput.test.tsx @@ -1,15 +1,30 @@ -import type { FieldDescription } from '@dynamic-field-kit/core'; -import { render, screen } from '@testing-library/react'; +import type { + FieldDescription, + FieldRendererProps, + Properties, +} from '@dynamic-field-kit/core'; +import { + act, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react'; import React from 'react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import MultiFieldInput from '../src/components/MultiFieldInput'; import { fieldRegistry } from '../src/fieldRegistry'; import { layoutRegistry } from '../src/layout/layoutRegistry'; +import { + useDynamicForm, + type UseDynamicFormResult, +} from '../src/useDynamicForm'; declare module '@dynamic-field-kit/core' { interface FieldTypeMap { text: string; email: string; + dirtyProbe: string; } } @@ -217,3 +232,125 @@ describe('MultiFieldInput', () => { expect(screen.getAllByTestId('input')[1]).toHaveValue('Hello Ada'); }); }); + +describe('dirty baseline', () => { + const dirtyFields: FieldDescription[] = [ + { name: 'title', type: 'dirtyProbe', label: 'Title' }, + ]; + + const DirtyProbe = ({ + id, + value, + dirty, + onValueChange, + }: FieldRendererProps) => ( + onValueChange?.(e.target.value)} + /> + ); + + beforeEach(() => { + fieldRegistry.register('dirtyProbe', DirtyProbe); + layoutRegistry.register( + 'column', + ({ children }: { children: React.ReactNode }) =>
{children}
, + ); + }); + + it('is not dirty when properties arrive after mount', async () => { + const Late = () => { + const [props, setProps] = React.useState( + undefined, + ); + React.useEffect(() => setProps({ title: 'loaded' }), []); + return ( + + ); + }; + render(); + await waitFor(() => + expect(screen.getByTestId('late-title')).toHaveValue('loaded'), + ); + expect(screen.getByTestId('late-title')).toHaveAttribute( + 'data-dirty', + 'false', + ); + }); + + it('honours an explicit initialProperties baseline', () => { + render( + , + ); + expect(screen.getByTestId('explicit-title')).toHaveAttribute( + 'data-dirty', + 'true', + ); + }); + + it('re-bases when the bound form is reset to new values', async () => { + let api: UseDynamicFormResult | undefined; + const Bound = () => { + const form = useDynamicForm({ + fields: dirtyFields, + initialValues: { title: 'a' }, + }); + api = form; + return ( + + ); + }; + render(); + + await act(async () => api!.reset({ title: 'b' })); + + expect(screen.getByTestId('bound-title')).toHaveValue('b'); + expect(screen.getByTestId('bound-title')).toHaveAttribute( + 'data-dirty', + 'false', + ); + }); + + it('still reports dirty while the user types in controlled mode', async () => { + const Bound = () => { + const form = useDynamicForm({ + fields: dirtyFields, + initialValues: { title: 'a' }, + }); + return ( + + ); + }; + render(); + + fireEvent.change(screen.getByTestId('typing-title'), { + target: { value: 'ab' }, + }); + + await waitFor(() => + expect(screen.getByTestId('typing-title')).toHaveAttribute( + 'data-dirty', + 'true', + ), + ); + }); +}); From 99e9ef2d3664d7ff82cbad04cc2c0d7ae956c07b Mon Sep 17 00:00:00 2001 From: Van Nguyen Date: Fri, 4 Sep 2026 20:52:58 +0700 Subject: [PATCH 03/31] perf(react): stop validating the same data twice per change handleChange validated eagerly and the [fields, data] effect validated the identical object again after commit. The effect now skips data it has already seen, and re-arms when fields change identity so a schema swap still revalidates untouched data. Vue and Angular do not have this bug - neither store runs a watch or effect, so both already validate exactly once in handleChange. --- packages/react/src/useDynamicForm.ts | 17 +++++++++++++++++ packages/react/test/validation.test.tsx | 20 +++++++++++++++++++- 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/packages/react/src/useDynamicForm.ts b/packages/react/src/useDynamicForm.ts index 19ead2b..d905e35 100644 --- a/packages/react/src/useDynamicForm.ts +++ b/packages/react/src/useDynamicForm.ts @@ -142,7 +142,23 @@ export function useDynamicForm({ // Keeps the result in step with later data changes. The seed above covers // the first render (including the server's); from here on validators run // after commit, never from a useMemo that re-runs on every render. + // Identity of the data the synchronous pass last ran against. `handleChange` + // validates eagerly so `errors` is correct in the same tick; without this + // the effect below would then validate the identical object a second time. + const lastValidatedRef = useRef(undefined); + // A `fields` change must re-validate even when `data` is untouched, so + // re-arm the guard rather than letting the identity match short-circuit it. + const fieldsRef = useRef(fields); + if (fieldsRef.current !== fields) { + fieldsRef.current = fields; + lastValidatedRef.current = undefined; + } + useEffect(() => { + if (lastValidatedRef.current === data) { + return; + } + lastValidatedRef.current = data; commitSyncResult(validateFields(fields, data)); }, [fields, data, commitSyncResult]); @@ -194,6 +210,7 @@ export function useDynamicForm({ validationRunRef.current += 1; setIsValidating(false); + lastValidatedRef.current = next; const res = validateFields(fields, next); commitSyncResult(res); diff --git a/packages/react/test/validation.test.tsx b/packages/react/test/validation.test.tsx index cff4021..e90ddf2 100644 --- a/packages/react/test/validation.test.tsx +++ b/packages/react/test/validation.test.tsx @@ -1,10 +1,11 @@ import type { FieldDescription } from '@dynamic-field-kit/core'; import { FieldRegistry } from '@dynamic-field-kit/core'; -import { render, screen } from '@testing-library/react'; +import { act, render, renderHook, screen } from '@testing-library/react'; import React from 'react'; import { describe, expect, it, vi } from 'vitest'; import MultiFieldInput from '../src/components/MultiFieldInput'; import { FieldRegistryProvider } from '../src/FieldRegistryContext'; +import { useDynamicForm } from '../src/useDynamicForm'; import '../src/layout/defaultLayouts'; declare module '@dynamic-field-kit/core' { @@ -111,3 +112,20 @@ describe('React validation wiring', () => { }); }); }); + +describe('validation is not run twice per change', () => { + it('validates once per handleChange call', async () => { + const validate = vi.fn(() => undefined); + const countedFields: FieldDescription[] = [ + { name: 'title', type: 'text', label: 'Title', validate }, + ]; + const { result } = renderHook(() => + useDynamicForm({ fields: countedFields, initialValues: { title: '' } }), + ); + + validate.mockClear(); + await act(async () => result.current.handleChange({ title: 'a' })); + + expect(validate).toHaveBeenCalledTimes(1); + }); +}); From 65e0a56c5019066e874b0f05051d77ffde10ddd7 Mon Sep 17 00:00:00 2001 From: Van Nguyen Date: Fri, 4 Sep 2026 20:53:45 +0700 Subject: [PATCH 04/31] feat(vue): expose baselineValues and getDirtyValues from useDynamicForm Mirrors the React adapter so the dirty baseline is reachable from outside the composable and survives reset(newValues). --- packages/vue/src/useDynamicForm.ts | 19 ++++++++++ packages/vue/test/useDynamicForm.test.ts | 47 ++++++++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/packages/vue/src/useDynamicForm.ts b/packages/vue/src/useDynamicForm.ts index 0d59e23..e3fb150 100644 --- a/packages/vue/src/useDynamicForm.ts +++ b/packages/vue/src/useDynamicForm.ts @@ -23,6 +23,10 @@ export function useDynamicForm({ validateOnChange = false, }: UseDynamicFormOptions) { const data = ref(applyComputedValues(fields, initialValues)); + // The baseline `dirty` is measured against: the initialValues option until + // reset(newValues) replaces it. Distinct from that option, which never + // changes. See the React adapter for the full rationale. + const baselineValues = ref({ ...data.value }); const errors = ref>({}); const isDirty = ref(false); const touched = ref>({}); @@ -130,6 +134,18 @@ export function useDynamicForm({ } /** Clears the touched map without touching data, errors or dirty state. */ + function getDirtyValues(): Properties { + const baseline = baselineValues.value; + const current = data.value; + const dirty: Properties = {}; + for (const key of Object.keys(current)) { + if (!Object.is(current[key], baseline[key])) { + dirty[key] = current[key]; + } + } + return dirty; + } + function resetTouched() { touched.value = {}; } @@ -147,6 +163,7 @@ export function useDynamicForm({ const seed = newValues ?? initialValues; const next = applyComputedValues(fields, seed); data.value = next; + baselineValues.value = { ...next }; errors.value = {}; isDirty.value = false; touched.value = {}; @@ -224,6 +241,8 @@ export function useDynamicForm({ isValidationComplete, validationStatus, isDirty, + baselineValues, + getDirtyValues, touched, isSubmitting, isSubmitted, diff --git a/packages/vue/test/useDynamicForm.test.ts b/packages/vue/test/useDynamicForm.test.ts index ed305f3..60103df 100644 --- a/packages/vue/test/useDynamicForm.test.ts +++ b/packages/vue/test/useDynamicForm.test.ts @@ -324,3 +324,50 @@ describe('scope cleanup', () => { expect(seen?.aborted).toBe(true); }); }); + +describe('baselineValues and getDirtyValues', () => { + const baselineFields: FieldDescription[] = [ + { name: 'title', type: 'text', label: 'Title' }, + { name: 'note', type: 'text', label: 'Note' }, + ]; + + it('exposes the initial values as the baseline', () => { + const form = useDynamicForm({ + fields: baselineFields, + initialValues: { title: 'a', note: 'n' }, + }); + expect(form.baselineValues.value).toEqual({ title: 'a', note: 'n' }); + expect(form.getDirtyValues()).toEqual({}); + }); + + it('reports only the changed entries as dirty', () => { + const form = useDynamicForm({ + fields: baselineFields, + initialValues: { title: 'a', note: 'n' }, + }); + form.setFieldValue('title', 'b'); + expect(form.getDirtyValues()).toEqual({ title: 'b' }); + }); + + it('re-bases the baseline on reset(newValues)', () => { + const form = useDynamicForm({ + fields: baselineFields, + initialValues: { title: 'a', note: 'n' }, + }); + form.setFieldValue('title', 'b'); + form.reset({ title: 'c', note: 'n' }); + + expect(form.baselineValues.value).toEqual({ title: 'c', note: 'n' }); + expect(form.getDirtyValues()).toEqual({}); + }); + + it('restores the original baseline on a bare reset()', () => { + const form = useDynamicForm({ + fields: baselineFields, + initialValues: { title: 'a', note: 'n' }, + }); + form.reset({ title: 'c', note: 'n' }); + form.reset(); + expect(form.baselineValues.value).toEqual({ title: 'a', note: 'n' }); + }); +}); From 422197b886377f461e44d20a146a5020d4d59f32 Mon Sep 17 00:00:00 2001 From: Van Nguyen Date: Fri, 4 Sep 2026 20:55:05 +0700 Subject: [PATCH 05/31] fix(vue): re-base per-field dirty on reset and late-arriving values Matches the React adapter: the baseline was a const snapshot taken in setup() and never reassigned. It now tracks the first non-undefined properties, or comes from initialProperties / form.baselineValues. The first-seen tracker is a ref rather than a plain binding because the baseline is a computed, which only re-evaluates on reactive reads. --- .../vue/src/components/MultiFieldInput.ts | 44 ++++++++++++-- packages/vue/test/MultiFieldInput.test.ts | 60 +++++++++++++++++++ 2 files changed, 99 insertions(+), 5 deletions(-) diff --git a/packages/vue/src/components/MultiFieldInput.ts b/packages/vue/src/components/MultiFieldInput.ts index 7c6654b..5c93f77 100644 --- a/packages/vue/src/components/MultiFieldInput.ts +++ b/packages/vue/src/components/MultiFieldInput.ts @@ -17,6 +17,7 @@ import { h, PropType, reactive, + ref, unref, watch, } from 'vue'; @@ -32,6 +33,8 @@ export interface DynamicFormBinding { data: Ref | Properties; errors: Ref> | Record; touched: Ref> | Record; + /** The values per-field `dirty` is measured against. See `useDynamicForm`. */ + baselineValues?: Ref | Properties; handleChange: (data: Properties) => void; handleBlur: (fieldName: string) => void; } @@ -89,6 +92,14 @@ const MultiFieldInput = /* @__PURE__ */ defineComponent({ type: Object as PropType, default: undefined, }, + // The values per-field `dirty` is measured against. Defaults to the first + // non-undefined `properties` this component sees - what an edit form wants + // when its values arrive from a fetch after mount. Supplied automatically + // by the `form` shorthand. + initialProperties: { + type: Object as PropType, + default: undefined, + }, onChange: { type: Function as PropType<(data: Properties) => void>, default: undefined, @@ -204,10 +215,33 @@ const MultiFieldInput = /* @__PURE__ */ defineComponent({ } }; - // Snapshot of the values this form opened with, for the `dirty` flag. - const initialProperties: Properties = { - ...(effectiveProperties.value ?? {}), - }; + // Baseline for the `dirty` flag. Tracks the first non-undefined properties + // rather than `{}` at mount: values that arrive from a fetch after mount + // would otherwise mark every field dirty forever. Re-basing on every + // change is not an option - in controlled mode `form.data` gets a new + // identity on every keystroke, which would pin `dirty` to false instead. + // A ref, not a plain binding: `baseline` below is a computed, so it only + // re-evaluates when something it reads is reactive. + const firstSeenProperties = ref(undefined); + watch( + () => effectiveProperties.value, + (next) => { + if (firstSeenProperties.value === undefined && next !== undefined) { + firstSeenProperties.value = { ...next }; + } + }, + { immediate: true }, + ); + + const baseline = computed( + () => + props.initialProperties ?? + (props.form?.baselineValues !== undefined + ? unref(props.form.baselineValues) + : undefined) ?? + firstSeenProperties.value ?? + {}, + ); function handleBlurField(key: string) { if (controlledTouched.value === undefined) { @@ -440,7 +474,7 @@ const MultiFieldInput = /* @__PURE__ */ defineComponent({ idPrefix: effectiveIdPrefix.value, touched: Boolean(effectiveTouched.value[f.name]), errors: effectiveErrors.value, - dirty: data[f.name] !== initialProperties[f.name], + dirty: !Object.is(data[f.name], baseline.value[f.name]), onValueChangeField: handleValueChange, onBlurField: handleBlurField, }), diff --git a/packages/vue/test/MultiFieldInput.test.ts b/packages/vue/test/MultiFieldInput.test.ts index 5a7e8e2..c3dda05 100644 --- a/packages/vue/test/MultiFieldInput.test.ts +++ b/packages/vue/test/MultiFieldInput.test.ts @@ -261,3 +261,63 @@ describe('MultiFieldInput', () => { expect(wrapper.findAll('input')[1].element.value).toBe('Hello Ada'); }); }); + +describe('dirty baseline', () => { + const dirtyProbeRenderer = { + props: ['value', 'dirty', 'id'], + template: + '', + }; + + const dirtyFields: FieldDescription[] = [ + { name: 'title', type: 'text', label: 'Title' }, + ]; + + it('is not dirty when properties arrive after mount', async () => { + const wrapper = mountMulti( + { + fieldDescriptions: dirtyFields, + properties: undefined, + idPrefix: 'late', + }, + dirtyProbeRenderer, + ); + + await wrapper.setProps({ properties: { title: 'loaded' } }); + + const input = wrapper.get('[data-testid="late-title"]'); + expect((input.element as HTMLInputElement).value).toBe('loaded'); + expect(input.attributes('data-dirty')).toBe('false'); + }); + + it('honours an explicit initialProperties baseline', () => { + const wrapper = mountMulti( + { + fieldDescriptions: dirtyFields, + properties: { title: 'edited' }, + initialProperties: { title: 'original' }, + idPrefix: 'explicit', + }, + dirtyProbeRenderer, + ); + + expect( + wrapper.get('[data-testid="explicit-title"]').attributes('data-dirty'), + ).toBe('true'); + }); + + it('is not dirty against its own opening values', () => { + const wrapper = mountMulti( + { + fieldDescriptions: dirtyFields, + properties: { title: 'original' }, + idPrefix: 'same', + }, + dirtyProbeRenderer, + ); + + expect( + wrapper.get('[data-testid="same-title"]').attributes('data-dirty'), + ).toBe('false'); + }); +}); From 72e9c5e9299f57485e26f5fb72cea1a284ca6cbb Mon Sep 17 00:00:00 2001 From: Van Nguyen Date: Fri, 4 Sep 2026 20:56:38 +0700 Subject: [PATCH 06/31] feat(angular): expose baselineValues and getDirtyValues from the form store Mirrors the React and Vue adapters. --- .../angular/src/lib/dynamic-form.store.ts | 19 ++++++++ .../angular/test/dynamicFormStore.spec.ts | 47 +++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/packages/angular/src/lib/dynamic-form.store.ts b/packages/angular/src/lib/dynamic-form.store.ts index f9c1bd7..61d832c 100644 --- a/packages/angular/src/lib/dynamic-form.store.ts +++ b/packages/angular/src/lib/dynamic-form.store.ts @@ -23,6 +23,10 @@ export function createDynamicFormStore(options: DynamicFormOptions) { const validateOnChange = options.validateOnChange ?? false; const data = signal(applyComputedValues(fields, initialValues)); + // The baseline `dirty` is measured against: the initialValues option until + // reset(newValues) replaces it. Distinct from that option, which never + // changes. See the React adapter for the full rationale. + const baselineValues = signal({ ...data() }); const errors = signal>({}); const isDirty = signal(false); const touched = signal>({}); @@ -120,6 +124,18 @@ export function createDynamicFormStore(options: DynamicFormOptions) { } /** Clears the touched map without touching data, errors or dirty state. */ + function getDirtyValues(): Properties { + const baseline = baselineValues(); + const current = data(); + const dirty: Properties = {}; + for (const key of Object.keys(current)) { + if (!Object.is(current[key], baseline[key])) { + dirty[key] = current[key]; + } + } + return dirty; + } + function resetTouched() { touched.set({}); } @@ -137,6 +153,7 @@ export function createDynamicFormStore(options: DynamicFormOptions) { const seed = newValues ?? initialValues; const next = applyComputedValues(fields, seed); data.set(next); + baselineValues.set({ ...next }); errors.set({}); isDirty.set(false); touched.set({}); @@ -219,6 +236,8 @@ export function createDynamicFormStore(options: DynamicFormOptions) { isValidationComplete, validationStatus, isDirty, + baselineValues, + getDirtyValues, touched, isSubmitting, isSubmitted, diff --git a/packages/angular/test/dynamicFormStore.spec.ts b/packages/angular/test/dynamicFormStore.spec.ts index 7cd174c..9ebdf65 100644 --- a/packages/angular/test/dynamicFormStore.spec.ts +++ b/packages/angular/test/dynamicFormStore.spec.ts @@ -258,3 +258,50 @@ describe('Angular Signal DynamicFormStore', () => { expect(store.isSubmitted()).toBe(false); }); }); + +describe('baselineValues and getDirtyValues', () => { + const baselineFields: FieldDescription[] = [ + { name: 'title', type: 'text', label: 'Title' }, + { name: 'note', type: 'text', label: 'Note' }, + ]; + + it('exposes the initial values as the baseline', () => { + const store = createDynamicFormStore({ + fields: baselineFields, + initialValues: { title: 'a', note: 'n' }, + }); + expect(store.baselineValues()).toEqual({ title: 'a', note: 'n' }); + expect(store.getDirtyValues()).toEqual({}); + }); + + it('reports only the changed entries as dirty', () => { + const store = createDynamicFormStore({ + fields: baselineFields, + initialValues: { title: 'a', note: 'n' }, + }); + store.setFieldValue('title', 'b'); + expect(store.getDirtyValues()).toEqual({ title: 'b' }); + }); + + it('re-bases the baseline on reset(newValues)', () => { + const store = createDynamicFormStore({ + fields: baselineFields, + initialValues: { title: 'a', note: 'n' }, + }); + store.setFieldValue('title', 'b'); + store.reset({ title: 'c', note: 'n' }); + + expect(store.baselineValues()).toEqual({ title: 'c', note: 'n' }); + expect(store.getDirtyValues()).toEqual({}); + }); + + it('restores the original baseline on a bare reset()', () => { + const store = createDynamicFormStore({ + fields: baselineFields, + initialValues: { title: 'a', note: 'n' }, + }); + store.reset({ title: 'c', note: 'n' }); + store.reset(); + expect(store.baselineValues()).toEqual({ title: 'a', note: 'n' }); + }); +}); From 7732a07e3c787c966c6b94823afb275b3cf3a09e Mon Sep 17 00:00:00 2001 From: Van Nguyen Date: Fri, 4 Sep 2026 20:57:44 +0700 Subject: [PATCH 07/31] fix(angular): let the dirty baseline be re-based after a reset Completes the three-adapter fix. This adapter turned out to be the least affected: init() only records a baseline when properties is set, so values arriving after mount were already handled correctly - the late-load test added here passed before the fix. What was broken is that the initialised guard pinned the baseline permanently, so a store reset could never move it. The new initialProperties input is that escape hatch; pass store.baselineValues() into it. The private field is renamed to firstSeenProperties to free the name for the input, matching React and Vue. --- .../angular/src/components/MultiFieldInput.ts | 14 ++++-- packages/angular/test/MultiFieldInput.spec.ts | 46 +++++++++++++++++++ 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/packages/angular/src/components/MultiFieldInput.ts b/packages/angular/src/components/MultiFieldInput.ts index 8c06392..de45156 100644 --- a/packages/angular/src/components/MultiFieldInput.ts +++ b/packages/angular/src/components/MultiFieldInput.ts @@ -136,6 +136,13 @@ function nextInstanceId(): number { export class MultiFieldInput implements OnInit, OnChanges { @Input() fieldDescriptions: FieldDescription[] = []; @Input() properties?: Properties; + /** + * The values per-field `dirty` is measured against. Defaults to the first + * non-`undefined` `properties` this component sees. This adapter has no + * `form` shorthand, so pass `store.baselineValues()` here to keep `dirty` + * correct across `store.reset(newValues)`. + */ + @Input() initialProperties?: Properties; @Output() onChange = new EventEmitter(); @Output() validityChange = new EventEmitter(); /** @@ -171,7 +178,7 @@ export class MultiFieldInput implements OnInit, OnChanges { // component is client-rendered by the time ids matter, so a module counter // is enough. private readonly instanceId = nextInstanceId(); - private initialProperties: Properties = {}; + private firstSeenProperties: Properties = {}; private indexedErrorsSource?: Record; private indexedErrors = new Map< string, @@ -208,7 +215,8 @@ export class MultiFieldInput implements OnInit, OnChanges { /** Whether this field's value differs from the one the form opened with. */ isFieldDirty(fieldName: string): boolean { - return this.data[fieldName] !== this.initialProperties[fieldName]; + const baseline = this.initialProperties ?? this.firstSeenProperties; + return !Object.is(this.data[fieldName], baseline[fieldName]); } fieldErrors(fieldName: string): string[] | undefined { @@ -391,7 +399,7 @@ export class MultiFieldInput implements OnInit, OnChanges { // Baseline for the `dirty` flag: the values the form opened with, not // whatever `properties` happens to hold after later edits. if (!this.initialised) { - this.initialProperties = { ...this.properties }; + this.firstSeenProperties = { ...this.properties }; this.initialised = true; } } diff --git a/packages/angular/test/MultiFieldInput.spec.ts b/packages/angular/test/MultiFieldInput.spec.ts index 3b6a66c..123d3aa 100644 --- a/packages/angular/test/MultiFieldInput.spec.ts +++ b/packages/angular/test/MultiFieldInput.spec.ts @@ -261,3 +261,49 @@ describe('MultiFieldInput', () => { expect(container.style.gridTemplateColumns).toBe('repeat(3, 1fr)'); }); }); + +describe('MultiFieldInput dirty baseline', () => { + let registry: ReturnType; + + beforeEach(() => { + registry = makeRegistry(); + registry.register('text', TextRendererComponent as never); + TestBed.configureTestingModule({ + imports: [MultiFieldInput], + providers: [{ provide: FIELD_REGISTRY, useValue: registry }], + }); + }); + + const dirtyFields: FieldDescription[] = [{ name: 'title', type: 'text' }]; + + it('is not dirty when properties arrive after mount', () => { + const fixture = TestBed.createComponent(MultiFieldInput); + fixture.componentRef.setInput('fieldDescriptions', dirtyFields); + fixture.componentRef.setInput('properties', undefined); + fixture.detectChanges(); + + fixture.componentRef.setInput('properties', { title: 'loaded' }); + fixture.detectChanges(); + + expect(fixture.componentInstance.isFieldDirty('title')).toBe(false); + }); + + it('honours an explicit initialProperties baseline', () => { + const fixture = TestBed.createComponent(MultiFieldInput); + fixture.componentRef.setInput('fieldDescriptions', dirtyFields); + fixture.componentRef.setInput('properties', { title: 'edited' }); + fixture.componentRef.setInput('initialProperties', { title: 'original' }); + fixture.detectChanges(); + + expect(fixture.componentInstance.isFieldDirty('title')).toBe(true); + }); + + it('is not dirty against its own opening values', () => { + const fixture = TestBed.createComponent(MultiFieldInput); + fixture.componentRef.setInput('fieldDescriptions', dirtyFields); + fixture.componentRef.setInput('properties', { title: 'original' }); + fixture.detectChanges(); + + expect(fixture.componentInstance.isFieldDirty('title')).toBe(false); + }); +}); From 76b4a16f78da6a2bfcd6b311b6f7ebce64c9f080 Mon Sep 17 00:00:00 2001 From: Van Nguyen Date: Fri, 4 Sep 2026 20:59:32 +0700 Subject: [PATCH 08/31] docs: add migration guide and changeset for the 1.7.0 form-state fixes --- .changeset/great-pugs-repeat.md | 18 ++++++++ docs/MIGRATING.md | 75 +++++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+) create mode 100644 .changeset/great-pugs-repeat.md create mode 100644 docs/MIGRATING.md diff --git a/.changeset/great-pugs-repeat.md b/.changeset/great-pugs-repeat.md new file mode 100644 index 0000000..37cee48 --- /dev/null +++ b/.changeset/great-pugs-repeat.md @@ -0,0 +1,18 @@ +--- +'@dynamic-field-kit/core': minor +'@dynamic-field-kit/react': minor +'@dynamic-field-kit/vue': minor +'@dynamic-field-kit/angular': minor +--- + +Fix per-field `dirty`, which was measured against a baseline captured at mount +and never re-based - wrong after `reset(newValues)` on all three adapters, and +wrong on React and Vue for values that arrive after mount, where every field +reported dirty forever. + +Adds `baselineValues` and `getDirtyValues()` to the form store on all three +adapters, and an `initialProperties` prop to `MultiFieldInput` for re-basing +without a store. Comparison moves from `!==` to `Object.is`, so a `NaN` numeric +field no longer reads as permanently dirty. + +React's `useDynamicForm` no longer validates the same data twice per change. diff --git a/docs/MIGRATING.md b/docs/MIGRATING.md new file mode 100644 index 0000000..1073d54 --- /dev/null +++ b/docs/MIGRATING.md @@ -0,0 +1,75 @@ +# Migrating + +Version-by-version tables of what changed and what to do about it. For the full +list of changes see each package's `CHANGELOG.md`. + +## 1.6.x → 1.7.0 + +Everything here is additive or a bugfix. No API was removed, and no peer range +moved. + +### Per-field `dirty` is now correct after a reset, and after a late load + +`dirty` was measured against a baseline captured once at mount and never +reassigned. Two cases were wrong: + +- after `reset(newValues)` every field compared against the **pre-reset** + values, so fields the reset had just changed read as clean and fields it left + alone read as dirty; +- on React and Vue, when `properties` arrived from a fetch **after** mount (the + normal shape of an edit form) the baseline was `{}`, so every field reported + `dirty: true` forever. + +The baseline now comes from whoever owns the values. + +| Before | After | +| ---------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | +| `` — baseline frozen at mount, `{}` if `data` was not ready yet | unchanged call; the baseline is the first non-`undefined` `properties` | +| no way to re-base the baseline | `` | +| `form={form}` — baseline frozen at mount | unchanged call; the baseline follows `form.reset(newValues)` | + +**No change is required.** If you worked around this by remounting the form with +a changing `key`, you can drop the workaround. + +Angular is the exception: it never had the late-load bug, because its `init()` +only records a baseline once `properties` is actually set. It did have the reset +bug. Since the Angular adapter has no `form` shorthand, pass the store's +baseline in explicitly: + +```html + +``` + +### `getDirtyValues()` + +For PATCH-style submits that should carry only what the user actually edited. + +| Before | After | +| ----------------------------------------------------------------------------------------------------- | --------------------------------------- | +| `const changed = Object.fromEntries(Object.entries(form.data).filter(([k, v]) => v !== original[k]))` | `const changed = form.getDirtyValues()` | + +Keys present in the data but absent from the baseline count as dirty. Comparison +is by `Object.is`, so a `NaN` numeric field does not read as permanently dirty. + +### `baselineValues` + +The values `dirty` is measured against, now exposed on the form store. React +returns a plain object, Vue a `Ref`, Angular a `Signal`. + +This is **not** the same as the `initialValues` option: that option never +changes, while the baseline is replaced by `reset(newValues)`. They differ as +soon as a form is reset. + +### React only: one fewer validation pass per keystroke + +`useDynamicForm` validated the same data twice on every change — once eagerly in +`handleChange`, once again in an effect after commit. It now validates once. + +Synchronous validators are pure by contract, so this is invisible unless you +were counting calls in a test or relying on a side effect inside a validator. +Vue and Angular were never affected; their stores run no watch or effect and +already validated exactly once. From a4b18ed32ddad8c0d2d9b03a727092767d6c2fe9 Mon Sep 17 00:00:00 2001 From: Van Nguyen Date: Fri, 4 Sep 2026 21:03:50 +0700 Subject: [PATCH 09/31] fix(core): make ariaDescribedBy point at a real error node id It was hard-coded undefined, so focusFirstInvalidField - which selects [aria-invalid=true] - had nothing to find for consumers whose renderers followed the official recipe. makeErrorId defines the convention once, in one place, for all three adapters. --- packages/core/src/rendererProps.ts | 21 ++++++++--- packages/core/test/rendererProps.test.ts | 46 ++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 5 deletions(-) diff --git a/packages/core/src/rendererProps.ts b/packages/core/src/rendererProps.ts index 9fb0db4..97f6336 100644 --- a/packages/core/src/rendererProps.ts +++ b/packages/core/src/rendererProps.ts @@ -85,6 +85,17 @@ export function makeFieldId( return fieldDescription.id ?? `${prefix}-${fieldDescription.name}`; } +/** + * The id of the node that renders a field's validation message. + * + * `ariaDescribedBy` points here, so a renderer that forwards it must put this + * id on whatever element shows the error - otherwise the reference dangles and + * assistive technology has nothing to read. + */ +export function makeErrorId(id: string): string { + return `${id}-error`; +} + /** * Builds the complete renderer prop bag for one field. Shared by the React, * Vue and Angular adapters so all three forward an identical set. @@ -142,11 +153,11 @@ export function buildFieldRendererProps({ description, id, ariaInvalid: Boolean(error), - // Left undefined on purpose: the adapters do not render the description or - // error node themselves, so pointing aria-describedby at an id that may not - // exist would be worse than omitting it. A renderer that does render those - // nodes can set it from `id`. - ariaDescribedBy: undefined, + // Points at the error node, but only when there is an error to point at. + // The adapters render that node for default renderers; a custom renderer + // that forwards this prop must put `makeErrorId(id)` on its own message + // element, or the reference dangles. + ariaDescribedBy: error ? makeErrorId(id) : undefined, ariaRequired: Boolean(required), min, max, diff --git a/packages/core/test/rendererProps.test.ts b/packages/core/test/rendererProps.test.ts index b505a8b..d335559 100644 --- a/packages/core/test/rendererProps.test.ts +++ b/packages/core/test/rendererProps.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest'; import { buildFieldRendererProps, FIELD_RENDERER_PROP_KEYS, + makeErrorId, makeFieldId, } from '../src/rendererProps'; import type { FieldDescription } from '../src/types'; @@ -149,3 +150,48 @@ describe('buildFieldRendererProps', () => { } }); }); + +describe('ariaDescribedBy', () => { + it('points at the error node id when the field has an error', () => { + const props = buildFieldRendererProps({ + fieldDescription: { + name: 'title', + type: 'text', + required: true, + validate: () => 'Required', + }, + data: { title: '' }, + id: 'form-title', + }); + + expect(props.ariaInvalid).toBe(true); + expect(props.ariaDescribedBy).toBe('form-title-error'); + expect(props.ariaDescribedBy).toBe(makeErrorId('form-title')); + }); + + it('is undefined when the field is valid', () => { + const props = buildFieldRendererProps({ + fieldDescription: { name: 'title', type: 'text' }, + data: { title: 'ok' }, + id: 'form-title', + }); + + expect(props.ariaInvalid).toBe(false); + expect(props.ariaDescribedBy).toBeUndefined(); + }); + + it('is undefined for a disabled field, which is never validated', () => { + const props = buildFieldRendererProps({ + fieldDescription: { + name: 'title', + type: 'text', + disabled: true, + validate: () => 'Required', + }, + data: { title: '' }, + id: 'form-title', + }); + + expect(props.ariaDescribedBy).toBeUndefined(); + }); +}); From d54837a9c734458d8db2fe01212652158892d286 Mon Sep 17 00:00:00 2001 From: Van Nguyen Date: Fri, 4 Sep 2026 21:04:39 +0700 Subject: [PATCH 10/31] feat(core): warn in dev when props shadows a renderer contract key 1.6.0 moved placeholder, min, max, step, accept and multiple to the top level of FieldDescription. Values left behind in props are overwritten by the resolved contract and vanish with no throw and no warning - the one upgrade hazard a consumer cannot diagnose from the outside. Fires once per field+key and only outside production. --- packages/core/src/rendererProps.ts | 55 ++++++++++++++++ packages/core/test/rendererProps.test.ts | 80 +++++++++++++++++++++++- 2 files changed, 134 insertions(+), 1 deletion(-) diff --git a/packages/core/src/rendererProps.ts b/packages/core/src/rendererProps.ts index 97f6336..1cf8079 100644 --- a/packages/core/src/rendererProps.ts +++ b/packages/core/src/rendererProps.ts @@ -43,6 +43,59 @@ export const FIELD_RENDERER_PROP_KEYS = [ export type FieldRendererPropKey = (typeof FIELD_RENDERER_PROP_KEYS)[number]; +function isDev(): boolean { + return ( + typeof process !== 'undefined' && + !!process.env && + process.env.NODE_ENV !== 'production' + ); +} + +const RESERVED_PROP_KEYS: ReadonlySet = new Set( + FIELD_RENDERER_PROP_KEYS, +); +const warnedReservedProps = new Set(); + +/** Test-only. Clears the warn-once memo so each case starts from silence. */ +export function __resetReservedPropWarnings(): void { + warnedReservedProps.clear(); +} + +/** + * `props` is spread *before* the resolved contract in every adapter, so a key + * the contract owns is silently overwritten - usually by `undefined`, which is + * indistinguishable from the value simply vanishing. Nothing throws, so this + * warning is the only signal a consumer gets. + * + * Fires once per field+key: a form re-renders constantly, and a console filled + * with the same line is a console nobody reads. + */ +function warnOnReservedProps( + fieldName: string, + extraProps: Properties | undefined, +): void { + if (!isDev() || !extraProps) { + return; + } + for (const key of Object.keys(extraProps)) { + if (!RESERVED_PROP_KEYS.has(key)) { + continue; + } + const memo = `${fieldName}.${key}`; + if (warnedReservedProps.has(memo)) { + continue; + } + warnedReservedProps.add(memo); + console.warn( + `[dynamic-field-kit] field "${fieldName}" passes "${key}" through ` + + `\`props\`, but "${key}" is part of the renderer prop contract and is ` + + `resolved from the field description itself, so the value in \`props\` ` + + `is discarded. Move it to the top level: ` + + `{ name: "${fieldName}", ${key}: ... }.`, + ); + } +} + /** * A fully resolved renderer prop bag, plus the two keys the adapter layer needs * but the renderer never sees as-is: `type` (which renderer to look up) and @@ -125,6 +178,8 @@ export function buildFieldRendererProps({ props: extraProps, } = fieldDescription; + warnOnReservedProps(name, extraProps); + const disabled = resolveDisabled(fieldDescription, data, rootData); const readOnly = resolveReadOnly(fieldDescription, data, rootData); const options = resolveOptions(fieldDescription, data, rootData); diff --git a/packages/core/test/rendererProps.test.ts b/packages/core/test/rendererProps.test.ts index d335559..af39dd1 100644 --- a/packages/core/test/rendererProps.test.ts +++ b/packages/core/test/rendererProps.test.ts @@ -1,5 +1,6 @@ -import { describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { + __resetReservedPropWarnings, buildFieldRendererProps, FIELD_RENDERER_PROP_KEYS, makeErrorId, @@ -195,3 +196,80 @@ describe('ariaDescribedBy', () => { expect(props.ariaDescribedBy).toBeUndefined(); }); }); + +describe('reserved props warning', () => { + beforeEach(() => { + __resetReservedPropWarnings(); + }); + + it('warns when props carries a key the contract owns', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + buildFieldRendererProps({ + fieldDescription: { + name: 'title', + type: 'text', + props: { placeholder: 'from props' }, + }, + data: {}, + id: 'form-title', + }); + + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0][0]).toContain('placeholder'); + expect(warn.mock.calls[0][0]).toContain('title'); + warn.mockRestore(); + }); + + it('warns only once for the same field and key', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const fieldDescription: FieldDescription = { + name: 'title', + type: 'text', + props: { placeholder: 'from props' }, + }; + + buildFieldRendererProps({ fieldDescription, data: {}, id: 'a' }); + buildFieldRendererProps({ fieldDescription, data: {}, id: 'a' }); + + expect(warn).toHaveBeenCalledTimes(1); + warn.mockRestore(); + }); + + it('stays silent for props keys the contract does not own', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + buildFieldRendererProps({ + fieldDescription: { + name: 'title', + type: 'text', + props: { maxLength: 10, acceptFile: 'x' }, + }, + data: {}, + id: 'form-title', + }); + + expect(warn).not.toHaveBeenCalled(); + warn.mockRestore(); + }); + + it('stays silent in production', () => { + const previous = process.env.NODE_ENV; + process.env.NODE_ENV = 'production'; + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + buildFieldRendererProps({ + fieldDescription: { + name: 'title', + type: 'text', + props: { placeholder: 'from props' }, + }, + data: {}, + id: 'form-title', + }); + + expect(warn).not.toHaveBeenCalled(); + warn.mockRestore(); + process.env.NODE_ENV = previous; + }); +}); From e433212b05b0b68db425009365cc15ad89e6efee Mon Sep 17 00:00:00 2001 From: Van Nguyen Date: Fri, 4 Sep 2026 21:05:51 +0700 Subject: [PATCH 11/31] feat(react): render a validation message for default renderers The default renderers forwarded aria-describedby but never rendered the error they were handed, so the reference had nothing to point at. Emitted as a fragment sibling - no wrapper element, so layout is unchanged - and only where no custom renderer is registered, so consumers rendering their own message do not get a second copy. --- .../react/src/components/DynamicInput.tsx | 38 +++++++++--- packages/react/test/defaultRenderers.test.tsx | 59 +++++++++++++++++++ 2 files changed, 90 insertions(+), 7 deletions(-) diff --git a/packages/react/src/components/DynamicInput.tsx b/packages/react/src/components/DynamicInput.tsx index 2c20d94..71f7e2a 100644 --- a/packages/react/src/components/DynamicInput.tsx +++ b/packages/react/src/components/DynamicInput.tsx @@ -1,6 +1,7 @@ import { FieldRendererProps, FieldTypeKey, + makeErrorId, Properties, } from '@dynamic-field-kit/core'; import React, { ReactNode, useMemo } from 'react'; @@ -45,28 +46,51 @@ const DynamicInputInner = ({ }: Props) => { const registry = useFieldRegistry(); - // Memoize renderer lookup to avoid unnecessary work on re-renders - const Renderer = useMemo( - () => - ((registry.get(type) as React.ComponentType) || + // Memoize renderer lookup to avoid unnecessary work on re-renders. Whether + // it fell back to a default is tracked too: a custom renderer owns its own + // error presentation, and emitting a second message would duplicate it. + const { Renderer, isDefault } = useMemo(() => { + const registered = registry.get(type) as + React.ComponentType | undefined; + return { + Renderer: (registered ?? getDefaultRenderer(type)) as React.ComponentType, - [registry, type], - ); + isDefault: !registered, + }; + }, [registry, type]); if (!Renderer) { return
Unknown field type: {type}
; } + const { error, id } = rendererProps as FieldRendererProps; + // Spread rather than re-listing each prop: the set is fixed by core's // FIELD_RENDERER_PROP_KEYS contract, and a hand-maintained list here is // exactly how `placeholder`, `min`, `max`, `step`, `accept` and `multiple` // came to be silently dropped on their way to the renderer. - return React.createElement(Renderer, { + const control = React.createElement(Renderer, { ...extraProps, ...(rendererProps as FieldRendererProps), onValueChange: onChange, onBlur, }); + + if (!isDefault || !error?.length || !id) { + return control; + } + + // A fragment, not a wrapper element: the message appears, but nothing about + // the surrounding layout changes. The id is what `ariaDescribedBy` targets - + // without this node that reference would dangle. + return ( + <> + {control} + + + ); }; // Skip re-render when none of the rendered props actually changed, so diff --git a/packages/react/test/defaultRenderers.test.tsx b/packages/react/test/defaultRenderers.test.tsx index 934edea..8d06def 100644 --- a/packages/react/test/defaultRenderers.test.tsx +++ b/packages/react/test/defaultRenderers.test.tsx @@ -1,3 +1,8 @@ +import { + FieldRegistry, + makeErrorId, + type FieldRendererProps, +} from '@dynamic-field-kit/core'; import { render, screen, fireEvent } from '@testing-library/react'; import React from 'react'; import { describe, expect, it, vi } from 'vitest'; @@ -7,6 +12,7 @@ import { DefaultEmailRenderer, DefaultTextareaRenderer, } from '../src/defaultRenderers'; +import { FieldRegistryProvider } from '../src/FieldRegistryContext'; describe('React Default Built-in Renderers', () => { it('renders default text input when type is "text" without custom registration', () => { @@ -139,3 +145,56 @@ describe('React Default Built-in Renderers', () => { expect(screen.getByDisplayValue('pass')).toBeInTheDocument(); }); }); + +describe('default renderer error node', () => { + it('renders the message with the id ariaDescribedBy points at', () => { + render( + {}} + />, + ); + + const input = screen.getByRole('textbox'); + const described = input.getAttribute('aria-describedby'); + expect(described).toBe('f-title-error'); + expect(document.getElementById(described!)).toHaveTextContent( + 'Title is required', + ); + }); + + it('renders nothing extra when the field is valid', () => { + render( + {}} />, + ); + + expect(document.getElementById('f-ok-error')).toBeNull(); + }); + + it('leaves a custom renderer to render its own message', () => { + const registry = new FieldRegistry(); + registry.register('text', (({ error }: FieldRendererProps) => ( + {error?.[0]} + )) as unknown as never); + + render( + + {}} + /> + , + ); + + expect(screen.getByTestId('custom')).toHaveTextContent('Boom'); + expect(document.getElementById('f-custom-error')).toBeNull(); + }); +}); From 6426cedacbe52cc845044966b3b8bd8fde70bbfa Mon Sep 17 00:00:00 2001 From: Van Nguyen Date: Fri, 4 Sep 2026 21:07:11 +0700 Subject: [PATCH 12/31] feat(vue): render a validation message for default renderers Mirrors the React adapter's markup exactly, returned as a fragment array so no wrapper element appears. This adapter declares error as [String, Array], unlike React where core only ever supplies an array, so the message is normalised before use - indexing a raw string would have rendered its first character. --- packages/vue/src/components/DynamicInput.ts | 36 ++++++++++++++++-- packages/vue/test/defaultRenderers.test.ts | 42 +++++++++++++++++++++ 2 files changed, 75 insertions(+), 3 deletions(-) diff --git a/packages/vue/src/components/DynamicInput.ts b/packages/vue/src/components/DynamicInput.ts index 4441ea0..6576471 100644 --- a/packages/vue/src/components/DynamicInput.ts +++ b/packages/vue/src/components/DynamicInput.ts @@ -1,4 +1,4 @@ -import { FieldTypeKey, Properties } from '@dynamic-field-kit/core'; +import { FieldTypeKey, makeErrorId, Properties } from '@dynamic-field-kit/core'; import { defineComponent, computed, h, PropType } from 'vue'; import { getDefaultRenderer } from '../defaultRenderers'; import { useFieldRegistry } from '../fieldRegistryContext'; @@ -117,16 +117,20 @@ const DynamicInput = /* @__PURE__ */ defineComponent({ setup(props) { const registry = useFieldRegistry(); + const registered = computed(() => registry.get(props.type)); const Renderer = computed( - () => registry.get(props.type) || getDefaultRenderer(props.type), + () => registered.value || getDefaultRenderer(props.type), ); + // A custom renderer owns its own error presentation; emitting a second + // message alongside it would duplicate what the consumer already renders. + const isDefault = computed(() => !registered.value); return () => { if (!Renderer.value) { return h('div', `Unknown field type: ${props.type}`); } - return h(Renderer.value, { + const control = h(Renderer.value, { ...props.extraProps, value: props.value, // Both spellings: `onUpdate:value` is the Vue idiom the bundled @@ -162,6 +166,32 @@ const DynamicInput = /* @__PURE__ */ defineComponent({ accept: props.accept, multiple: props.multiple, }); + + // This adapter accepts `error` as a string as well as an array, so index + // 0 of a raw string would be its first character. + const firstError = Array.isArray(props.error) + ? props.error[0] + : props.error; + + if (!isDefault.value || !firstError || !props.id) { + return control; + } + + // An array, not a wrapper element: Vue renders it as a fragment, so the + // message appears without changing the surrounding layout. The id is + // what `ariaDescribedBy` targets. + return [ + control, + h( + 'div', + { + id: makeErrorId(props.id), + class: 'dfk-field-error', + role: 'alert', + }, + firstError, + ), + ]; }; }, }); diff --git a/packages/vue/test/defaultRenderers.test.ts b/packages/vue/test/defaultRenderers.test.ts index 57b0452..a9c5504 100644 --- a/packages/vue/test/defaultRenderers.test.ts +++ b/packages/vue/test/defaultRenderers.test.ts @@ -1,3 +1,4 @@ +import { makeErrorId } from '@dynamic-field-kit/core'; import { mount } from '@vue/test-utils'; import { describe, expect, it, vi } from 'vitest'; import DynamicInput from '../src/components/DynamicInput'; @@ -141,3 +142,44 @@ describe('Vue Default Built-in Renderers', () => { expect(area.exists()).toBe(true); }); }); + +describe('default renderer error node', () => { + it('renders the message with the id ariaDescribedBy points at', () => { + const wrapper = mount(DynamicInput, { + props: { + type: 'text', + id: 'f-title', + value: '', + error: ['Title is required'], + ariaInvalid: true, + ariaDescribedBy: makeErrorId('f-title'), + }, + }); + + const node = wrapper.find('#f-title-error'); + expect(node.exists()).toBe(true); + expect(node.text()).toContain('Title is required'); + }); + + it('renders nothing extra when the field is valid', () => { + const wrapper = mount(DynamicInput, { + props: { type: 'text', id: 'f-ok', value: 'x' }, + }); + expect(wrapper.find('#f-ok-error').exists()).toBe(false); + }); +}); + +describe('default renderer error node accepts a bare string', () => { + it('renders the whole string, not its first character', () => { + const wrapper = mount(DynamicInput, { + props: { + type: 'text', + id: 'f-str', + value: '', + error: 'Title is required', + }, + }); + + expect(wrapper.find('#f-str-error').text()).toBe('Title is required'); + }); +}); From 057280537816aa0f82bdd495fb52b3297a1a16a1 Mon Sep 17 00:00:00 2001 From: Van Nguyen Date: Fri, 4 Sep 2026 21:09:00 +0700 Subject: [PATCH 13/31] feat(angular): render a validation message for default renderers Completes the three-adapter error node, so aria-describedby resolves on every adapter instead of dangling. Two adapter-specific notes. The template uses *ngIf, not the @if block: the peer range starts at Angular 16 and block control flow is 17+. And the condition asks the registry directly rather than reading a flag set in render(), which runs in ngAfterViewInit - by then this template's bindings are already checked for the pass, and under OnPush nothing would mark them dirty again. --- .../angular/src/components/DynamicInput.ts | 39 +++++++++++- packages/angular/test/DynamicInput.spec.ts | 59 +++++++++++++++++++ 2 files changed, 96 insertions(+), 2 deletions(-) diff --git a/packages/angular/src/components/DynamicInput.ts b/packages/angular/src/components/DynamicInput.ts index 3627a85..9bc91d4 100644 --- a/packages/angular/src/components/DynamicInput.ts +++ b/packages/angular/src/components/DynamicInput.ts @@ -15,7 +15,7 @@ import { ViewChild, ViewContainerRef, } from '@angular/core'; -import { FieldTypeKey, Properties } from '@dynamic-field-kit/core'; +import { FieldTypeKey, makeErrorId, Properties } from '@dynamic-field-kit/core'; import { Subscription } from 'rxjs'; import { FIELD_REGISTRY } from '../fieldRegistryToken'; import { BaseInputComponent } from './BaseInput'; @@ -55,7 +55,17 @@ const KNOWN_PROPS = [ standalone: true, imports: [CommonModule], changeDetection: ChangeDetectionStrategy.OnPush, - template: `
`, + // *ngIf rather than @if: the peer range starts at Angular 16, and the + // built-in control flow block syntax is 17+. + template: `
+ `, }) export class DynamicInput extends BaseInputComponent @@ -140,6 +150,31 @@ export class DynamicInput this.inputInstance = undefined; } + /** The id `ariaDescribedBy` points at. See core's `makeErrorId`. */ + errorNodeId(): string { + return makeErrorId(this.id ?? ''); + } + + /** `error` may arrive as a bare string, so index 0 would be a character. */ + firstError(): string | undefined { + return Array.isArray(this.error) ? this.error[0] : this.error; + } + + /** + * Whether the adapter should render the validation message itself. + * + * Asks the registry directly rather than reading a flag set by `render()`: + * `render()` runs in `ngAfterViewInit`, by which point this template's + * bindings have already been checked for the pass, and under `OnPush` + * nothing would mark them dirty again. A registered renderer owns its own + * error presentation, so only the built-in fallback gets a message here. + */ + showDefaultError(): boolean { + return Boolean( + !this.registry.get(this.type) && this.firstError() && this.id, + ); + } + private render(): void { const Renderer = this.registry.get(this.type); this.cleanup(); diff --git a/packages/angular/test/DynamicInput.spec.ts b/packages/angular/test/DynamicInput.spec.ts index 0d70d66..67b28ee 100644 --- a/packages/angular/test/DynamicInput.spec.ts +++ b/packages/angular/test/DynamicInput.spec.ts @@ -341,3 +341,62 @@ describe('DynamicInput', () => { expect(seen).toEqual([]); }); }); + +describe('DynamicInput default renderer error node', () => { + let registry: ReturnType; + + beforeEach(() => { + // Deliberately empty: nothing registered for 'text', so DynamicInput falls + // back to its built-in HTML5 rendering. + registry = makeRegistry(); + TestBed.configureTestingModule({ + imports: [DynamicInput], + providers: [{ provide: FIELD_REGISTRY, useValue: registry }], + }); + }); + + it('renders the message with the id ariaDescribedBy points at', () => { + const fixture = TestBed.createComponent(DynamicInput); + fixture.componentRef.setInput('type', 'text'); + fixture.componentRef.setInput('id', 'f-title'); + fixture.componentRef.setInput('error', ['Title is required']); + fixture.detectChanges(); + + const node: HTMLElement | null = + fixture.nativeElement.querySelector('#f-title-error'); + expect(node).not.toBeNull(); + expect(node!.textContent).toContain('Title is required'); + }); + + it('renders nothing extra when the field is valid', () => { + const fixture = TestBed.createComponent(DynamicInput); + fixture.componentRef.setInput('type', 'text'); + fixture.componentRef.setInput('id', 'f-ok'); + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('#f-ok-error')).toBeNull(); + }); + + it('leaves a custom renderer to render its own message', () => { + registry.register('text', TextRendererComponent as never); + const fixture = TestBed.createComponent(DynamicInput); + fixture.componentRef.setInput('type', 'text'); + fixture.componentRef.setInput('id', 'f-custom'); + fixture.componentRef.setInput('error', ['Boom']); + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('#f-custom-error')).toBeNull(); + }); + + it('renders a bare string error whole, not its first character', () => { + const fixture = TestBed.createComponent(DynamicInput); + fixture.componentRef.setInput('type', 'text'); + fixture.componentRef.setInput('id', 'f-str'); + fixture.componentRef.setInput('error', 'Title is required'); + fixture.detectChanges(); + + const node: HTMLElement | null = + fixture.nativeElement.querySelector('#f-str-error'); + expect(node!.textContent?.trim()).toBe('Title is required'); + }); +}); From fb7afed01ee72566fd858374882b244975287689 Mon Sep 17 00:00:00 2001 From: Van Nguyen Date: Fri, 4 Sep 2026 21:11:28 +0700 Subject: [PATCH 14/31] docs: aria wiring recipe and 1.7.0 renderer-contract migration notes Also re-exports makeErrorId from all three adapters - check-docs-api- references caught that the recipe imported it from the react package, which did not have it. Angular re-exported none of the renderer-prop helpers, so it gains buildFieldRendererProps, makeFieldId and FIELD_RENDERER_PROP_KEYS alongside, matching react and vue. --- .changeset/olive-pumas-argue.md | 19 +++++++++++ docs/MIGRATING.md | 52 ++++++++++++++++++++++++++++++ docs/ui-kit-recipes.md | 52 ++++++++++++++++++++++++++++-- packages/angular/src/public-api.ts | 9 ++++++ packages/react/src/index.ts | 1 + packages/vue/src/index.ts | 1 + 6 files changed, 131 insertions(+), 3 deletions(-) create mode 100644 .changeset/olive-pumas-argue.md diff --git a/.changeset/olive-pumas-argue.md b/.changeset/olive-pumas-argue.md new file mode 100644 index 0000000..8c98386 --- /dev/null +++ b/.changeset/olive-pumas-argue.md @@ -0,0 +1,19 @@ +--- +'@dynamic-field-kit/core': minor +'@dynamic-field-kit/react': minor +'@dynamic-field-kit/vue': minor +'@dynamic-field-kit/angular': minor +--- + +`ariaDescribedBy` is now `${id}-error` when a field has an error instead of +being hard-coded `undefined`, and `makeErrorId` is exported so a custom renderer +can put the matching id on its message element. Without this, +`focusFirstInvalidField` had nothing to find for anyone following the official +renderer recipe. + +Default renderers now render the validation message they were already being +handed - the one visible change in this release. Custom renderers are untouched, +so nobody gets two copies of their own message. + +Development builds now warn when `FieldDescription.props` carries a key the +renderer prop contract owns, which 1.6.0 made possible to lose silently. diff --git a/docs/MIGRATING.md b/docs/MIGRATING.md index 1073d54..aaeed2e 100644 --- a/docs/MIGRATING.md +++ b/docs/MIGRATING.md @@ -73,3 +73,55 @@ Synchronous validators are pure by contract, so this is invisible unless you were counting calls in a test or relying on a side effect inside a validator. Vue and Angular were never affected; their stores run no watch or effect and already validated exactly once. + +### Default renderers now show validation messages + +**This is the one visible change in 1.7.0.** If a field uses the built-in +renderers — that is, you registered no renderer for its type — an invalid field +now renders + +```html + +``` + +as a sibling of the control. Previously the default renderers were handed +`error` and dropped it, so the form showed nothing at all. + +Custom renderers are **not** affected. The node is emitted only where a default +renderer was used, so nobody who renders their own message gets a second copy. + +The node ships with no styling beyond that class hook. To keep the old silence: + +```css +.dfk-field-error { + display: none; +} +``` + +### `ariaDescribedBy` now has a value + +It was hard-coded `undefined`. It is now `` `${id}-error` `` when the field has +an error, and `undefined` while it is valid. + +If your renderer forwards `aria-describedby`, put the matching id on your +message element. `makeErrorId(id)` is exported from core and re-exported by all +three adapters: + +| Before | After | +| ------------------------------------- | ----------------------------------------------------------- | +| `help={message}` | `help={{message}}` | +| `aria-describedby` always `undefined` | bind `props.ariaDescribedBy` directly — no need to clear it | + +This matters beyond screen readers: `focusFirstInvalidField` selects +`[aria-invalid="true"]`, so a renderer that never forwards `ariaInvalid` makes +that helper silently do nothing. See +[Forward the aria props](./ui-kit-recipes.md#forward-the-aria-props). + +### Dev-mode warning when `props` shadows the contract + +1.6.0 moved `placeholder`, `min`, `max`, `step`, `accept` and `multiple` to the +top level of `FieldDescription`. Values left behind in `props` were discarded +silently — no throw, no warning, the value just vanished. + +A development-only `console.warn` now names the field and the key, once per +pair. Production builds are unchanged and emit nothing. diff --git a/docs/ui-kit-recipes.md b/docs/ui-kit-recipes.md index d7da105..22692af 100644 --- a/docs/ui-kit-recipes.md +++ b/docs/ui-kit-recipes.md @@ -2,8 +2,12 @@ These recipes keep `dynamic-field-kit` in charge of values, touched state and validation while a UI kit owns presentation. The rule is the same everywhere: -display `error` only when `touched`, forward `id`, and call the supplied value -and blur callbacks. +display `error` only when `touched`, forward `id` **and the three aria props**, +put `makeErrorId(id)` on the element showing the message, and call the supplied +value and blur callbacks. + +See [Forward the aria props](#forward-the-aria-props) for why the aria half is +not optional polish. ## React + Ant Design @@ -11,6 +15,7 @@ and blur callbacks. import { Form, Input } from 'antd'; import { fieldRegistry, + makeErrorId, type FieldRendererProps, } from '@dynamic-field-kit/react'; @@ -24,7 +29,11 @@ function AntText(props: FieldRendererProps) { label={props.label} required={props.required} validateStatus={message ? 'error' : undefined} - help={message} + // The id is what `ariaDescribedBy` points at. Without it the reference + // dangles and a screen reader has nothing to read out. + help={ + message ? {message} : undefined + } > ) { disabled={props.disabled} readOnly={props.readOnly} status={message ? 'error' : undefined} + aria-invalid={props.ariaInvalid} + aria-required={props.ariaRequired} + aria-describedby={props.ariaDescribedBy} onChange={(event) => props.onValueChange?.(event.target.value)} onBlur={props.onBlur} /> @@ -208,3 +220,37 @@ For the UI, bind the three status members rather than `isValid` alone - Typing cancels a live run in flight, so a stale result never overwrites a newer one. A submit is not cancelled by typing: it validates the snapshot it was given and always calls `onValid` or `onInvalid`. + +## Forward the aria props + +`buildFieldRendererProps` computes `ariaInvalid`, `ariaRequired` and +`ariaDescribedBy` for every field, on every adapter. A custom renderer that +drops them throws that work away. + +This is not optional polish. `focusFirstInvalidField` — the helper for moving +focus to the first problem after a failed submit — selects + +```js +'[aria-invalid="true"], input:invalid, select:invalid, textarea:invalid'; +``` + +A renderer that never sets `aria-invalid` makes that helper **silently do +nothing**. It does not throw and it does not warn; the form simply fails to +submit and focus stays where it was. This was the single most common gap found +in real consumer renderers, in part because earlier versions of this page did +not mention the aria props at all. + +Three things to wire, in every renderer: + +| Prop | Where it goes | +| ----------------- | ------------------------------------------------------------------------------------------------------------- | +| `ariaInvalid` | `aria-invalid` on the focusable control | +| `ariaRequired` | `aria-required` on the focusable control | +| `ariaDescribedBy` | `aria-describedby` on the control, **and** `makeErrorId(id)` as the `id` of the element rendering the message | + +`ariaDescribedBy` is `undefined` while the field is valid and +`` `${id}-error` `` once it has an error, so binding it unconditionally is +correct — there is nothing to clear. + +If you use the built-in renderers you get all of this already; they render the +message node themselves. diff --git a/packages/angular/src/public-api.ts b/packages/angular/src/public-api.ts index f87cfc0..2e848b2 100644 --- a/packages/angular/src/public-api.ts +++ b/packages/angular/src/public-api.ts @@ -42,6 +42,15 @@ export type { ValidationContext, } from '@dynamic-field-kit/core'; +// Renderer prop contract helpers. `makeErrorId` in particular is what a custom +// renderer needs to put on its message element so `ariaDescribedBy` resolves. +export { + buildFieldRendererProps, + makeErrorId, + makeFieldId, + FIELD_RENDERER_PROP_KEYS, +} from '@dynamic-field-kit/core'; + // Scoped registry: provide FIELD_REGISTRY on a component/route to give that // subtree an isolated set of renderers. export { FIELD_REGISTRY } from './fieldRegistryToken'; diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index da76f1e..d844ef7 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -38,6 +38,7 @@ export { type FieldRendererProps, buildFieldRendererProps, makeFieldId, + makeErrorId, FIELD_RENDERER_PROP_KEYS, } from '@dynamic-field-kit/core'; export { diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts index cd7e25e..dcb644d 100644 --- a/packages/vue/src/index.ts +++ b/packages/vue/src/index.ts @@ -39,6 +39,7 @@ export { type ValidationContext, buildFieldRendererProps, makeFieldId, + makeErrorId, FIELD_RENDERER_PROP_KEYS, } from '@dynamic-field-kit/core'; From e7a44a142700d83b67b1c31db0927194971fd720 Mon Sep 17 00:00:00 2001 From: Van Nguyen Date: Fri, 4 Sep 2026 21:14:50 +0700 Subject: [PATCH 15/31] feat(core): add a message catalog and resolver Validation messages could only be set per field, per form, by passing a string to each validator - so translating a form meant touching every field description. t lives on the existing ValidationContext rather than a new parameter: FieldDescription.validate already takes that context as its fourth argument, so there was no free slot and no need to invent one. An async validator gets the resolver for free as a result. --- packages/core/src/index.ts | 1 + packages/core/src/messages.ts | 87 +++++++++++++++++++++++++++++ packages/core/src/types.ts | 11 ++++ packages/core/test/messages.test.ts | 80 ++++++++++++++++++++++++++ 4 files changed, 179 insertions(+) create mode 100644 packages/core/src/messages.ts create mode 100644 packages/core/test/messages.test.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 9cbd407..a8fdda1 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -22,3 +22,4 @@ export * from './wizard'; export * from './rendererProps'; export * from './pathMaps'; +export * from './messages'; diff --git a/packages/core/src/messages.ts b/packages/core/src/messages.ts new file mode 100644 index 0000000..ae77eab --- /dev/null +++ b/packages/core/src/messages.ts @@ -0,0 +1,87 @@ +import type { Properties, ValidationContext } from './types'; + +/** + * Default messages for the built-in validators, keyed by validator name. + * `{name}` placeholders are filled from the params each validator supplies - + * `{min}` for `minLength`/`min`, `{max}`, `{other}` for `matches`. + * + * No locale bundles ship with this library: supply your own catalog, and + * anything omitted falls through to the English default baked into the + * validator itself. + */ +export interface MessageCatalog { + required?: string; + email?: string; + minLength?: string; + maxLength?: string; + min?: string; + max?: string; + pattern?: string; + matches?: string; +} + +export type MessageResolver = ( + key: string, + params?: Properties, +) => string | undefined; + +function interpolate(template: string, params?: Properties): string { + if (!params) { + return template; + } + // An unknown placeholder is left verbatim rather than replaced with + // "undefined": a visible `{unit}` in the UI reads as a bug report, whereas + // the string "undefined" reads as a mystery. + return template.replace(/\{(\w+)\}/g, (match, key: string) => + Object.prototype.hasOwnProperty.call(params, key) + ? String(params[key]) + : match, + ); +} + +export function createMessageResolver( + catalog?: MessageCatalog, +): MessageResolver { + return (key, params) => { + const template = catalog?.[key as keyof MessageCatalog]; + return template === undefined ? undefined : interpolate(template, params); + }; +} + +let defaultMessages: MessageCatalog | undefined; + +/** + * A process-wide catalog, for code that calls `validateFields` directly and has + * nowhere to thread a context through. A per-form catalog passed to + * `useDynamicForm({ messages })` takes precedence over this. + */ +export function setDefaultMessages(catalog?: MessageCatalog): void { + defaultMessages = catalog; +} + +export function getDefaultMessages(): MessageCatalog | undefined { + return defaultMessages; +} + +/** + * Message precedence, in one place so every built-in validator agrees: + * an explicitly passed message, then this form's catalog, then the global + * catalog, then the validator's own English default. + */ +export function resolveMessage( + ctx: ValidationContext | undefined, + key: keyof MessageCatalog, + params: Properties | undefined, + fallback: string, + explicit?: string, +): string { + if (explicit !== undefined) { + return explicit; + } + const fromContext = ctx?.t?.(key, params); + if (fromContext !== undefined) { + return fromContext; + } + const fromGlobal = createMessageResolver(defaultMessages)(key, params); + return fromGlobal ?? fallback; +} diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 403b3c0..79ecde9 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -23,6 +23,17 @@ export type Properties = Record; export interface ValidationContext { /** Aborted when a newer validation run supersedes this one. */ signal?: AbortSignal; + /** + * Resolves a validator's message key against the catalog in effect for this + * form, returning undefined for a key the catalog omits so the validator + * falls back to its own default. Supplied by the adapters from + * `useDynamicForm({ messages })`. + * + * It lives here rather than in a parameter of its own because `validate` + * already receives this context as its fourth argument - one object carries + * both concerns, and an async validator gets the resolver for free. + */ + t?: (key: string, params?: Properties) => string | undefined; } export interface FieldRendererProps { diff --git a/packages/core/test/messages.test.ts b/packages/core/test/messages.test.ts new file mode 100644 index 0000000..9710238 --- /dev/null +++ b/packages/core/test/messages.test.ts @@ -0,0 +1,80 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { + createMessageResolver, + getDefaultMessages, + resolveMessage, + setDefaultMessages, +} from '../src/messages'; + +afterEach(() => setDefaultMessages(undefined)); + +describe('createMessageResolver', () => { + it('returns the catalog entry for a known key', () => { + const t = createMessageResolver({ required: 'Bắt buộc' }); + expect(t('required')).toBe('Bắt buộc'); + }); + + it('returns undefined for a key the catalog omits', () => { + const t = createMessageResolver({ required: 'Bắt buộc' }); + expect(t('email')).toBeUndefined(); + }); + + it('interpolates named params', () => { + const t = createMessageResolver({ minLength: 'Tối thiểu {min} ký tự' }); + expect(t('minLength', { min: 8 })).toBe('Tối thiểu 8 ký tự'); + }); + + it('leaves an unknown placeholder alone rather than printing undefined', () => { + const t = createMessageResolver({ minLength: 'At least {min} of {unit}' }); + expect(t('minLength', { min: 8 })).toBe('At least 8 of {unit}'); + }); + + it('with no catalog resolves nothing', () => { + const t = createMessageResolver(); + expect(t('required')).toBeUndefined(); + }); +}); + +describe('setDefaultMessages', () => { + it('is read back by getDefaultMessages', () => { + setDefaultMessages({ required: 'Global' }); + expect(getDefaultMessages()).toEqual({ required: 'Global' }); + }); + + it('is cleared by passing undefined', () => { + setDefaultMessages({ required: 'Global' }); + setDefaultMessages(undefined); + expect(getDefaultMessages()).toBeUndefined(); + }); +}); + +describe('resolveMessage precedence', () => { + it('prefers an explicitly passed message over everything', () => { + setDefaultMessages({ required: 'Global' }); + const ctx = { t: createMessageResolver({ required: 'Scoped' }) }; + expect( + resolveMessage(ctx, 'required', undefined, 'English', 'Explicit'), + ).toBe('Explicit'); + }); + + it('prefers the context resolver over the global default', () => { + setDefaultMessages({ required: 'Global' }); + const ctx = { t: createMessageResolver({ required: 'Scoped' }) }; + expect(resolveMessage(ctx, 'required', undefined, 'English')).toBe( + 'Scoped', + ); + }); + + it('falls back to the global default when the context has no resolver', () => { + setDefaultMessages({ required: 'Global' }); + expect(resolveMessage(undefined, 'required', undefined, 'English')).toBe( + 'Global', + ); + }); + + it('falls back to the hard-coded English when nothing is configured', () => { + expect(resolveMessage(undefined, 'required', undefined, 'English')).toBe( + 'English', + ); + }); +}); From a1942726d64982f9921232c38986a230e2226649 Mon Sep 17 00:00:00 2001 From: Van Nguyen Date: Fri, 4 Sep 2026 21:15:56 +0700 Subject: [PATCH 16/31] feat(core): resolve validator messages at validate time, add matches Each validator computed its message when the field description was built, so a catalog could never reach it. Resolution moves inside the returned closure, with an explicitly passed string still winning over any catalog - every existing call site behaves identically. validators.matches lands in the same change because its default message needs that machinery; every consumer was hand-writing the same (value, data) => value !== data.other for confirm-password fields. --- packages/core/src/validators.ts | 144 +++++++++++++++++++------- packages/core/test/validators.test.ts | 84 ++++++++++++++- 2 files changed, 191 insertions(+), 37 deletions(-) diff --git a/packages/core/src/validators.ts b/packages/core/src/validators.ts index 58feb71..c93fcbf 100644 --- a/packages/core/src/validators.ts +++ b/packages/core/src/validators.ts @@ -1,36 +1,52 @@ -import type { Properties } from './types'; +import { resolveMessage } from './messages'; +import type { Properties, ValidationContext } from './types'; export type ValidatorFn = ( value: unknown, data?: Properties, rootData?: Properties, + ctx?: ValidationContext, ) => string | undefined; +function isEmpty(value: unknown): boolean { + return value === undefined || value === null || value === ''; +} + export const validators = { /** Enforces that a value is non-empty (not undefined, null, empty string, or empty array). */ - required(message = 'Field is required'): ValidatorFn { - return (value: unknown) => { - if ( - value === undefined || - value === null || - value === '' || - (Array.isArray(value) && value.length === 0) - ) { - return message; + required(message?: string): ValidatorFn { + return (value, _data, _rootData, ctx) => { + if (isEmpty(value) || (Array.isArray(value) && value.length === 0)) { + // Resolved here, inside the closure, rather than when the field + // description is built: a catalog supplied to the form could never + // reach a message baked in at definition time. + return resolveMessage( + ctx, + 'required', + undefined, + 'Field is required', + message, + ); } return undefined; }; }, /** Enforces a valid email format. */ - email(message = 'Invalid email address'): ValidatorFn { + email(message?: string): ValidatorFn { const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - return (value: unknown) => { - if (value === undefined || value === null || value === '') { + return (value, _data, _rootData, ctx) => { + if (isEmpty(value)) { return undefined; } if (typeof value !== 'string' || !emailRegex.test(value)) { - return message; + return resolveMessage( + ctx, + 'email', + undefined, + 'Invalid email address', + message, + ); } return undefined; }; @@ -38,14 +54,19 @@ export const validators = { /** Enforces minimum string length or array length. */ minLength(min: number, message?: string): ValidatorFn { - const msg = message ?? `Minimum length is ${min}`; - return (value: unknown) => { - if (value === undefined || value === null || value === '') { + return (value, _data, _rootData, ctx) => { + if (isEmpty(value)) { return undefined; } if (typeof value === 'string' || Array.isArray(value)) { if (value.length < min) { - return msg; + return resolveMessage( + ctx, + 'minLength', + { min }, + `Minimum length is ${min}`, + message, + ); } } return undefined; @@ -54,14 +75,19 @@ export const validators = { /** Enforces maximum string length or array length. */ maxLength(max: number, message?: string): ValidatorFn { - const msg = message ?? `Maximum length is ${max}`; - return (value: unknown) => { - if (value === undefined || value === null || value === '') { + return (value, _data, _rootData, ctx) => { + if (isEmpty(value)) { return undefined; } if (typeof value === 'string' || Array.isArray(value)) { if (value.length > max) { - return msg; + return resolveMessage( + ctx, + 'maxLength', + { max }, + `Maximum length is ${max}`, + message, + ); } } return undefined; @@ -70,14 +96,19 @@ export const validators = { /** Enforces minimum numerical value. */ min(minVal: number, message?: string): ValidatorFn { - const msg = message ?? `Minimum value is ${minVal}`; - return (value: unknown) => { - if (value === undefined || value === null || value === '') { + return (value, _data, _rootData, ctx) => { + if (isEmpty(value)) { return undefined; } const num = Number(value); if (Number.isNaN(num) || num < minVal) { - return msg; + return resolveMessage( + ctx, + 'min', + { min: minVal }, + `Minimum value is ${minVal}`, + message, + ); } return undefined; }; @@ -85,32 +116,67 @@ export const validators = { /** Enforces maximum numerical value. */ max(maxVal: number, message?: string): ValidatorFn { - const msg = message ?? `Maximum value is ${maxVal}`; - return (value: unknown) => { - if (value === undefined || value === null || value === '') { + return (value, _data, _rootData, ctx) => { + if (isEmpty(value)) { return undefined; } const num = Number(value); if (Number.isNaN(num) || num > maxVal) { - return msg; + return resolveMessage( + ctx, + 'max', + { max: maxVal }, + `Maximum value is ${maxVal}`, + message, + ); } return undefined; }; }, /** Enforces a regex pattern. */ - pattern(regex: RegExp, message = 'Invalid format'): ValidatorFn { - return (value: unknown) => { - if (value === undefined || value === null || value === '') { + pattern(regex: RegExp, message?: string): ValidatorFn { + return (value, _data, _rootData, ctx) => { + if (isEmpty(value)) { return undefined; } if (typeof value !== 'string' || !regex.test(value)) { - return message; + return resolveMessage( + ctx, + 'pattern', + undefined, + 'Invalid format', + message, + ); } return undefined; }; }, + /** + * Enforces that this field equals another field's value - confirm-password, + * confirm-email. Skips empty values so `required` owns that message rather + * than both firing at once. + */ + matches(otherFieldName: string, message?: string): ValidatorFn { + return (value, data, _rootData, ctx) => { + if (isEmpty(value)) { + return undefined; + } + // Object.is, not !==: two NaNs are the same value for this purpose. + if (Object.is(value, data?.[otherFieldName])) { + return undefined; + } + return resolveMessage( + ctx, + 'matches', + { other: otherFieldName }, + `Must match ${otherFieldName}`, + message, + ); + }; + }, + /** Combines multiple validator functions into a single field validator. */ compose( ...fns: ValidatorFn[] @@ -118,11 +184,17 @@ export const validators = { value: unknown, data: Properties, rootData?: Properties, + ctx?: ValidationContext, ) => string[] | undefined { - return (value: unknown, data: Properties, rootData?: Properties) => { + return ( + value: unknown, + data: Properties, + rootData?: Properties, + ctx?: ValidationContext, + ) => { const errors: string[] = []; for (const fn of fns) { - const err = fn(value, data, rootData); + const err = fn(value, data, rootData, ctx); if (err) { errors.push(err); } diff --git a/packages/core/test/validators.test.ts b/packages/core/test/validators.test.ts index 7122c17..354883c 100644 --- a/packages/core/test/validators.test.ts +++ b/packages/core/test/validators.test.ts @@ -1,4 +1,5 @@ -import { describe, expect, test } from 'vitest'; +import { describe, expect, it, test } from 'vitest'; +import { createMessageResolver } from '../src/messages'; import { validators } from '../src/validators'; describe('validators utility', () => { @@ -56,3 +57,84 @@ describe('validators utility', () => { expect(composed('abcde', {})).toBeUndefined(); }); }); + +describe('validators read the message catalog', () => { + const ctx = { + t: createMessageResolver({ + required: 'Bắt buộc', + minLength: 'Tối thiểu {min} ký tự', + max: 'Tối đa {max}', + }), + }; + + it('uses the catalog when no message is passed', () => { + expect(validators.required()('', {}, undefined, ctx)).toBe('Bắt buộc'); + }); + + it('interpolates validator params into the catalog entry', () => { + expect(validators.minLength(8)('abc', {}, undefined, ctx)).toBe( + 'Tối thiểu 8 ký tự', + ); + expect(validators.max(10)(11, {}, undefined, ctx)).toBe('Tối đa 10'); + }); + + it('still lets an explicitly passed message win', () => { + expect(validators.required('Explicit')('', {}, undefined, ctx)).toBe( + 'Explicit', + ); + }); + + it('keeps the English default when no catalog is in play', () => { + expect(validators.required()('')).toBe('Field is required'); + expect(validators.minLength(8)('abc')).toBe('Minimum length is 8'); + }); + + it('threads the context through compose', () => { + const composed = validators.compose(validators.required()); + expect(composed('', {}, undefined, ctx)).toEqual(['Bắt buộc']); + }); +}); + +describe('validators.matches', () => { + it('passes when the two values are equal', () => { + expect( + validators.matches('password')('secret', { password: 'secret' }), + ).toBeUndefined(); + }); + + it('fails when they differ', () => { + expect(validators.matches('password')('typo', { password: 'secret' })).toBe( + 'Must match password', + ); + }); + + it('takes its message from the catalog, with the other field interpolated', () => { + const ctx = { t: createMessageResolver({ matches: 'Phải khớp {other}' }) }; + expect( + validators.matches('password')( + 'typo', + { password: 'secret' }, + undefined, + ctx, + ), + ).toBe('Phải khớp password'); + }); + + it('lets an explicit message win', () => { + expect( + validators.matches('password', 'Passwords differ')('typo', { + password: 'secret', + }), + ).toBe('Passwords differ'); + }); + + it('skips an empty value, leaving required to report it', () => { + expect( + validators.matches('password')('', { password: 'secret' }), + ).toBeUndefined(); + }); + + it('compares with Object.is so two NaNs match', () => { + expect(validators.matches('a')(NaN, { a: NaN })).toBeUndefined(); + }); +}); From 611527c5c254523ca474e20d3708324bb6a63678 Mon Sep 17 00:00:00 2001 From: Van Nguyen Date: Fri, 4 Sep 2026 21:17:02 +0700 Subject: [PATCH 17/31] feat(core): thread the message context through validation validateField and validateFields gain a trailing optional context, so a catalog reaches the validators - including inside repeatable groups, where the recursive call now forwards it. validateFieldsAsync needed no change: its options bag already is the ValidationContext and was already threaded recursively, so t flows there as soon as a caller supplies it. --- packages/core/src/validation.ts | 12 +++++-- packages/core/test/validation.test.ts | 50 ++++++++++++++++++++++++++- 2 files changed, 58 insertions(+), 4 deletions(-) diff --git a/packages/core/src/validation.ts b/packages/core/src/validation.ts index 4d60e13..9846a61 100644 --- a/packages/core/src/validation.ts +++ b/packages/core/src/validation.ts @@ -124,6 +124,7 @@ function runSyncValidate( rootData: Properties | undefined, /** Key to report this field under - a grouped field is not just `name`. */ reportKey = field.name, + context?: ValidationContext, ): { errors: string[]; isPending: boolean } { if (!field.validate) { return { errors: [], isPending: false }; @@ -136,7 +137,7 @@ function runSyncValidate( } return { errors: [], isPending: true }; } - const result = field.validate(value, data, rootData); + const result = field.validate(value, data, rootData, context); if (isPromiseLike(result)) { // A rejected async result has no observer on the synchronous path. Attach // one so live validation does not create an unhandled rejection; callers @@ -160,8 +161,10 @@ export function validateField( value: unknown, data: Properties, rootData?: Properties, + context?: ValidationContext, ): string[] { - return runSyncValidate(field, value, data, rootData).errors; + return runSyncValidate(field, value, data, rootData, field.name, context) + .errors; } /** Run one field's validate hook asynchronously; always returns a Promise resolving to string[]. */ @@ -192,6 +195,7 @@ export function validateFields( fields: FieldDescription[], data: Properties, rootData: Properties = data, + context?: ValidationContext, ): ValidationResult { const errors: Record = {}; const pending: string[] = []; @@ -209,7 +213,7 @@ export function validateFields( ? (data[field.name] as Properties[]) : []; items.forEach((item, index) => { - const sub = validateFields(field.fields, item, rootData); + const sub = validateFields(field.fields, item, rootData, context); for (const [key, messages] of Object.entries(sub.errors)) { errors[`${field.name}[${index}].${key}`] = messages; } @@ -225,6 +229,8 @@ export function validateFields( data[field.name], data, rootData, + field.name, + context, ); if (fieldErrors.length > 0) { errors[field.name] = fieldErrors; diff --git a/packages/core/test/validation.test.ts b/packages/core/test/validation.test.ts index c6023a1..7e59418 100644 --- a/packages/core/test/validation.test.ts +++ b/packages/core/test/validation.test.ts @@ -1,6 +1,7 @@ -import { describe, expect, test } from 'vitest'; +import { describe, expect, it, test } from 'vitest'; import type { FieldDescription } from '../src'; import { zodValidator, yupValidator } from '../src/adapters'; +import { createMessageResolver } from '../src/messages'; import { resolveDisabled, resolveOptions, @@ -9,6 +10,7 @@ import { validateFields, validateFieldsAsync, } from '../src/validation'; +import { validators } from '../src/validators'; declare module '../src' { interface FieldTypeMap { @@ -241,3 +243,49 @@ describe('zodValidator and yupValidator', () => { expect(validator('hello', {})).toBeUndefined(); }); }); + +describe('validateFields threads the message context', () => { + const ctxFields: FieldDescription[] = [ + { name: 'title', type: 'text', validate: validators.required() }, + ]; + + it('reaches a built-in validator through validateFields', () => { + const result = validateFields(ctxFields, { title: '' }, undefined, { + t: createMessageResolver({ required: 'Bắt buộc' }), + }); + expect(result.errors.title).toEqual(['Bắt buộc']); + }); + + it('reaches it through validateField too', () => { + expect( + validateField(ctxFields[0], '', { title: '' }, undefined, { + t: createMessageResolver({ required: 'Bắt buộc' }), + }), + ).toEqual(['Bắt buộc']); + }); + + it('keeps the English default with no context', () => { + expect(validateFields(ctxFields, { title: '' }).errors.title).toEqual([ + 'Field is required', + ]); + }); + + it('descends into repeatable groups with the context intact', () => { + const grouped: FieldDescription[] = [ + { + name: 'items', + type: 'text', + fields: [ + { name: 'label', type: 'text', validate: validators.required() }, + ], + }, + ]; + const result = validateFields( + grouped, + { items: [{ label: '' }] }, + undefined, + { t: createMessageResolver({ required: 'Bắt buộc' }) }, + ); + expect(result.errors['items[0].label']).toEqual(['Bắt buộc']); + }); +}); From 2103537119b3de3e26f25c8bf67da530d828abea Mon Sep 17 00:00:00 2001 From: Van Nguyen Date: Fri, 4 Sep 2026 21:18:06 +0700 Subject: [PATCH 18/31] feat(react): accept a messages catalog in useDynamicForm Both validateFieldsAsync call sites spread the context before setting signal, rather than replacing the options object - dropping the signal there would silently disable run cancellation. --- packages/react/src/useDynamicForm.ts | 54 ++++++++++++++++++--- packages/react/test/useDynamicForm.test.tsx | 33 +++++++++++++ 2 files changed, 81 insertions(+), 6 deletions(-) diff --git a/packages/react/src/useDynamicForm.ts b/packages/react/src/useDynamicForm.ts index d905e35..3d1ed27 100644 --- a/packages/react/src/useDynamicForm.ts +++ b/packages/react/src/useDynamicForm.ts @@ -1,8 +1,11 @@ import { applyComputedValues, collectFieldPaths, + createMessageResolver, FieldDescription, + type MessageCatalog, Properties, + type ValidationContext, type ValidationResult, validateFields, validateFieldsAsync, @@ -14,6 +17,13 @@ export interface UseDynamicFormOptions { initialValues?: Properties; validateOnBlur?: boolean; validateOnChange?: boolean; + /** + * Messages for the built-in validators, set once for the whole form instead + * of per field. A message passed directly to a validator still wins, and any + * key omitted here falls back to the validator's English default. See core's + * `MessageCatalog`. + */ + messages?: MessageCatalog; } export interface UseDynamicFormResult { @@ -97,7 +107,15 @@ export function useDynamicForm({ initialValues = {}, validateOnBlur = true, validateOnChange = false, + messages, }: UseDynamicFormOptions): UseDynamicFormResult { + // A ref, not a memo: every validation call site reads it, including the + // useState initialiser that runs before any memo would be assigned. + const validationContextRef = useRef({ + t: createMessageResolver(messages), + }); + validationContextRef.current.t = createMessageResolver(messages); + const [data, setData] = useState(() => applyComputedValues(fields, initialValues), ); @@ -111,7 +129,7 @@ export function useDynamicForm({ // for an empty required field and never correct it. The initialiser runs // once, unlike the useMemo this replaced, which ran on every render. const [validationResult, setValidationResult] = useState( - () => validateFields(fields, data), + () => validateFields(fields, data, undefined, validationContextRef.current), ); const [isValidating, setIsValidating] = useState(false); const validationRunRef = useRef(0); @@ -159,7 +177,9 @@ export function useDynamicForm({ return; } lastValidatedRef.current = data; - commitSyncResult(validateFields(fields, data)); + commitSyncResult( + validateFields(fields, data, undefined, validationContextRef.current), + ); }, [fields, data, commitSyncResult]); useEffect( @@ -171,7 +191,12 @@ export function useDynamicForm({ ); const validate = useCallback(() => { - const res = validateFields(fields, data); + const res = validateFields( + fields, + data, + undefined, + validationContextRef.current, + ); setErrors(res.errors); return commitSyncResult(res); }, [fields, data, commitSyncResult]); @@ -185,6 +210,7 @@ export function useDynamicForm({ setIsValidating(true); try { const res = await validateFieldsAsync(fields, snapshot, snapshot, { + ...validationContextRef.current, signal: controller.signal, }); if (run !== validationRunRef.current || dataRef.current !== snapshot) { @@ -211,7 +237,12 @@ export function useDynamicForm({ setIsValidating(false); lastValidatedRef.current = next; - const res = validateFields(fields, next); + const res = validateFields( + fields, + next, + undefined, + validationContextRef.current, + ); commitSyncResult(res); if (validateOnChange) { @@ -258,7 +289,12 @@ export function useDynamicForm({ (fieldName: string) => { setFieldTouched(fieldName, true); if (validateOnBlur) { - const res = validateFields(fields, data); + const res = validateFields( + fields, + data, + undefined, + validationContextRef.current, + ); setErrors(res.errors); commitSyncResult(res); } @@ -316,6 +352,7 @@ export function useDynamicForm({ const snapshot = data; setIsValidating(true); const res = await validateFieldsAsync(fields, snapshot, snapshot, { + ...validationContextRef.current, signal: controller.signal, }); if (submitRun !== submitRunRef.current) { @@ -332,7 +369,12 @@ export function useDynamicForm({ setErrors(res.errors); setValidationResult(res); } else { - const live = validateFields(fields, dataRef.current); + const live = validateFields( + fields, + dataRef.current, + undefined, + validationContextRef.current, + ); setErrors(live.errors); commitSyncResult(live); } diff --git a/packages/react/test/useDynamicForm.test.tsx b/packages/react/test/useDynamicForm.test.tsx index 5dfa326..372fd09 100644 --- a/packages/react/test/useDynamicForm.test.tsx +++ b/packages/react/test/useDynamicForm.test.tsx @@ -1,3 +1,4 @@ +import { validators } from '@dynamic-field-kit/core'; import { act, renderHook, waitFor } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; import type { FieldDescription } from '../src'; @@ -275,3 +276,35 @@ describe('baselineValues and getDirtyValues', () => { expect(result.current.getDirtyValues()).toEqual({ note: 'added' }); }); }); + +describe('messages', () => { + const msgFields: FieldDescription[] = [ + { name: 'title', type: 'text', validate: validators.required() }, + ]; + + it('resolves validator messages through the supplied catalog', () => { + const { result } = renderHook(() => + useDynamicForm({ + fields: msgFields, + initialValues: { title: '' }, + messages: { required: 'Bắt buộc' }, + }), + ); + + act(() => { + result.current.validate(); + }); + expect(result.current.errors.title).toEqual(['Bắt buộc']); + }); + + it('keeps the English default with no catalog', () => { + const { result } = renderHook(() => + useDynamicForm({ fields: msgFields, initialValues: { title: '' } }), + ); + + act(() => { + result.current.validate(); + }); + expect(result.current.errors.title).toEqual(['Field is required']); + }); +}); From a93c493ecd294253b907f8b55e6a5f249ff1e61d Mon Sep 17 00:00:00 2001 From: Van Nguyen Date: Fri, 4 Sep 2026 21:18:58 +0700 Subject: [PATCH 19/31] feat(vue): accept a messages catalog in useDynamicForm --- packages/vue/src/useDynamicForm.ts | 50 +++++++++++++++++++++--- packages/vue/test/useDynamicForm.test.ts | 26 ++++++++++++ 2 files changed, 70 insertions(+), 6 deletions(-) diff --git a/packages/vue/src/useDynamicForm.ts b/packages/vue/src/useDynamicForm.ts index e3fb150..b6d00f6 100644 --- a/packages/vue/src/useDynamicForm.ts +++ b/packages/vue/src/useDynamicForm.ts @@ -1,8 +1,11 @@ import { applyComputedValues, collectFieldPaths, + createMessageResolver, FieldDescription, + type MessageCatalog, Properties, + type ValidationContext, type ValidationResult, validateFields, validateFieldsAsync, @@ -14,6 +17,13 @@ export interface UseDynamicFormOptions { initialValues?: Properties; validateOnBlur?: boolean; validateOnChange?: boolean; + /** + * Messages for the built-in validators, set once for the whole form instead + * of per field. A message passed directly to a validator still wins, and any + * key omitted here falls back to the validator's English default. See core's + * `MessageCatalog`. + */ + messages?: MessageCatalog; } export function useDynamicForm({ @@ -21,7 +31,11 @@ export function useDynamicForm({ initialValues = {}, validateOnBlur = true, validateOnChange = false, + messages, }: UseDynamicFormOptions) { + const validationContext: ValidationContext = { + t: createMessageResolver(messages), + }; const data = ref(applyComputedValues(fields, initialValues)); // The baseline `dirty` is measured against: the initialValues option until // reset(newValues) replaces it. Distinct from that option, which never @@ -32,7 +46,12 @@ export function useDynamicForm({ const touched = ref>({}); const isSubmitting = ref(false); const isSubmitted = ref(false); - const initialValidation = validateFields(fields, data.value); + const initialValidation = validateFields( + fields, + data.value, + undefined, + validationContext, + ); const validationResult = ref(initialValidation); const isValidating = ref(false); let validationRun = 0; @@ -67,7 +86,12 @@ export function useDynamicForm({ } function validate() { - const res = validateFields(fields, data.value); + const res = validateFields( + fields, + data.value, + undefined, + validationContext, + ); errors.value = res.errors; return commitSyncResult(res); } @@ -81,6 +105,7 @@ export function useDynamicForm({ isValidating.value = true; try { const res = await validateFieldsAsync(fields, snapshot, snapshot, { + ...validationContext, signal: controller.signal, }); if (run !== validationRun || data.value !== snapshot) { @@ -104,7 +129,7 @@ export function useDynamicForm({ validationRun += 1; isValidating.value = false; - const res = validateFields(fields, next); + const res = validateFields(fields, next, undefined, validationContext); commitSyncResult(res); if (validateOnChange) { @@ -153,7 +178,12 @@ export function useDynamicForm({ function handleBlur(fieldName: string) { setFieldTouched(fieldName, true); if (validateOnBlur) { - const res = validateFields(fields, data.value); + const res = validateFields( + fields, + data.value, + undefined, + validationContext, + ); errors.value = res.errors; commitSyncResult(res); } @@ -172,7 +202,9 @@ export function useDynamicForm({ validationController?.abort(); validationRun += 1; isValidating.value = false; - commitSyncResult(validateFields(fields, next)); + commitSyncResult( + validateFields(fields, next, undefined, validationContext), + ); } function handleSubmit( @@ -201,6 +233,7 @@ export function useDynamicForm({ const snapshot = data.value; isValidating.value = true; const res = await validateFieldsAsync(fields, snapshot, snapshot, { + ...validationContext, signal: controller.signal, }); if (thisSubmit !== submitRun) { @@ -214,7 +247,12 @@ export function useDynamicForm({ errors.value = res.errors; validationResult.value = res; } else { - const live = validateFields(fields, data.value); + const live = validateFields( + fields, + data.value, + undefined, + validationContext, + ); errors.value = live.errors; validationResult.value = live; } diff --git a/packages/vue/test/useDynamicForm.test.ts b/packages/vue/test/useDynamicForm.test.ts index 60103df..5f5518c 100644 --- a/packages/vue/test/useDynamicForm.test.ts +++ b/packages/vue/test/useDynamicForm.test.ts @@ -1,3 +1,4 @@ +import { validators } from '@dynamic-field-kit/core'; import { describe, expect, it, vi } from 'vitest'; import { effectScope } from 'vue'; import type { FieldDescription } from '../src'; @@ -371,3 +372,28 @@ describe('baselineValues and getDirtyValues', () => { expect(form.baselineValues.value).toEqual({ title: 'a', note: 'n' }); }); }); + +describe('messages', () => { + const msgFields: FieldDescription[] = [ + { name: 'title', type: 'text', validate: validators.required() }, + ]; + + it('resolves validator messages through the supplied catalog', () => { + const form = useDynamicForm({ + fields: msgFields, + initialValues: { title: '' }, + messages: { required: 'Bắt buộc' }, + }); + form.validate(); + expect(form.errors.value.title).toEqual(['Bắt buộc']); + }); + + it('keeps the English default with no catalog', () => { + const form = useDynamicForm({ + fields: msgFields, + initialValues: { title: '' }, + }); + form.validate(); + expect(form.errors.value.title).toEqual(['Field is required']); + }); +}); From 11a189d08e666050e7438a0de72b7106d06bbf37 Mon Sep 17 00:00:00 2001 From: Van Nguyen Date: Fri, 4 Sep 2026 21:19:37 +0700 Subject: [PATCH 20/31] feat(angular): accept a messages catalog in the form store --- .../angular/src/lib/dynamic-form.store.ts | 34 +++++++++++++++---- .../angular/test/dynamicFormStore.spec.ts | 27 ++++++++++++++- 2 files changed, 54 insertions(+), 7 deletions(-) diff --git a/packages/angular/src/lib/dynamic-form.store.ts b/packages/angular/src/lib/dynamic-form.store.ts index 61d832c..bc025a3 100644 --- a/packages/angular/src/lib/dynamic-form.store.ts +++ b/packages/angular/src/lib/dynamic-form.store.ts @@ -2,8 +2,11 @@ import { computed, signal } from '@angular/core'; import { applyComputedValues, collectFieldPaths, + createMessageResolver, FieldDescription, + type MessageCatalog, Properties, + type ValidationContext, type ValidationResult, validateFields, validateFieldsAsync, @@ -14,6 +17,13 @@ export interface DynamicFormOptions { initialValues?: Properties; validateOnBlur?: boolean; validateOnChange?: boolean; + /** + * Messages for the built-in validators, set once for the whole form instead + * of per field. A message passed directly to a validator still wins, and any + * key omitted here falls back to the validator's English default. See core's + * `MessageCatalog`. + */ + messages?: MessageCatalog; } export function createDynamicFormStore(options: DynamicFormOptions) { @@ -21,6 +31,9 @@ export function createDynamicFormStore(options: DynamicFormOptions) { const initialValues = options.initialValues || {}; const validateOnBlur = options.validateOnBlur ?? true; const validateOnChange = options.validateOnChange ?? false; + const validationContext: ValidationContext = { + t: createMessageResolver(options.messages), + }; const data = signal(applyComputedValues(fields, initialValues)); // The baseline `dirty` is measured against: the initialValues option until @@ -33,7 +46,7 @@ export function createDynamicFormStore(options: DynamicFormOptions) { const isSubmitting = signal(false); const isSubmitted = signal(false); const validationResult = signal( - validateFields(fields, data()), + validateFields(fields, data(), undefined, validationContext), ); const isValidating = signal(false); let validationRun = 0; @@ -57,7 +70,7 @@ export function createDynamicFormStore(options: DynamicFormOptions) { } function validate(): boolean { - const res = validateFields(fields, data()); + const res = validateFields(fields, data(), undefined, validationContext); errors.set(res.errors); return commitSyncResult(res); } @@ -71,6 +84,7 @@ export function createDynamicFormStore(options: DynamicFormOptions) { isValidating.set(true); try { const res = await validateFieldsAsync(fields, snapshot, snapshot, { + ...validationContext, signal: controller.signal, }); if (run !== validationRun || data() !== snapshot) { @@ -94,7 +108,7 @@ export function createDynamicFormStore(options: DynamicFormOptions) { validationRun += 1; isValidating.set(false); - const res = validateFields(fields, next); + const res = validateFields(fields, next, undefined, validationContext); commitSyncResult(res); if (validateOnChange) { @@ -143,7 +157,7 @@ export function createDynamicFormStore(options: DynamicFormOptions) { function handleBlur(fieldName: string) { setFieldTouched(fieldName, true); if (validateOnBlur) { - const res = validateFields(fields, data()); + const res = validateFields(fields, data(), undefined, validationContext); errors.set(res.errors); commitSyncResult(res); } @@ -162,7 +176,9 @@ export function createDynamicFormStore(options: DynamicFormOptions) { validationController?.abort(); validationRun += 1; isValidating.set(false); - commitSyncResult(validateFields(fields, next)); + commitSyncResult( + validateFields(fields, next, undefined, validationContext), + ); } /** @@ -196,6 +212,7 @@ export function createDynamicFormStore(options: DynamicFormOptions) { const snapshot = data(); isValidating.set(true); const res = await validateFieldsAsync(fields, snapshot, snapshot, { + ...validationContext, signal: controller.signal, }); if (thisSubmit !== submitRun) { @@ -209,7 +226,12 @@ export function createDynamicFormStore(options: DynamicFormOptions) { errors.set(res.errors); validationResult.set(res); } else { - const live = validateFields(fields, data()); + const live = validateFields( + fields, + data(), + undefined, + validationContext, + ); errors.set(live.errors); validationResult.set(live); } diff --git a/packages/angular/test/dynamicFormStore.spec.ts b/packages/angular/test/dynamicFormStore.spec.ts index 9ebdf65..8f15d82 100644 --- a/packages/angular/test/dynamicFormStore.spec.ts +++ b/packages/angular/test/dynamicFormStore.spec.ts @@ -1,4 +1,4 @@ -import { FieldDescription } from '@dynamic-field-kit/core'; +import { FieldDescription, validators } from '@dynamic-field-kit/core'; import { describe, expect, it, vi } from 'vitest'; import { createDynamicFormStore } from '../src/lib/dynamic-form.store'; @@ -305,3 +305,28 @@ describe('baselineValues and getDirtyValues', () => { expect(store.baselineValues()).toEqual({ title: 'a', note: 'n' }); }); }); + +describe('messages', () => { + const msgFields: FieldDescription[] = [ + { name: 'title', type: 'text', validate: validators.required() }, + ]; + + it('resolves validator messages through the supplied catalog', () => { + const store = createDynamicFormStore({ + fields: msgFields, + initialValues: { title: '' }, + messages: { required: 'Bắt buộc' }, + }); + store.validate(); + expect(store.errors()['title']).toEqual(['Bắt buộc']); + }); + + it('keeps the English default with no catalog', () => { + const store = createDynamicFormStore({ + fields: msgFields, + initialValues: { title: '' }, + }); + store.validate(); + expect(store.errors()['title']).toEqual(['Field is required']); + }); +}); From 7ca2354b857ce3e3fa5181427790a5343d9d9b97 Mon Sep 17 00:00:00 2001 From: Van Nguyen Date: Fri, 4 Sep 2026 21:20:47 +0700 Subject: [PATCH 21/31] docs: message catalog reference and 1.7.0 i18n migration notes --- .changeset/brave-melons-shave.md | 21 ++++++++++++ README.md | 55 +++++++++++++++++++++++++++----- docs/MIGRATING.md | 27 ++++++++++++++++ 3 files changed, 95 insertions(+), 8 deletions(-) create mode 100644 .changeset/brave-melons-shave.md diff --git a/.changeset/brave-melons-shave.md b/.changeset/brave-melons-shave.md new file mode 100644 index 0000000..d0c2070 --- /dev/null +++ b/.changeset/brave-melons-shave.md @@ -0,0 +1,21 @@ +--- +'@dynamic-field-kit/core': minor +'@dynamic-field-kit/react': minor +'@dynamic-field-kit/vue': minor +'@dynamic-field-kit/angular': minor +--- + +Validation messages can be set once per form via `useDynamicForm({ messages })`, +or process-wide via `setDefaultMessages`, instead of passing a string to every +validator on every field. Built-in validators now resolve their message when +they run rather than when the field description is built, which is what made a +catalog impossible before. A message passed directly to a validator still wins, +and the English defaults are unchanged when no catalog is supplied. + +`ValidationContext` - already `validate`'s fourth argument - gains an optional +`t` resolver, so a hand-written validator can translate its own messages too. + +Adds `validators.matches(otherFieldName)` for confirm-password and +confirm-email fields, which every consumer was hand-writing. + +No locale bundles ship: the mechanism is here, the translations are yours. diff --git a/README.md b/README.md index eaf96c1..0014dca 100644 --- a/README.md +++ b/README.md @@ -308,14 +308,14 @@ const fields: FieldDescription[] = [ ]; ``` -| Property | Description | -| ----------------- | --------------------------------------------------------------------------------------------------- | -| validate | `(value, data, rootData?, context?) => string | string[] | undefined | Promise<...>`. Falsy means valid. `context.signal` aborts when a newer run supersedes this one. | -| validationMode | `'sync' | 'async'`. Declares a validator that returns a Promise without the `async` keyword, so the live pass skips it instead of calling it. | -| validators | Built-in helpers: `required`, `email`, `minLength`, `maxLength`, `min`, `max`, `pattern`, `compose` | -| options | Array of option objects or dynamic callback function `(data, rootData?) => Option[]` | -| disabledCondition | `(data, rootData?) => boolean`. OR-ed with the static `disabled` flag. | -| readOnlyCondition | `(data, rootData?) => boolean`. | +| Property | Description | +| ----------------- | -------------------------------------------------------------------------------------------------------------- | +| validate | `(value, data, rootData?, context?) => string | string[] | undefined | Promise<...>`. Falsy means valid. `context.signal` aborts when a newer run supersedes this one. | +| validationMode | `'sync' | 'async'`. Declares a validator that returns a Promise without the `async` keyword, so the live pass skips it instead of calling it. | +| validators | Built-in helpers: `required`, `email`, `minLength`, `maxLength`, `min`, `max`, `pattern`, `matches`, `compose` | +| options | Array of option objects or dynamic callback function `(data, rootData?) => Option[]` | +| disabledCondition | `(data, rootData?) => boolean`. OR-ed with the static `disabled` flag. | +| readOnlyCondition | `(data, rootData?) => boolean`. | `MultiFieldInput` passes each field's current `error` and effective `disabled`/`readOnly` to its renderer (via `FieldRendererProps`), and emits an @@ -722,3 +722,42 @@ MIT © [vannt-dev](https://github.com/vannt-dev) ## 🤝 Contributing Contributions welcome! Please see individual package READMEs for setup and development instructions. + +### Validation messages + +Set the built-in validators' messages once for a whole form instead of passing a +string to every validator on every field: + +```ts +const form = useDynamicForm({ + fields, + messages: { + required: 'Bắt buộc', + minLength: 'Tối thiểu {min} ký tự', + matches: 'Phải khớp {other}', + }, +}); +``` + +A message passed directly to a validator still wins, and any key you omit falls +back to the English default. For code that calls `validateFields` directly and +has no form to hang a catalog on, `setDefaultMessages(catalog)` sets a +process-wide one; a per-form catalog takes precedence over it. + +| Key | Params | English default | +| ----------- | --------- | ----------------------- | +| `required` | — | Field is required | +| `email` | — | Invalid email address | +| `minLength` | `{min}` | Minimum length is {min} | +| `maxLength` | `{max}` | Maximum length is {max} | +| `min` | `{min}` | Minimum value is {min} | +| `max` | `{max}` | Maximum value is {max} | +| `pattern` | — | Invalid format | +| `matches` | `{other}` | Must match {other} | + +**No locale bundles ship with this library.** Supply your own catalog — the +mechanism is here, the translations are yours. + +A placeholder with no matching param is left in the string verbatim rather than +replaced with `undefined`, so a typo shows up as a visible `{unit}` instead of +a mystery. diff --git a/docs/MIGRATING.md b/docs/MIGRATING.md index aaeed2e..4cfd021 100644 --- a/docs/MIGRATING.md +++ b/docs/MIGRATING.md @@ -125,3 +125,30 @@ silently — no throw, no warning, the value just vanished. A development-only `console.warn` now names the field and the key, once per pair. Production builds are unchanged and emit nothing. + +### Validation messages can be set per form + +Previously the only way to change a built-in validator's message was to pass a +string on every field of every form. The validator baked that string in when the +field description was built, so nothing set later could reach it. + +| Before | After | +| ------------------------------------------------------------ | ---------------------------------------------------------------- | +| `validators.required('Bắt buộc')` on every field | `useDynamicForm({ fields, messages: { required: 'Bắt buộc' } })` | +| no way to change messages for a direct `validateFields` call | `setDefaultMessages(catalog)` | + +Fully backward compatible: a message passed to a validator still wins over any +catalog, and with no catalog the English defaults are unchanged. + +`ValidationContext` — already the fourth argument to `validate`, carrying +`signal` — gains an optional `t`. A hand-written validator can use it to +translate its own messages. Nothing is required of existing validators. + +### `validators.matches` + +| Before | After | +| --------------------------------------------------------------------------------- | ------------------------------------------ | +| `validate: (value, data) => (value !== data.password ? 'Must match' : undefined)` | `validate: validators.matches('password')` | + +Skips empty values so `required` owns that case rather than both firing at once, +and compares with `Object.is` so two `NaN`s match. From f5c14213bb0e3c15dfd378cffed0a1fb5aac39d0 Mon Sep 17 00:00:00 2001 From: Van Nguyen Date: Fri, 4 Sep 2026 21:24:36 +0700 Subject: [PATCH 22/31] feat(core): add createOptionsLoader for async, debounced field options debounceMs was declared in FieldDescription, published in the .d.ts and read by no implementation anywhere - setting it did nothing. It now debounces this loader. Everything hard lives here rather than three times over in the adapters: debounce, abort of a superseded run, a run counter that discards an out-of-order response even when the signal is ignored, and shallow deps comparison. An AbortError is deliberately not an error state - being superseded is normal and would otherwise flash a failure on every keystroke of a search box. options takes one signature, not a union of sync and async shapes: a union defeats TypeScript's contextual inference, so every existing options: (data) => ... would have started erroring under noImplicitAny. Returning a promise is what makes a loader async. resolveOptions now returns undefined for those, so no renderer is handed a Promise. --- packages/core/src/index.ts | 1 + packages/core/src/optionsLoader.ts | 187 +++++++++++++++ packages/core/src/types.ts | 73 +++++- packages/core/src/validation.ts | 13 +- packages/core/test/optionsLoader.test.ts | 277 +++++++++++++++++++++++ 5 files changed, 545 insertions(+), 6 deletions(-) create mode 100644 packages/core/src/optionsLoader.ts create mode 100644 packages/core/test/optionsLoader.test.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a8fdda1..cd68b7d 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -23,3 +23,4 @@ export * from './wizard'; export * from './rendererProps'; export * from './pathMaps'; export * from './messages'; +export * from './optionsLoader'; diff --git a/packages/core/src/optionsLoader.ts b/packages/core/src/optionsLoader.ts new file mode 100644 index 0000000..df0c3de --- /dev/null +++ b/packages/core/src/optionsLoader.ts @@ -0,0 +1,187 @@ +import type { + FieldDescription, + OptionsFn, + OptionsStatus, + Properties, +} from './types'; + +export interface OptionsState { + status: OptionsStatus; + options?: Properties[]; + error?: unknown; +} + +export interface OptionsLoader { + /** + * Re-evaluates `optionsDeps` against the current data and fetches only when + * they actually changed. Safe to call on every render or keystroke. + */ + update(data: Properties, rootData?: Properties): void; + /** + * Renderer-driven refetch for a search-remote field. Always fetches (after + * the debounce), because the query is state the form data never sees. + */ + setQuery(query: string): void; + current(): OptionsState; + /** Aborts anything in flight and stops further callbacks. */ + dispose(): void; +} + +/** + * Whether this field's options are loaded asynchronously. + * + * Detected the same way `validationMode` detects async validators + * (`constructor.name === 'AsyncFunction'`), with `optionsMode: 'async'` as the + * explicit escape hatch for a function that returns a promise without the + * `async` keyword. + */ +export function isAsyncOptions(field: FieldDescription): boolean { + if (field.optionsMode === 'async') { + return true; + } + if (field.optionsMode === 'sync') { + return false; + } + return ( + typeof field.options === 'function' && + field.options.constructor?.name === 'AsyncFunction' + ); +} + +function isAbortError(error: unknown): boolean { + return error instanceof Error && error.name === 'AbortError'; +} + +function sameDeps(left: unknown[], right: unknown[]): boolean { + return ( + left.length === right.length && + left.every((value, index) => Object.is(value, right[index])) + ); +} + +/** + * Owns everything hard about loading a field's options asynchronously: + * debouncing, aborting a superseded run, discarding a response that lands out + * of order, and deciding whether the dependencies actually changed. + * + * Framework-agnostic on purpose. Each adapter wraps this in its own reactivity + * primitive and forwards the state as renderer props, so the logic exists once + * rather than three times. + */ +export function createOptionsLoader( + field: FieldDescription, + onChange: (state: OptionsState) => void, +): OptionsLoader { + let state: OptionsState = { status: 'idle' }; + let disposed = false; + + // Incremented per fetch. A response whose run is stale is dropped even if the + // abort did not take - a fetch implementation is free to ignore the signal, + // and this is the check that does not depend on it cooperating. + let run = 0; + let controller: AbortController | undefined; + let timer: ReturnType | undefined; + + let lastDeps: unknown[] | undefined; + let currentQuery: string | undefined; + let latestData: Properties = {}; + let latestRootData: Properties | undefined; + + function emit(next: OptionsState): void { + if (disposed) { + return; + } + state = next; + onChange(state); + } + + function fetchNow(): void { + if (disposed) { + return; + } + const thisRun = ++run; + controller?.abort(); + const thisController = new AbortController(); + controller = thisController; + + emit({ ...state, status: 'loading' }); + + const load = field.options as OptionsFn; + Promise.resolve( + load(latestData, latestRootData, { + query: currentQuery, + signal: thisController.signal, + }), + ).then( + (options) => { + if (thisRun !== run) { + return; + } + emit({ status: 'ready', options }); + }, + (error: unknown) => { + if (thisRun !== run) { + return; + } + // Being superseded is normal, not a failure. Reporting it as one would + // flash an error in the UI on every keystroke of a search box. + if (isAbortError(error)) { + return; + } + emit({ status: 'error', error, options: state.options }); + }, + ); + } + + function schedule(): void { + if (disposed) { + return; + } + const wait = field.debounceMs ?? 0; + if (timer !== undefined) { + clearTimeout(timer); + timer = undefined; + } + if (wait <= 0) { + // Not setTimeout(0): the undebounced case should not wait on the timer + // queue, which in a test with fake timers would never fire at all. + fetchNow(); + return; + } + timer = setTimeout(() => { + timer = undefined; + fetchNow(); + }, wait); + } + + return { + update(data, rootData) { + latestData = data; + latestRootData = rootData; + const deps = field.optionsDeps?.(data, rootData) ?? []; + if (lastDeps !== undefined && sameDeps(lastDeps, deps)) { + return; + } + lastDeps = deps; + schedule(); + }, + + setQuery(query) { + currentQuery = query; + schedule(); + }, + + current() { + return state; + }, + + dispose() { + disposed = true; + if (timer !== undefined) { + clearTimeout(timer); + timer = undefined; + } + controller?.abort(); + }, + }; +} diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 79ecde9..92299a6 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -20,6 +20,35 @@ export interface FieldTypeMap { export type Properties = Record; +/** The third argument an options loader receives, for async loading. */ +export interface OptionsContext { + /** + * Whatever the renderer last passed to `onOptionsQuery` - the search box in + * a search-remote picker. Undefined for a purely data-driven load. + */ + query?: string; + /** Aborted when a newer load supersedes this one. */ + signal: AbortSignal; +} + +/** + * Resolves a field's options. + * + * One signature rather than a union of a sync and an async shape, and the + * positional `(data, rootData)` is unchanged from before async loading + * existed. Both of those are deliberate: a union of two function types + * defeats TypeScript's contextual inference, so every existing + * `options: (data) => …` would have started erroring under `noImplicitAny`. + * Returning a promise is what makes a loader async, not its parameter shape. + */ +export type OptionsFn = ( + data: Properties, + rootData?: Properties, + ctx?: OptionsContext, +) => Properties[] | Promise; + +export type OptionsStatus = 'idle' | 'loading' | 'ready' | 'error'; + export interface ValidationContext { /** Aborted when a newer validation run supersedes this one. */ signal?: AbortSignal; @@ -49,6 +78,22 @@ export interface FieldRendererProps { dirty?: boolean; error?: string | string[]; options?: Properties[]; + /** + * Where the option list currently stands. Only ever set for a field with an + * async loader; undefined means the options are static or synchronous and + * there is nothing to wait for. + */ + optionsStatus?: OptionsStatus; + /** Whatever the async loader rejected with, when `optionsStatus` is 'error'. */ + optionsError?: unknown; + /** + * Ask for a fresh option list matching `query` - the search box in a + * search-remote picker. Debounced by the field's `debounceMs`. + * + * Not part of `FIELD_RENDERER_PROP_KEYS`: it is a callback, attached by the + * adapter alongside `onValueChange` and `onBlur`. + */ + onOptionsQuery?: (query: string) => void; className?: string; description?: unknown; id?: string; @@ -113,14 +158,36 @@ export interface FieldDescription { disabledCondition?: (data: Properties, rootData?: Properties) => boolean; /** Dynamic read-only state. */ readOnlyCondition?: (data: Properties, rootData?: Properties) => boolean; - /** Dynamic options list or static mảng options. */ - options?: - Properties[] | ((data: Properties, rootData?: Properties) => Properties[]); + /** + * A static list, a synchronous function of the form data, or an + * asynchronous loader. Declare `optionsMode: 'async'` for a loader that + * returns a promise without the `async` keyword, the way `validationMode` + * works. + */ + options?: Properties[] | OptionsFn; + /** Mirrors `validationMode`, for the options loader. */ + optionsMode?: 'sync' | 'async'; + /** + * Values an async loader depends on. It refetches when any of them changes, + * compared shallowly with `Object.is`. Defaults to `[]`, meaning fetch once: + * without this the loader would have to refetch on every keystroke in the + * whole form, since it cannot see what the loader function reads. + * + * Ignored for synchronous options. + */ + optionsDeps?: (data: Properties, rootData?: Properties) => unknown[]; min?: number | string; max?: number | string; step?: number | string; accept?: string; multiple?: boolean; + /** + * Debounce for the async options loader, in milliseconds. Rapid `update` or + * `onOptionsQuery` calls inside the window collapse into one fetch. + * + * Ignored for synchronous options. Before 1.7.0 this was declared but read + * by nothing at all - setting it did nothing. + */ debounceMs?: number; className?: string; description?: unknown; diff --git a/packages/core/src/validation.ts b/packages/core/src/validation.ts index 9846a61..6490b19 100644 --- a/packages/core/src/validation.ts +++ b/packages/core/src/validation.ts @@ -1,4 +1,5 @@ import { isFieldGroup } from './fieldGroup'; +import { isAsyncOptions } from './optionsLoader'; import type { FieldDescription, Properties, ValidationContext } from './types'; export interface ValidationResult { @@ -97,17 +98,23 @@ export function resolveReadOnly( return field.readOnlyCondition?.(data, rootData) === true; } -/** Resolves dynamic options or returns static options list. */ +/** + * Resolves a static or synchronous options list. + * + * Returns undefined for a field whose options load asynchronously: those are + * owned by `createOptionsLoader`, and calling the function here would hand the + * renderer a Promise as its `options`. + */ export function resolveOptions( field: FieldDescription, data: Properties, rootData?: Properties, ): Properties[] | undefined { - if (!field.options) { + if (!field.options || isAsyncOptions(field)) { return undefined; } if (typeof field.options === 'function') { - return field.options(data, rootData); + return field.options(data, rootData) as Properties[]; } return field.options; } diff --git a/packages/core/test/optionsLoader.test.ts b/packages/core/test/optionsLoader.test.ts new file mode 100644 index 0000000..4e762d0 --- /dev/null +++ b/packages/core/test/optionsLoader.test.ts @@ -0,0 +1,277 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + createOptionsLoader, + isAsyncOptions, + type OptionsState, +} from '../src/optionsLoader'; +import type { FieldDescription, Properties } from '../src/types'; +import { resolveOptions } from '../src/validation'; + +const OPTIONS: Properties[] = [{ label: 'Hanoi', value: 'hn' }]; + +function collect() { + const states: OptionsState[] = []; + return { states, onChange: (s: OptionsState) => states.push({ ...s }) }; +} + +describe('isAsyncOptions', () => { + it('is false for a static array', () => { + expect(isAsyncOptions({ name: 'a', type: 'text', options: OPTIONS })).toBe( + false, + ); + }); + + it('is false for a synchronous function', () => { + expect( + isAsyncOptions({ name: 'a', type: 'text', options: () => OPTIONS }), + ).toBe(false); + }); + + it('is true for a native async function', () => { + expect( + isAsyncOptions({ + name: 'a', + type: 'text', + options: async () => OPTIONS, + }), + ).toBe(true); + }); + + it('honours an explicit optionsMode for a promise-returning non-async fn', () => { + expect( + isAsyncOptions({ + name: 'a', + type: 'text', + optionsMode: 'async', + options: () => Promise.resolve(OPTIONS), + }), + ).toBe(true); + }); +}); + +describe('resolveOptions leaves async loaders alone', () => { + it('returns undefined rather than handing the renderer a promise', () => { + const field: FieldDescription = { + name: 'city', + type: 'text', + options: async () => OPTIONS, + }; + expect(resolveOptions(field, {})).toBeUndefined(); + }); + + it('still resolves a synchronous function', () => { + const field: FieldDescription = { + name: 'city', + type: 'text', + options: () => OPTIONS, + }; + expect(resolveOptions(field, {})).toEqual(OPTIONS); + }); +}); + +describe('createOptionsLoader', () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it('fetches once and reports idle -> loading -> ready', async () => { + const field: FieldDescription = { + name: 'city', + type: 'text', + options: async () => OPTIONS, + }; + const { states, onChange } = collect(); + const loader = createOptionsLoader(field, onChange); + + expect(loader.current().status).toBe('idle'); + loader.update({}); + expect(states.map((s) => s.status)).toEqual(['loading']); + + await vi.runAllTimersAsync(); + + expect(states.map((s) => s.status)).toEqual(['loading', 'ready']); + expect(loader.current().options).toEqual(OPTIONS); + }); + + it('collapses calls inside the debounce window into one fetch', async () => { + const load = vi.fn(async () => OPTIONS); + const field: FieldDescription = { + name: 'city', + type: 'text', + options: load, + debounceMs: 100, + optionsDeps: (data) => [data.country], + }; + const loader = createOptionsLoader(field, () => {}); + + loader.update({ country: 'a' }); + loader.update({ country: 'b' }); + loader.update({ country: 'c' }); + + await vi.advanceTimersByTimeAsync(150); + + expect(load).toHaveBeenCalledTimes(1); + expect(loader.current().options).toEqual(OPTIONS); + }); + + it('does not refetch when the deps are unchanged', async () => { + const load = vi.fn(async () => OPTIONS); + const field: FieldDescription = { + name: 'city', + type: 'text', + options: load, + optionsDeps: (data) => [data.country], + }; + const loader = createOptionsLoader(field, () => {}); + + loader.update({ country: 'vn', unrelated: 1 }); + await vi.runAllTimersAsync(); + loader.update({ country: 'vn', unrelated: 2 }); + await vi.runAllTimersAsync(); + + expect(load).toHaveBeenCalledTimes(1); + }); + + it('refetches when the deps change', async () => { + const load = vi.fn(async () => OPTIONS); + const field: FieldDescription = { + name: 'city', + type: 'text', + options: load, + optionsDeps: (data) => [data.country], + }; + const loader = createOptionsLoader(field, () => {}); + + loader.update({ country: 'vn' }); + await vi.runAllTimersAsync(); + loader.update({ country: 'us' }); + await vi.runAllTimersAsync(); + + expect(load).toHaveBeenCalledTimes(2); + }); + + it('fetches exactly once with no optionsDeps declared', async () => { + const load = vi.fn(async () => OPTIONS); + const field: FieldDescription = { + name: 'city', + type: 'text', + options: load, + }; + const loader = createOptionsLoader(field, () => {}); + + loader.update({ a: 1 }); + loader.update({ a: 2 }); + await vi.runAllTimersAsync(); + + expect(load).toHaveBeenCalledTimes(1); + }); + + it('aborts the previous run when a newer one starts', async () => { + const signals: AbortSignal[] = []; + const field: FieldDescription = { + name: 'city', + type: 'text', + options: async (_data, _root, ctx) => { + signals.push(ctx!.signal); + return OPTIONS; + }, + }; + const loader = createOptionsLoader(field, () => {}); + + loader.setQuery('a'); + loader.setQuery('b'); + await vi.runAllTimersAsync(); + + expect(signals).toHaveLength(2); + expect(signals[0].aborted).toBe(true); + expect(signals[1].aborted).toBe(false); + }); + + it('discards a slow first response that lands after a faster second', async () => { + const field: FieldDescription = { + name: 'city', + type: 'text', + options: async (_data, _root, ctx) => { + if (ctx?.query === 'slow') { + await new Promise((resolve) => setTimeout(resolve, 500)); + return [{ value: 'STALE' }]; + } + return [{ value: 'FRESH' }]; + }, + }; + const loader = createOptionsLoader(field, () => {}); + + loader.setQuery('slow'); + loader.setQuery('fast'); + await vi.advanceTimersByTimeAsync(1000); + + expect(loader.current().options).toEqual([{ value: 'FRESH' }]); + }); + + it('reports a rejection as an error state without throwing', async () => { + const boom = new Error('network down'); + const field: FieldDescription = { + name: 'city', + type: 'text', + optionsMode: 'async', + options: () => Promise.reject(boom), + }; + const loader = createOptionsLoader(field, () => {}); + + loader.update({}); + await vi.runAllTimersAsync(); + + expect(loader.current().status).toBe('error'); + expect(loader.current().error).toBe(boom); + }); + + it('does not report an AbortError as an error state', async () => { + const abortErr = new Error('aborted'); + abortErr.name = 'AbortError'; + const field: FieldDescription = { + name: 'city', + type: 'text', + optionsMode: 'async', + options: () => Promise.reject(abortErr), + }; + const loader = createOptionsLoader(field, () => {}); + + loader.update({}); + await vi.runAllTimersAsync(); + + expect(loader.current().status).not.toBe('error'); + }); + + it('passes the query through to the loader', async () => { + const seen: (string | undefined)[] = []; + const field: FieldDescription = { + name: 'user', + type: 'text', + options: async (_data, _root, ctx) => { + seen.push(ctx?.query); + return OPTIONS; + }, + }; + const loader = createOptionsLoader(field, () => {}); + + loader.setQuery('ada'); + await vi.runAllTimersAsync(); + + expect(seen).toEqual(['ada']); + }); + + it('stops emitting after dispose', async () => { + const { states, onChange } = collect(); + const field: FieldDescription = { + name: 'city', + type: 'text', + options: async () => OPTIONS, + }; + const loader = createOptionsLoader(field, onChange); + + loader.update({}); + loader.dispose(); + await vi.runAllTimersAsync(); + + expect(states.map((s) => s.status)).toEqual(['loading']); + }); +}); From 54619e7a6c6194241a38322ed47aa02ca65ad6c4 Mon Sep 17 00:00:00 2001 From: Van Nguyen Date: Fri, 4 Sep 2026 21:25:20 +0700 Subject: [PATCH 23/31] feat(core): carry options loading state in the renderer prop bag optionsStatus and optionsError join FIELD_RENDERER_PROP_KEYS, so lint:renderer-parity now requires all three adapters to forward them - it currently fails on angular, which the next commits fix. onOptionsQuery is deliberately not in that list: it is a callback, attached alongside onValueChange and onBlur, and putting it there would make the parity script check the wrong kind of thing. --- packages/core/src/rendererProps.ts | 17 +++++++- packages/core/test/rendererProps.test.ts | 54 ++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 1 deletion(-) diff --git a/packages/core/src/rendererProps.ts b/packages/core/src/rendererProps.ts index 1cf8079..adf8ccf 100644 --- a/packages/core/src/rendererProps.ts +++ b/packages/core/src/rendererProps.ts @@ -1,3 +1,4 @@ +import type { OptionsState } from './optionsLoader'; import type { FieldDescription, FieldRendererProps, Properties } from './types'; import { resolveDisabled, @@ -28,6 +29,8 @@ export const FIELD_RENDERER_PROP_KEYS = [ 'dirty', 'error', 'options', + 'optionsStatus', + 'optionsError', 'className', 'description', 'id', @@ -114,6 +117,12 @@ export interface BuildFieldRendererPropsInput { rootData?: Properties; /** Resolved DOM id for this field - see `makeFieldId`. */ id: string; + /** + * Current state of an async options load, from `createOptionsLoader`. + * Omitted for static or synchronous options, where there is nothing to wait + * for and `resolveOptions` already has the answer. + */ + optionsState?: OptionsState; touched?: boolean; dirty?: boolean; /** @@ -161,6 +170,7 @@ export function buildFieldRendererProps({ touched, dirty, validationErrors, + optionsState, }: BuildFieldRendererPropsInput): ResolvedFieldRendererProps { const { name, @@ -182,7 +192,10 @@ export function buildFieldRendererProps({ const disabled = resolveDisabled(fieldDescription, data, rootData); const readOnly = resolveReadOnly(fieldDescription, data, rootData); - const options = resolveOptions(fieldDescription, data, rootData); + // An async loader owns the list; resolveOptions returns undefined for those. + const options = optionsState + ? optionsState.options + : resolveOptions(fieldDescription, data, rootData); // A disabled field is not submitted, so validating it would surface an error // the user cannot act on. @@ -204,6 +217,8 @@ export function buildFieldRendererProps({ dirty, error, options, + optionsStatus: optionsState?.status, + optionsError: optionsState?.error, className, description, id, diff --git a/packages/core/test/rendererProps.test.ts b/packages/core/test/rendererProps.test.ts index af39dd1..a8c97e4 100644 --- a/packages/core/test/rendererProps.test.ts +++ b/packages/core/test/rendererProps.test.ts @@ -273,3 +273,57 @@ describe('reserved props warning', () => { process.env.NODE_ENV = previous; }); }); + +describe('options loading state', () => { + const asyncField: FieldDescription = { + name: 'city', + type: 'text', + options: async () => [{ value: 'hn' }], + }; + + it('takes options and status from the supplied loader state', () => { + const props = buildFieldRendererProps({ + fieldDescription: asyncField, + data: {}, + id: 'f-city', + optionsState: { status: 'ready', options: [{ value: 'hn' }] }, + }); + + expect(props.options).toEqual([{ value: 'hn' }]); + expect(props.optionsStatus).toBe('ready'); + expect(props.optionsError).toBeUndefined(); + }); + + it('surfaces a load failure', () => { + const boom = new Error('down'); + const props = buildFieldRendererProps({ + fieldDescription: asyncField, + data: {}, + id: 'f-city', + optionsState: { status: 'error', error: boom }, + }); + + expect(props.optionsStatus).toBe('error'); + expect(props.optionsError).toBe(boom); + }); + + it('leaves a synchronous field untouched', () => { + const props = buildFieldRendererProps({ + fieldDescription: { + name: 'city', + type: 'text', + options: [{ value: 'hn' }], + }, + data: {}, + id: 'f-city', + }); + + expect(props.options).toEqual([{ value: 'hn' }]); + expect(props.optionsStatus).toBeUndefined(); + }); + + it('declares both new keys in the contract', () => { + expect(FIELD_RENDERER_PROP_KEYS).toContain('optionsStatus'); + expect(FIELD_RENDERER_PROP_KEYS).toContain('optionsError'); + }); +}); From ca09e84a3a615c60787b05722f1ee20b741d7047 Mon Sep 17 00:00:00 2001 From: Van Nguyen Date: Fri, 4 Sep 2026 21:28:27 +0700 Subject: [PATCH 24/31] feat(react): load async field options with debounce and abort Two things this needed beyond wiring the loader in. FieldInput's memo comparator only compares this field's own slice of the data, so a field whose optionsDeps read *another* field would never re-render to notice the change - a country/city pair would load once and never again. Async-options fields now compare the whole data object. resolveOptions now drops a promise returned by a loader that detection missed, and warns with the fix. constructor.name === 'AsyncFunction' does not survive a memoiser, a spy or a transpiler helper, and handing the renderer a pending promise as its option list is worse than an empty one. This mirrors what the validate path already does. --- packages/core/src/validation.ts | 34 +++- packages/core/test/optionsLoader.test.ts | 41 +++- .../react/src/components/DynamicInput.tsx | 4 + packages/react/src/components/FieldInput.tsx | 39 +++- packages/react/test/asyncOptions.test.tsx | 181 ++++++++++++++++++ 5 files changed, 296 insertions(+), 3 deletions(-) create mode 100644 packages/react/test/asyncOptions.test.tsx diff --git a/packages/core/src/validation.ts b/packages/core/src/validation.ts index 6490b19..7ca62d7 100644 --- a/packages/core/src/validation.ts +++ b/packages/core/src/validation.ts @@ -58,6 +58,28 @@ function warnAsyncValidator(key: string): void { ); } +const warnedUndeclaredAsyncOptions = new Set(); + +/** Test-only. Clears the warn-once memo so each case starts from silence. */ +export function __resetOptionsWarnings(): void { + warnedUndeclaredAsyncOptions.clear(); +} + +function warnUndeclaredAsyncOptions(name: string): void { + if (!isDev() || warnedUndeclaredAsyncOptions.has(name)) { + return; + } + warnedUndeclaredAsyncOptions.add(name); + console.warn( + `[dynamic-field-kit] the options function for "${name}" returned a ` + + `Promise, but the field is not declared async, so its options were ` + + `dropped rather than handed to the renderer as a pending promise. ` + + `Native async functions are detected automatically; a loader wrapped ` + + `in a memoiser, a spy or a transpiler helper is not. Add ` + + `\`optionsMode: 'async'\` to the field.`, + ); +} + function isPromiseLike(value: unknown): value is PromiseLike { return ( (typeof value === 'object' || typeof value === 'function') && @@ -114,7 +136,17 @@ export function resolveOptions( return undefined; } if (typeof field.options === 'function') { - return field.options(data, rootData) as Properties[]; + const result = field.options(data, rootData); + if (isPromiseLike(result)) { + // Detection missed it: `constructor.name` is not 'AsyncFunction' for a + // loader wrapped in a memoiser, a spy, or a transpiler's helper. Handing + // the renderer this promise as its option list would be worse than an + // empty list, so drop it and say what to do about it. + void Promise.resolve(result).catch(() => undefined); + warnUndeclaredAsyncOptions(field.name); + return undefined; + } + return result; } return field.options; } diff --git a/packages/core/test/optionsLoader.test.ts b/packages/core/test/optionsLoader.test.ts index 4e762d0..e2b155f 100644 --- a/packages/core/test/optionsLoader.test.ts +++ b/packages/core/test/optionsLoader.test.ts @@ -5,7 +5,7 @@ import { type OptionsState, } from '../src/optionsLoader'; import type { FieldDescription, Properties } from '../src/types'; -import { resolveOptions } from '../src/validation'; +import { __resetOptionsWarnings, resolveOptions } from '../src/validation'; const OPTIONS: Properties[] = [{ label: 'Hanoi', value: 'hn' }]; @@ -275,3 +275,42 @@ describe('createOptionsLoader', () => { expect(states.map((s) => s.status)).toEqual(['loading']); }); }); + +describe('an async loader that detection cannot see', () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => { + vi.useRealTimers(); + __resetOptionsWarnings(); + }); + + it('drops the promise instead of handing it to the renderer, and says why', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const field: FieldDescription = { + name: 'city', + type: 'text', + // A spy wrapper loses `constructor.name === 'AsyncFunction'`, exactly as + // a memoiser or a transpiler helper would. + options: vi.fn(async () => OPTIONS) as never, + }; + + expect(resolveOptions(field, {})).toBeUndefined(); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0][0]).toContain("optionsMode: 'async'"); + warn.mockRestore(); + }); + + it('works normally once optionsMode is declared', async () => { + const field: FieldDescription = { + name: 'city', + type: 'text', + optionsMode: 'async', + options: vi.fn(async () => OPTIONS) as never, + }; + const loader = createOptionsLoader(field, () => {}); + + loader.update({}); + await vi.runAllTimersAsync(); + + expect(loader.current().options).toEqual(OPTIONS); + }); +}); diff --git a/packages/react/src/components/DynamicInput.tsx b/packages/react/src/components/DynamicInput.tsx index 71f7e2a..6168437 100644 --- a/packages/react/src/components/DynamicInput.tsx +++ b/packages/react/src/components/DynamicInput.tsx @@ -35,6 +35,8 @@ interface Props { multiple?: boolean; /** Extra, framework-agnostic props forwarded verbatim to the renderer. */ extraProps?: Properties; + /** Renderer-driven refetch for a search-remote field. */ + onOptionsQuery?: (query: string) => void; } const DynamicInputInner = ({ @@ -42,6 +44,7 @@ const DynamicInputInner = ({ onChange, onBlur, extraProps, + onOptionsQuery, ...rendererProps }: Props) => { const registry = useFieldRegistry(); @@ -74,6 +77,7 @@ const DynamicInputInner = ({ ...(rendererProps as FieldRendererProps), onValueChange: onChange, onBlur, + onOptionsQuery, }); if (!isDefault || !error?.length || !id) { diff --git a/packages/react/src/components/FieldInput.tsx b/packages/react/src/components/FieldInput.tsx index 729cb66..1d20027 100644 --- a/packages/react/src/components/FieldInput.tsx +++ b/packages/react/src/components/FieldInput.tsx @@ -1,10 +1,14 @@ import { buildFieldRendererProps, + createOptionsLoader, + isAsyncOptions, makeFieldId, FieldDescription, + type OptionsLoader, + type OptionsState, Properties, } from '@dynamic-field-kit/core'; -import React, { useCallback } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import DynamicInput from './DynamicInput'; import FieldGroupInput from './FieldGroupInput'; @@ -36,6 +40,27 @@ const FieldInputInner = ({ }: Props) => { const { name, fields } = fieldDescription; + // Async options only. A field with a static or synchronous list allocates + // nothing here and takes the same path it always has. + const isAsync = isAsyncOptions(fieldDescription); + const [optionsState, setOptionsState] = useState( + isAsync ? { status: 'idle' } : undefined, + ); + const loaderRef = useRef(undefined); + if (isAsync && !loaderRef.current) { + loaderRef.current = createOptionsLoader(fieldDescription, setOptionsState); + } + useEffect(() => () => loaderRef.current?.dispose(), []); + useEffect(() => { + // The loader decides whether `optionsDeps` actually changed, so calling it + // on every data change is cheap and keeps that decision in one place. + loaderRef.current?.update(renderInfos, rootData); + }); + + const handleOptionsQuery = useCallback((query: string) => { + loaderRef.current?.setQuery(query); + }, []); + // Stable per-field handler so DynamicInput's memoization isn't defeated // by a freshly-allocated closure on every parent render. const handleChange = useCallback( @@ -74,6 +99,7 @@ const FieldInputInner = ({ touched, dirty, validationErrors: errors === undefined ? undefined : (errors[name] ?? []), + optionsState, }); return ( @@ -82,6 +108,7 @@ const FieldInputInner = ({ description={rendererProps.description as React.ReactNode} onChange={handleChange} onBlur={handleBlur} + onOptionsQuery={isAsync ? handleOptionsQuery : undefined} /> ); }; @@ -91,6 +118,16 @@ const FieldInputInner = ({ // slice this field actually reads instead. const FieldInput = /* @__PURE__ */ React.memo(FieldInputInner, (prev, next) => { const name = prev.fieldDescription.name; + // A field whose options load from `optionsDeps` reads values belonging to + // *other* fields, so the per-field slice check below would skip the render + // that tells its loader anything changed - a country/city pair would never + // reload. Compare the whole data object for those. + if ( + isAsyncOptions(prev.fieldDescription) && + prev.renderInfos !== next.renderInfos + ) { + return false; + } return ( prev.fieldDescription === next.fieldDescription && prev.onValueChangeField === next.onValueChangeField && diff --git a/packages/react/test/asyncOptions.test.tsx b/packages/react/test/asyncOptions.test.tsx new file mode 100644 index 0000000..934a572 --- /dev/null +++ b/packages/react/test/asyncOptions.test.tsx @@ -0,0 +1,181 @@ +import type { + FieldDescription, + FieldRendererProps, + Properties, +} from '@dynamic-field-kit/core'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import React from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import MultiFieldInput from '../src/components/MultiFieldInput'; +import { fieldRegistry } from '../src/fieldRegistry'; +import '../src/layout/defaultLayouts'; + +declare module '@dynamic-field-kit/core' { + interface FieldTypeMap { + optionProbe: string; + text: string; + } +} + +// Renders the option state as text so a test can assert on it without any +// knowledge of how the adapter stores it. +const OptionProbe = ({ + id, + options, + optionsStatus, + optionsError, + onOptionsQuery, +}: FieldRendererProps) => ( +
+ {optionsStatus ?? 'none'} + + {(options ?? []).map((o) => String(o.value)).join(',')} + + + {optionsError ? String((optionsError as Error).message) : ''} + + +
+); + +describe('async field options', () => { + beforeEach(() => { + fieldRegistry.register('optionProbe', OptionProbe); + }); + + it('goes loading then ready and shows the resolved options', async () => { + const fields: FieldDescription[] = [ + { + name: 'city', + type: 'optionProbe', + options: async () => [{ value: 'hn' }, { value: 'sg' }], + }, + ]; + + render(); + + await waitFor(() => + expect(screen.getByTestId('a-city-status')).toHaveTextContent('ready'), + ); + expect(screen.getByTestId('a-city-options')).toHaveTextContent('hn,sg'); + }); + + it('passes the renderer query through to the loader', async () => { + const load = vi.fn(async (_d: Properties, _r, ctx) => [ + { value: ctx?.query ?? 'none' }, + ]); + const fields: FieldDescription[] = [ + { + name: 'user', + type: 'optionProbe', + optionsMode: 'async', + options: load, + }, + ]; + + render(); + await waitFor(() => + expect(screen.getByTestId('b-user-status')).toHaveTextContent('ready'), + ); + + screen.getByTestId('b-user-search').click(); + + await waitFor(() => + expect(screen.getByTestId('b-user-options')).toHaveTextContent('ada'), + ); + }); + + it('reports a failed load', async () => { + const fields: FieldDescription[] = [ + { + name: 'city', + type: 'optionProbe', + options: async () => { + throw new Error('network down'); + }, + }, + ]; + + render(); + + await waitFor(() => + expect(screen.getByTestId('c-city-status')).toHaveTextContent('error'), + ); + expect(screen.getByTestId('c-city-error')).toHaveTextContent( + 'network down', + ); + }); + + it('leaves a synchronous field with no options status at all', () => { + const fields: FieldDescription[] = [ + { + name: 'city', + type: 'optionProbe', + options: [{ value: 'hn' }], + }, + ]; + + render(); + + expect(screen.getByTestId('d-city-status')).toHaveTextContent('none'); + expect(screen.getByTestId('d-city-options')).toHaveTextContent('hn'); + }); +}); + +describe('dependent async options', () => { + beforeEach(() => { + fieldRegistry.register('optionProbe', OptionProbe); + fieldRegistry.register('text', (({ id, value, onValueChange }) => ( + onValueChange?.(e.target.value)} + /> + )) as never); + }); + + it('reloads when another field it depends on changes', async () => { + const load = vi.fn(async (data: Properties) => [ + { value: `${data.country}-city` }, + ]); + const fields: FieldDescription[] = [ + { name: 'country', type: 'text' }, + { + name: 'city', + type: 'optionProbe', + optionsMode: 'async', + options: load, + optionsDeps: (data) => [data.country], + // Without this the mount pair - data starts empty, then properties + // arrive from MultiFieldInput's effect - would be two separate loads. + debounceMs: 50, + }, + ]; + + render( + , + ); + + await waitFor(() => + expect(screen.getByTestId('e-city-options')).toHaveTextContent('vn-city'), + ); + + fireEvent.change(screen.getByTestId('e-country'), { + target: { value: 'us' }, + }); + + await waitFor(() => + expect(screen.getByTestId('e-city-options')).toHaveTextContent('us-city'), + ); + expect(load).toHaveBeenCalledTimes(2); + }); +}); From 5a461ce07f83833c773e0969a6704be4a618ea65 Mon Sep 17 00:00:00 2001 From: Van Nguyen Date: Fri, 4 Sep 2026 21:29:36 +0700 Subject: [PATCH 25/31] feat(vue): load async field options with debounce and abort Mirrors the React adapter. The watch is deep on the whole data object because optionsDeps can read another field's value, and the loader - not the component - decides whether anything it cares about changed. --- packages/vue/src/components/DynamicInput.ts | 23 +++- packages/vue/src/components/FieldInput.ts | 42 ++++++- packages/vue/test/asyncOptions.test.ts | 129 ++++++++++++++++++++ 3 files changed, 192 insertions(+), 2 deletions(-) create mode 100644 packages/vue/test/asyncOptions.test.ts diff --git a/packages/vue/src/components/DynamicInput.ts b/packages/vue/src/components/DynamicInput.ts index 6576471..2300a22 100644 --- a/packages/vue/src/components/DynamicInput.ts +++ b/packages/vue/src/components/DynamicInput.ts @@ -1,4 +1,9 @@ -import { FieldTypeKey, makeErrorId, Properties } from '@dynamic-field-kit/core'; +import { + FieldTypeKey, + makeErrorId, + type OptionsStatus, + Properties, +} from '@dynamic-field-kit/core'; import { defineComponent, computed, h, PropType } from 'vue'; import { getDefaultRenderer } from '../defaultRenderers'; import { useFieldRegistry } from '../fieldRegistryContext'; @@ -52,6 +57,14 @@ const DynamicInput = /* @__PURE__ */ defineComponent({ type: Array as PropType, default: undefined, }, + optionsStatus: { + type: String as PropType, + default: undefined, + }, + optionsError: { + type: null, + default: undefined, + }, className: { type: String, default: undefined, @@ -108,6 +121,11 @@ const DynamicInput = /* @__PURE__ */ defineComponent({ type: Boolean, default: undefined, }, + // Renderer-driven refetch for a search-remote field. + onOptionsQuery: { + type: Function as PropType<(query: string) => void>, + default: undefined, + }, // Extra, framework-agnostic props forwarded verbatim to the renderer. extraProps: { type: Object as PropType, @@ -139,6 +157,7 @@ const DynamicInput = /* @__PURE__ */ defineComponent({ 'onUpdate:value': props.onChange, onValueChange: props.onChange, onBlur: props.onBlur, + onOptionsQuery: props.onOptionsQuery, label: props.label, placeholder: props.placeholder, required: props.required, @@ -146,6 +165,8 @@ const DynamicInput = /* @__PURE__ */ defineComponent({ dirty: props.dirty, error: props.error, options: props.options, + optionsStatus: props.optionsStatus, + optionsError: props.optionsError, // Vue's name for the contract's `className`, and the one intentional // deviation from it. Forwarding `className` as well is not an option: // a renderer that does not declare it lets the key fall through to its diff --git a/packages/vue/src/components/FieldInput.ts b/packages/vue/src/components/FieldInput.ts index 389b9fd..f2c2982 100644 --- a/packages/vue/src/components/FieldInput.ts +++ b/packages/vue/src/components/FieldInput.ts @@ -1,10 +1,21 @@ import { buildFieldRendererProps, + createOptionsLoader, + isAsyncOptions, makeFieldId, + type OptionsState, FieldDescription, Properties, } from '@dynamic-field-kit/core'; -import { defineComponent, h, PropType } from 'vue'; +import { + defineComponent, + getCurrentScope, + h, + onScopeDispose, + PropType, + shallowRef, + watch, +} from 'vue'; import DynamicInput from './DynamicInput'; const FieldInput = /* @__PURE__ */ defineComponent({ @@ -49,6 +60,31 @@ const FieldInput = /* @__PURE__ */ defineComponent({ }, }, setup(props) { + // Async options only. A static or synchronous list allocates nothing here + // and takes the path it always has. + const isAsync = isAsyncOptions(props.fieldDescription); + const optionsState = shallowRef( + isAsync ? { status: 'idle' } : undefined, + ); + const loader = isAsync + ? createOptionsLoader(props.fieldDescription, (state) => { + optionsState.value = state; + }) + : undefined; + + if (loader) { + // The loader decides whether `optionsDeps` actually changed, so watching + // the whole data object keeps that decision in one place. + watch( + () => props.renderInfos, + (data) => loader.update(data, props.rootData), + { immediate: true, deep: true }, + ); + if (getCurrentScope()) { + onScopeDispose(() => loader.dispose()); + } + } + return () => { const { name } = props.fieldDescription; @@ -61,12 +97,16 @@ const FieldInput = /* @__PURE__ */ defineComponent({ dirty: props.dirty, validationErrors: props.errors === undefined ? undefined : (props.errors[name] ?? []), + optionsState: optionsState.value, }); return h(DynamicInput, { ...rendererProps, onChange: (v: unknown) => props.onValueChangeField(v, name), onBlur: () => props.onBlurField?.(name), + onOptionsQuery: loader + ? (query: string) => loader.setQuery(query) + : undefined, }); }; }, diff --git a/packages/vue/test/asyncOptions.test.ts b/packages/vue/test/asyncOptions.test.ts new file mode 100644 index 0000000..6474a50 --- /dev/null +++ b/packages/vue/test/asyncOptions.test.ts @@ -0,0 +1,129 @@ +import type { FieldDescription, Properties } from '@dynamic-field-kit/core'; +import { FieldRegistry } from '@dynamic-field-kit/core'; +import { flushPromises, mount } from '@vue/test-utils'; +import { describe, expect, it, vi } from 'vitest'; +import { defineComponent, h } from 'vue'; +import MultiFieldInput from '../src/components/MultiFieldInput'; +import { FieldRegistryKey } from '../src/fieldRegistryContext'; +import '../src/layout/defaultLayouts'; + +const OptionProbe = defineComponent({ + props: { + id: String, + options: Array, + optionsStatus: String, + optionsError: null, + onOptionsQuery: Function, + }, + setup: (props) => () => + h('div', [ + h( + 'span', + { 'data-testid': `${props.id}-status` }, + props.optionsStatus ?? 'none', + ), + h( + 'span', + { 'data-testid': `${props.id}-options` }, + ((props.options ?? []) as Properties[]) + .map((o) => String(o.value)) + .join(','), + ), + h( + 'button', + { + 'data-testid': `${props.id}-search`, + onClick: () => (props.onOptionsQuery as (q: string) => void)?.('ada'), + }, + 'search', + ), + ]), +}); + +function mountWith(fields: FieldDescription[], idPrefix: string) { + const registry = new FieldRegistry(); + registry.register('optionProbe' as never, OptionProbe as never); + return mount(MultiFieldInput, { + props: { fieldDescriptions: fields, idPrefix }, + global: { provide: { [FieldRegistryKey]: registry } }, + }); +} + +describe('async field options', () => { + it('goes loading then ready and shows the resolved options', async () => { + const wrapper = mountWith( + [ + { + name: 'city', + type: 'optionProbe' as never, + options: async () => [{ value: 'hn' }, { value: 'sg' }], + }, + ], + 'a', + ); + + await flushPromises(); + + expect(wrapper.get('[data-testid="a-city-status"]').text()).toBe('ready'); + expect(wrapper.get('[data-testid="a-city-options"]').text()).toBe('hn,sg'); + }); + + it('passes the renderer query through to the loader', async () => { + const load = vi.fn(async (_d: Properties, _r?: Properties, ctx?) => [ + { value: ctx?.query ?? 'none' }, + ]); + const wrapper = mountWith( + [ + { + name: 'user', + type: 'optionProbe' as never, + optionsMode: 'async', + options: load, + }, + ], + 'b', + ); + await flushPromises(); + + await wrapper.get('[data-testid="b-user-search"]').trigger('click'); + await flushPromises(); + + expect(wrapper.get('[data-testid="b-user-options"]').text()).toBe('ada'); + }); + + it('reports a failed load', async () => { + const wrapper = mountWith( + [ + { + name: 'city', + type: 'optionProbe' as never, + options: async () => { + throw new Error('network down'); + }, + }, + ], + 'c', + ); + + await flushPromises(); + + expect(wrapper.get('[data-testid="c-city-status"]').text()).toBe('error'); + }); + + it('leaves a synchronous field with no options status at all', async () => { + const wrapper = mountWith( + [ + { + name: 'city', + type: 'optionProbe' as never, + options: [{ value: 'hn' }], + }, + ], + 'd', + ); + await flushPromises(); + + expect(wrapper.get('[data-testid="d-city-status"]').text()).toBe('none'); + expect(wrapper.get('[data-testid="d-city-options"]').text()).toBe('hn'); + }); +}); From f74a88d4fca3551e5abdd4c3bfce764bcfaa5ebb Mon Sep 17 00:00:00 2001 From: Van Nguyen Date: Fri, 4 Sep 2026 21:32:00 +0700 Subject: [PATCH 26/31] feat(angular): load async field options with debounce and abort Completes the three-adapter loader; lint:renderer-parity now reports 23 props instead of 21. The loader callback calls markForCheck: these components are OnPush, so an async arrival happens outside any event the view is checked for and the options would otherwise load and never appear. onOptionsQuery is declared on BaseInputComponent only - redeclaring it on DynamicInput is a TS4114-class error under useDefineForClassFields. --- packages/angular/src/components/BaseInput.ts | 9 ++ .../angular/src/components/DynamicInput.ts | 2 + packages/angular/src/components/FieldInput.ts | 43 +++++++- packages/angular/test/asyncOptions.spec.ts | 104 ++++++++++++++++++ .../react/src/components/DynamicInput.tsx | 3 + 5 files changed, 160 insertions(+), 1 deletion(-) create mode 100644 packages/angular/test/asyncOptions.spec.ts diff --git a/packages/angular/src/components/BaseInput.ts b/packages/angular/src/components/BaseInput.ts index 6591803..b593330 100644 --- a/packages/angular/src/components/BaseInput.ts +++ b/packages/angular/src/components/BaseInput.ts @@ -8,6 +8,8 @@ import { SimpleChanges, } from '@angular/core'; +import type { OptionsStatus } from '@dynamic-field-kit/core'; + // Mirrors the framework-agnostic FieldRendererProps (core's // FIELD_RENDERER_PROP_KEYS). Domain-specific inputs (acceptFile, maxLength, // ...) intentionally live on the individual renderer, not here - pass them per @@ -23,6 +25,9 @@ export interface FieldInputProps { dirty?: boolean; error?: string | string[]; options?: unknown[]; + optionsStatus?: OptionsStatus; + optionsError?: unknown; + onOptionsQuery?: (query: string) => void; className?: string; description?: string; id?: string; @@ -56,6 +61,10 @@ export abstract class BaseInputComponent implements OnChanges { @Input() dirty?: boolean; @Input() error?: string | string[]; @Input() options?: unknown[]; + @Input() optionsStatus?: OptionsStatus; + @Input() optionsError?: unknown; + /** Renderer-driven refetch for a search-remote field. */ + @Input() onOptionsQuery?: (query: string) => void; @Input() className?: string; @Input() description?: string; @Input() id?: string; diff --git a/packages/angular/src/components/DynamicInput.ts b/packages/angular/src/components/DynamicInput.ts index 9bc91d4..47e0356 100644 --- a/packages/angular/src/components/DynamicInput.ts +++ b/packages/angular/src/components/DynamicInput.ts @@ -37,6 +37,8 @@ const KNOWN_PROPS = [ 'dirty', 'error', 'options', + 'optionsStatus', + 'optionsError', 'className', 'description', 'id', diff --git a/packages/angular/src/components/FieldInput.ts b/packages/angular/src/components/FieldInput.ts index eb543ec..4079de9 100644 --- a/packages/angular/src/components/FieldInput.ts +++ b/packages/angular/src/components/FieldInput.ts @@ -6,11 +6,16 @@ import { EventEmitter, Input, OnChanges, + OnDestroy, Output, SimpleChanges, } from '@angular/core'; import { buildFieldRendererProps, + createOptionsLoader, + isAsyncOptions, + type OptionsLoader, + type OptionsState, makeFieldId, FieldDescription, Properties, @@ -33,6 +38,8 @@ import { DynamicInput } from './DynamicInput'; [required]="p.required" [description]="$any(p.description)" [options]="$any(p.options)" + [optionsStatus]="p.optionsStatus" + [optionsError]="p.optionsError" [className]="p.className" [disabled]="p.disabled" [readOnly]="p.readOnly" @@ -53,10 +60,11 @@ import { DynamicInput } from './DynamicInput'; " (focusout)="onBlurField.emit(fieldDescription!.name)" [extraProps]="p.extraProps" + [onOptionsQuery]="onOptionsQuery" > `, }) -export class FieldInput implements OnChanges { +export class FieldInput implements OnChanges, OnDestroy { @Input() fieldDescription?: FieldDescription; /** * Data at this field's own level. Preferred over `value`: the shared @@ -101,11 +109,43 @@ export class FieldInput implements OnChanges { constructor(private cdr: ChangeDetectorRef) {} + // Async options only; a static or synchronous list allocates nothing here. + private loader?: OptionsLoader; + private optionsState?: OptionsState; + + /** Bound into the template so a renderer can drive a search-remote refetch. */ + onOptionsQuery = (query: string): void => { + this.loader?.setQuery(query); + }; + ngOnChanges(_changes: SimpleChanges): void { + this.syncOptionsLoader(); this.rendererProps = this.buildProps(); this.cdr.markForCheck(); } + ngOnDestroy(): void { + this.loader?.dispose(); + } + + private syncOptionsLoader(): void { + const field = this.fieldDescription; + if (!field || !isAsyncOptions(field)) { + return; + } + if (!this.loader) { + this.optionsState = { status: 'idle' }; + this.loader = createOptionsLoader(field, (state) => { + this.optionsState = state; + this.rendererProps = this.buildProps(); + // OnPush: an async arrival happens outside any event this view is + // checked for, so without this the options would load and never appear. + this.cdr.markForCheck(); + }); + } + this.loader.update(this.data ?? {}, this.rootData); + } + private buildProps(): ResolvedFieldRendererProps | null { const field = this.fieldDescription; if (!field) { @@ -120,6 +160,7 @@ export class FieldInput implements OnChanges { id: makeFieldId(field, this.idPrefix), touched: this.touched, dirty: this.dirty, + optionsState: this.optionsState, }); // Explicitly bound inputs override what the field description resolves to, diff --git a/packages/angular/test/asyncOptions.spec.ts b/packages/angular/test/asyncOptions.spec.ts new file mode 100644 index 0000000..37a7554 --- /dev/null +++ b/packages/angular/test/asyncOptions.spec.ts @@ -0,0 +1,104 @@ +import { Component, Input } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import type { FieldDescription, Properties } from '@dynamic-field-kit/core'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { MultiFieldInput } from '../src/components/MultiFieldInput'; +import { FIELD_REGISTRY } from '../src/fieldRegistryToken'; +import { makeRegistry } from './helpers/renderers'; + +@Component({ + selector: 'dfk-option-probe', + standalone: true, + template: `{{ optionsStatus ?? 'none' }}{{ optionValues }}`, +}) +class OptionProbeComponent { + @Input() options?: unknown[]; + @Input() optionsStatus?: string; + @Input() optionsError?: unknown; + @Input() onOptionsQuery?: (q: string) => void; + + get optionValues(): string { + return ((this.options ?? []) as Properties[]) + .map((o) => String(o.value)) + .join(','); + } +} + +async function mountField(fields: FieldDescription[]) { + const registry = makeRegistry(); + registry.register('optionProbe' as never, OptionProbeComponent as never); + TestBed.configureTestingModule({ + imports: [MultiFieldInput], + providers: [{ provide: FIELD_REGISTRY, useValue: registry }], + }); + const fixture = TestBed.createComponent(MultiFieldInput); + fixture.componentRef.setInput('fieldDescriptions', fields); + fixture.componentRef.setInput('properties', {}); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + return fixture; +} + +describe('async field options', () => { + beforeEach(() => TestBed.resetTestingModule()); + + it('goes ready and shows the resolved options', async () => { + const fixture = await mountField([ + { + name: 'city', + type: 'optionProbe' as never, + options: async () => [{ value: 'hn' }, { value: 'sg' }], + }, + ]); + + expect(fixture.nativeElement.querySelector('.status').textContent).toBe( + 'ready', + ); + expect(fixture.nativeElement.querySelector('.opts').textContent).toBe( + 'hn,sg', + ); + }); + + it('reports a failed load', async () => { + const fixture = await mountField([ + { + name: 'city', + type: 'optionProbe' as never, + options: async () => { + throw new Error('network down'); + }, + }, + ]); + + expect(fixture.nativeElement.querySelector('.status').textContent).toBe( + 'error', + ); + }); + + it('leaves a synchronous field with no options status at all', async () => { + const fixture = await mountField([ + { + name: 'city', + type: 'optionProbe' as never, + options: [{ value: 'hn' }], + }, + ]); + + expect(fixture.nativeElement.querySelector('.status').textContent).toBe( + 'none', + ); + expect(fixture.nativeElement.querySelector('.opts').textContent).toBe('hn'); + }); + + it('never calls a synchronous options function through the loader', async () => { + const load = vi.fn(() => [{ value: 'hn' }]); + await mountField([ + { name: 'city', type: 'optionProbe' as never, options: load }, + ]); + + expect(load).toHaveBeenCalled(); + expect(load.mock.calls[0]).toHaveLength(2); + }); +}); diff --git a/packages/react/src/components/DynamicInput.tsx b/packages/react/src/components/DynamicInput.tsx index 6168437..0ba1c4e 100644 --- a/packages/react/src/components/DynamicInput.tsx +++ b/packages/react/src/components/DynamicInput.tsx @@ -2,6 +2,7 @@ import { FieldRendererProps, FieldTypeKey, makeErrorId, + type OptionsStatus, Properties, } from '@dynamic-field-kit/core'; import React, { ReactNode, useMemo } from 'react'; @@ -16,6 +17,8 @@ interface Props { label?: string; placeholder?: string; options?: Properties[]; + optionsStatus?: OptionsStatus; + optionsError?: unknown; className?: string; description?: ReactNode; disabled?: boolean; From 1bbdfe75190cbbcfd3cc89f2eb9e0b982057a87c Mon Sep 17 00:00:00 2001 From: Van Nguyen Date: Fri, 4 Sep 2026 21:33:23 +0700 Subject: [PATCH 27/31] docs: async options reference and 1.7.0 migration notes --- .changeset/olive-hounds-look.md | 19 ++++++++++++ README.md | 55 +++++++++++++++++++++++++++++++++ docs/MIGRATING.md | 31 +++++++++++++++++++ docs/ui-kit-recipes.md | 51 ++++++++++++++++++++++++++++++ 4 files changed, 156 insertions(+) create mode 100644 .changeset/olive-hounds-look.md diff --git a/.changeset/olive-hounds-look.md b/.changeset/olive-hounds-look.md new file mode 100644 index 0000000..707f903 --- /dev/null +++ b/.changeset/olive-hounds-look.md @@ -0,0 +1,19 @@ +--- +'@dynamic-field-kit/core': minor +'@dynamic-field-kit/react': minor +'@dynamic-field-kit/vue': minor +'@dynamic-field-kit/angular': minor +--- + +`options` can now return a promise, covering both dependent selects +(`optionsDeps`) and search-remote pickers (`onOptionsQuery`). Renderers receive +`optionsStatus` and `optionsError` alongside `options`. + +`debounceMs` was declared on `FieldDescription`, published in the `.d.ts` and +read by no implementation anywhere - setting it did nothing. It now debounces +these loads. + +Debounce, abort of a superseded request, and discarding a response that lands +out of order all live in core's `createOptionsLoader`, so the three adapters +share one implementation. Synchronous and static options are untouched and never +enter a loading state. diff --git a/README.md b/README.md index 0014dca..baaa403 100644 --- a/README.md +++ b/README.md @@ -761,3 +761,58 @@ mechanism is here, the translations are yours. A placeholder with no matching param is left in the string verbatim rather than replaced with `undefined`, so a typo shows up as a visible `{unit}` instead of a mystery. + +### Async options + +`options` can return a promise. Two shapes are covered, and the difference is +what triggers a reload. + +**Dependent options** — the reload is driven by form data: + +```ts +{ + name: 'city', + type: 'select', + options: async (data, _rootData, ctx) => + fetch(`/api/cities?country=${data.country}`, { signal: ctx?.signal }) + .then((r) => r.json()), + optionsDeps: (data) => [data.country], + debounceMs: 200, +} +``` + +**Search-remote** — the reload is driven by the renderer's own search box, +which the form data never sees. The renderer calls `onOptionsQuery`: + +```ts +{ + name: 'assignee', + type: 'userPicker', + options: async (_data, _rootData, ctx) => + fetch(`/api/users?q=${ctx?.query ?? ''}`, { signal: ctx?.signal }) + .then((r) => r.json()), + debounceMs: 300, +} +``` + +The renderer receives `optionsStatus` (`'idle' | 'loading' | 'ready' | 'error'`), +`optionsError`, and `onOptionsQuery`. + +| Field property | Effect | +| -------------- | ------------------------------------------------------------------------------ | +| `optionsDeps` | Values a reload depends on, compared shallowly. Defaults to `[]` — fetch once. | +| `optionsMode` | `'async'` for a loader that returns a promise without the `async` keyword. | +| `debounceMs` | Collapses rapid reloads into one fetch. Applies to async options only. | + +Superseded requests are aborted through `ctx.signal`, and a slow response that +lands after a newer one is discarded, so the option list always reflects the +most recent request rather than the last one to arrive. + +Native `async` functions are detected automatically. A loader wrapped in a +memoiser, a spy or a transpiler helper is **not** — `constructor.name` is no +longer `'AsyncFunction'`. Declare `optionsMode: 'async'` for those; without it +the promise is dropped and a development warning says so. + +Note that a form whose `properties` arrive after mount sees its data change +twice (empty, then loaded), which is two loads without a `debounceMs`. Setting +one collapses them. diff --git a/docs/MIGRATING.md b/docs/MIGRATING.md index 4cfd021..bf75090 100644 --- a/docs/MIGRATING.md +++ b/docs/MIGRATING.md @@ -152,3 +152,34 @@ translate its own messages. Nothing is required of existing validators. Skips empty values so `required` owns that case rather than both firing at once, and compares with `Object.is` so two `NaN`s match. + +### `debounceMs` finally does something + +Before 1.7.0 `debounceMs` was declared on `FieldDescription` and published in +the `.d.ts`, but no implementation read it anywhere. Setting it did nothing at +all. + +It now debounces async options loading. **Nobody's behaviour changes**, because +there was no behaviour — but if you set it hoping it would debounce +_validation_, it still does not. Async validation is debounced by not running on +the live pass at all; see `validationMode`. + +### Async options + +`options` may now return a promise, and the renderer gets `optionsStatus`, +`optionsError` and `onOptionsQuery` alongside it. + +| Before | After | +| --------------------------------------------------------------- | ---------------------------------------- | +| renderer manages its own `loading`/`options` state and debounce | `options: async (data, _root, ctx) => …` | +| no way to express "reload when country changes" | `optionsDeps: (data) => [data.country]` | +| search box wired by hand inside the renderer | `onOptionsQuery(query)` | + +Fully additive. A static array or a synchronous `(data, rootData) => Options[]` +behaves exactly as before and never enters a loading state. + +One detail worth knowing: `options` keeps a **single** signature, +`(data, rootData?, ctx?)`, rather than becoming a union of a sync and an async +shape. A union of two function types defeats TypeScript's contextual inference, +which would have made every existing `options: (data) => …` an implicit-`any` +error. Returning a promise is what makes a loader async. diff --git a/docs/ui-kit-recipes.md b/docs/ui-kit-recipes.md index 22692af..15f9ddc 100644 --- a/docs/ui-kit-recipes.md +++ b/docs/ui-kit-recipes.md @@ -254,3 +254,54 @@ correct — there is nothing to clear. If you use the built-in renderers you get all of this already; they render the message node themselves. + +## Async options in a renderer + +A field with an async loader hands the renderer three extra props. A minimal +search-remote picker uses all three: + +```tsx +import { type FieldRendererProps } from '@dynamic-field-kit/react'; + +function UserPicker({ + id, + options, + optionsStatus, + optionsError, + onOptionsQuery, + value, + onValueChange, + onBlur, +}: FieldRendererProps) { + return ( +
+ onOptionsQuery?.(e.target.value)} + onBlur={onBlur} + /> + {optionsStatus === 'loading' && } + {optionsStatus === 'error' && ( + {String((optionsError as Error)?.message)} + )} +
    + {(options ?? []).map((o) => ( +
  • + +
  • + ))} +
+
+ ); +} +``` + +`optionsStatus` is `undefined` for a field with static or synchronous options, +so `optionsStatus === 'loading'` is safely `false` there — one renderer works +for both. + +Do not debounce inside the renderer. `debounceMs` on the field already collapses +rapid `onOptionsQuery` calls, and a second layer would only add latency. From 930ef6425beea31a1989a62a1092a4c51d59dcb8 Mon Sep 17 00:00:00 2001 From: Van Nguyen Date: Fri, 4 Sep 2026 21:34:09 +0700 Subject: [PATCH 28/31] style: format the angular store spec --- packages/angular/test/dynamicFormStore.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/angular/test/dynamicFormStore.spec.ts b/packages/angular/test/dynamicFormStore.spec.ts index 8f15d82..6db4bdf 100644 --- a/packages/angular/test/dynamicFormStore.spec.ts +++ b/packages/angular/test/dynamicFormStore.spec.ts @@ -1,4 +1,4 @@ -import { FieldDescription, validators } from '@dynamic-field-kit/core'; +import { FieldDescription, validators } from '@dynamic-field-kit/core'; import { describe, expect, it, vi } from 'vitest'; import { createDynamicFormStore } from '../src/lib/dynamic-form.store'; From 2cc098f377eacde574e42656e2806af74095446f Mon Sep 17 00:00:00 2001 From: Van Nguyen Date: Fri, 4 Sep 2026 21:47:00 +0700 Subject: [PATCH 29/31] docs: bring the four package READMEs up to date with 1.7.0 The root README covered the new API; the per-package ones did not mention any of it. Each adapter README now documents baselineValues, getDirtyValues, the messages catalog, initialProperties, async options and the default renderers' new error node, and the core README carries the full catalog key table and the async options reference the adapters link to. One paragraph in the core README had become actively wrong: it still said ariaDescribedBy is the one prop no adapter fills in. It now explains what replaced that and why the old reasoning, though sound, left focusFirstInvalidField doing nothing. --- packages/angular/README.md | 109 ++++++++++++++++++++++++++------- packages/core/README.md | 113 +++++++++++++++++++++++++++++++--- packages/react/README.md | 120 ++++++++++++++++++++++++++++++------- packages/vue/README.md | 101 ++++++++++++++++++++++++------- 4 files changed, 372 insertions(+), 71 deletions(-) diff --git a/packages/angular/README.md b/packages/angular/README.md index 807c3eb..d8b140c 100644 --- a/packages/angular/README.md +++ b/packages/angular/README.md @@ -41,7 +41,7 @@ npm install @dynamic-field-kit/core@^1.5.0 @dynamic-field-kit/angular@^1.5.0 `dirty`, `id` and the aria flags used to be missing here, which left an Angular renderer no way to tell whether a field had been touched - `DynamicFormOptions` — what `createDynamicFormStore` takes: `fields`, - `initialValues`, `validateOnBlur`, `validateOnChange` + `initialValues`, `validateOnBlur`, `validateOnChange`, `messages` - `LayoutConfig` / `ColumnLayoutConfig` / `RowLayoutConfig` / `GridLayoutConfig` — the layout config types, re-exported from core - `BaseLayoutConfig` / `ResponsiveLayoutConfig` — this adapter's historical @@ -59,7 +59,15 @@ both packages: - `validators` — the built-in validator helpers (`required`, `email`, `minLength`, `compose`, …) - `FieldDescription` / `FieldTypeKey` / `FieldRendererProps` — the schema and renderer contracts every adapter shares -- `ValidationResult` / `ValidationContext` +- `ValidationResult` / `ValidationContext` — the context carries `signal` and the + optional `t` message resolver +- `buildFieldRendererProps` / `makeFieldId` / `makeErrorId` / + `FIELD_RENDERER_PROP_KEYS` — the renderer prop contract. `makeErrorId(id)` is + what a custom renderer puts on its message element so `aria-describedby` + resolves +- `createOptionsLoader` / `isAsyncOptions` — the async options engine +- `createMessageResolver` / `setDefaultMessages` / `MessageCatalog` — validation + message catalog `createDynamicFormStore` keeps live validation synchronous - a validator declared or detected as async is never invoked on that path. Its `handleSubmit` runs one @@ -170,6 +178,7 @@ import { [errors]="store.errors()" (onChange)="store.handleChange($event)" (onBlurField)="store.handleBlur($event)" + [initialProperties]="store.baselineValues()" >
@@ -181,6 +190,7 @@ export class MyForm { fields, initialValues: { country: 'VN' }, validateOnBlur: true, // default + messages: { required: 'Bắt buộc' }, // optional; see Validation & conditions }); // handleSubmit returns a handler, exactly like React and Vue. @@ -188,26 +198,28 @@ export class MyForm { } ``` -| Member | Description | -| ----------------------------------- | --------------------------------------------------------------------------------- | -| `data()` | Current form data, with `computeValue` fields applied | -| `errors()` | `Record`, keyed like `validateFields` | -| `isValid()` / `isDirty()` | Current synchronous validity / any value has changed | -| `isValidating()` | An async validation pass is in flight | -| `isValidationComplete()` | Every applicable validator finished and none is in flight | -| `validationStatus()` | `'valid' | 'invalid' | 'pending'`— prefer it over`isValid`alone:`valid` cannot tell "nothing is wrong" from "nothing is wrong yet" | -| `isSubmitting()` / `isSubmitted()` | In-flight submit / at least one submit attempted | -| `touched()` | Fields that have been blurred | -| `handleChange(data)` | Replace the whole form data — bind to `(onChange)` | -| `setFieldValue(name, value)` | Change one field | -| `handleBlur(name)` | Mark touched, and validate when `validateOnBlur` | -| `setFieldTouched(name, value?)` | Set touched explicitly | -| `touchAll()` | Mark every field touched — `handleSubmit` already calls it | -| `resetTouched()` | Clear touched only, leaving data/errors/dirty alone | -| `validate()` | Validate now, returns a boolean | -| `validateAsync()` | Validate now, awaiting Promise-based rules | -| `reset(values?)` | Back to `initialValues` (or the values given), clearing errors/touched/submission | -| `handleSubmit(onValid, onInvalid?)` | Returns an async handler; calls `preventDefault`, validates, then dispatches | +| Member | Description | +| ----------------------------------- | -------------------------------------------------------------------------------------------------------------- | +| `data()` | Current form data, with `computeValue` fields applied | +| `errors()` | `Record`, keyed like `validateFields` | +| `isValid()` / `isDirty()` | Current synchronous validity / any value has changed | +| `baselineValues()` | Signal holding the values `dirty` is measured against - `initialValues` until `reset(newValues)` replaces them | +| `getDirtyValues()` | Only the entries differing from `baselineValues`, for PATCH-style submits | +| `isValidating()` | An async validation pass is in flight | +| `isValidationComplete()` | Every applicable validator finished and none is in flight | +| `validationStatus()` | `'valid' | 'invalid' | 'pending'`— prefer it over`isValid`alone:`valid` cannot tell "nothing is wrong" from "nothing is wrong yet" | +| `isSubmitting()` / `isSubmitted()` | In-flight submit / at least one submit attempted | +| `touched()` | Fields that have been blurred | +| `handleChange(data)` | Replace the whole form data — bind to `(onChange)` | +| `setFieldValue(name, value)` | Change one field | +| `handleBlur(name)` | Mark touched, and validate when `validateOnBlur` | +| `setFieldTouched(name, value?)` | Set touched explicitly | +| `touchAll()` | Mark every field touched — `handleSubmit` already calls it | +| `resetTouched()` | Clear touched only, leaving data/errors/dirty alone | +| `validate()` | Validate now, returns a boolean | +| `validateAsync()` | Validate now, awaiting Promise-based rules | +| `reset(values?)` | Back to `initialValues` (or the values given), clearing errors/touched/submission | +| `handleSubmit(onValid, onInvalid?)` | Returns an async handler; calls `preventDefault`, validates, then dispatches | `MultiFieldInput` emits `(onBlurField)` with the field's name, driven by a `focusout` listener — so it works with any renderer, without the renderer @@ -247,6 +259,13 @@ Any type you have not registered falls back to one of these. `file` emits a `File` (or `File[]` when `multiple` is set), `range` and `number` emit numbers, `checkbox` / `switch` emit booleans; everything else emits strings. +Since 1.7.0 a default renderer also renders its validation message, as +`