@formbar/react 0.14.0 → 0.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -127,7 +127,8 @@ function useForm(options) {
127
127
  const subscribe = react.useRef((onStoreChange) => {
128
128
  return form.subscribe(onStoreChange);
129
129
  }).current;
130
- react.useSyncExternalStore(subscribe, () => form.getState());
130
+ const getSnapshot = react.useRef(() => form.getState()).current;
131
+ react.useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
131
132
  react.useEffect(() => {
132
133
  if (disposeTimerRef.current !== null) {
133
134
  clearTimeout(disposeTimerRef.current);
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/a11y.ts","../src/use-form-selector.ts","../src/use-field.ts","../src/core-form-options.ts","../src/use-form.ts","../src/use-expression-props.ts"],"names":["useRef","useCallback","useSyncExternalStore","useMemo","createForm","useEffect"],"mappings":"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","file":"index.cjs","sourcesContent":["import type { ValidationIssue } from \"@formbar/core\";\n\nconst DEFAULT_FIELD_PREFIX = \"field\";\n\n/** ARIA props for a form field */\nexport interface FieldA11yProps {\n\treadonly id: string;\n\treadonly \"aria-invalid\"?: boolean;\n\treadonly \"aria-describedby\"?: string;\n\treadonly \"aria-required\"?: boolean;\n\treadonly \"aria-errormessage\"?: string;\n}\n\n/** Label props for semantic association */\nexport interface LabelA11yProps {\n\treadonly htmlFor: string;\n}\n\n/** Description/error message props */\nexport interface DescriptionA11yProps {\n\treadonly id: string;\n\treadonly role?: \"alert\";\n}\n\n/** Generate a deterministic field ID from path */\nexport function fieldId(path: string, prefix: string = DEFAULT_FIELD_PREFIX): string {\n\treturn `${prefix}-${path\n\t\t.replace(/[.[\\]/]/g, \"-\")\n\t\t.replace(/-+/g, \"-\")\n\t\t.replace(/-$/, \"\")}`;\n}\n\n/** Generate description element ID */\nexport function descriptionId(path: string, prefix?: string): string {\n\treturn `${fieldId(path, prefix)}-description`;\n}\n\n/** Generate error element ID */\nexport function errorId(path: string, prefix?: string): string {\n\treturn `${fieldId(path, prefix)}-error`;\n}\n\n/** Get ARIA props for a field input element */\nexport function getFieldProps(\n\tpath: string,\n\toptions?: {\n\t\treadonly issues?: readonly ValidationIssue[];\n\t\treadonly required?: boolean;\n\t\treadonly hasDescription?: boolean;\n\t},\n): FieldA11yProps {\n\tconst id = fieldId(path);\n\tconst hasErrors = options?.issues?.some((i) => i.severity === \"error\") ?? false;\n\n\tconst describedBy: string[] = [];\n\tif (options?.hasDescription) describedBy.push(descriptionId(path));\n\tif (hasErrors) describedBy.push(errorId(path));\n\n\tconst props: FieldA11yProps = {\n\t\tid,\n\t\t...(hasErrors ? { \"aria-invalid\": true as const } : {}),\n\t\t...(describedBy.length > 0 ? { \"aria-describedby\": describedBy.join(\" \") } : {}),\n\t\t...(options?.required ? { \"aria-required\": true as const } : {}),\n\t\t...(hasErrors ? { \"aria-errormessage\": errorId(path) } : {}),\n\t};\n\n\treturn props;\n}\n\n/** Get label props for semantic association */\nexport function getLabelProps(path: string): LabelA11yProps {\n\treturn { htmlFor: fieldId(path) };\n}\n\n/** Get description element props */\nexport function getDescriptionProps(path: string): DescriptionA11yProps {\n\treturn { id: descriptionId(path) };\n}\n\n/** Get error message props */\nexport function getErrorProps(path: string): DescriptionA11yProps {\n\treturn { id: errorId(path), role: \"alert\" };\n}\n\n/** Find the first field path with errors (for focus management) */\nexport function findFirstErrorPath(issues: readonly ValidationIssue[]): string | undefined {\n\tconst firstError = issues.find((i) => i.severity === \"error\");\n\tif (!firstError) return undefined;\n\treturn firstError.path.segments.join(\".\");\n}\n\n/** Focus the first error field after submit (browser-only) */\nexport function focusFirstError(issues: readonly ValidationIssue[]): boolean {\n\tconst path = findFirstErrorPath(issues);\n\tif (!path) return false;\n\n\tif (typeof document === \"undefined\") return false;\n\n\tconst id = fieldId(path);\n\tconst element = document.getElementById(id);\n\tif (element) {\n\t\telement.focus();\n\t\treturn true;\n\t}\n\treturn false;\n}\n","import type { FormApi, FormState } from \"@formbar/core\";\nimport { useCallback, useRef, useSyncExternalStore } from \"react\";\n\n/**\n * Subscribe to a derived value from form state with fine-grained reactivity.\n * Only triggers re-render when the selected value changes (by equality function).\n *\n * @param form - The {@link FormApi} instance.\n * @param selector - Function that extracts a value from the full form state.\n * @param equalityFn - Optional equality comparator (defaults to `Object.is`).\n * @returns The current selected value, updated reactively.\n *\n * @example\n * ```typescript\n * const isValid = useFormSelector(form, (state) => state.issues.length === 0);\n * const submitCount = useFormSelector(form, (state) => state.meta.submitted);\n * ```\n */\nexport function useFormSelector<TData, TUi, T>(\n\tform: FormApi<TData, TUi>,\n\tselector: (state: FormState<TData, TUi>) => T,\n\tequalityFn?: (prev: T, next: T) => boolean,\n): T {\n\tconst eqRef = useRef(equalityFn ?? Object.is);\n\teqRef.current = equalityFn ?? Object.is;\n\n\tconst selectorRef = useRef(selector);\n\tselectorRef.current = selector;\n\n\tconst prevRef = useRef<{ readonly value: T; readonly initialized: boolean }>({\n\t\tvalue: undefined as T,\n\t\tinitialized: false,\n\t});\n\n\tconst subscribe = useCallback((onStoreChange: () => void) => form.subscribe(onStoreChange), [form]);\n\n\tconst getSnapshot = useCallback((): T => {\n\t\tconst next = selectorRef.current(form.getState());\n\t\tif (prevRef.current.initialized && eqRef.current(prevRef.current.value, next)) {\n\t\t\treturn prevRef.current.value;\n\t\t}\n\t\tprevRef.current = { value: next, initialized: true };\n\t\treturn next;\n\t}, [form]);\n\n\treturn useSyncExternalStore(subscribe, getSnapshot, getSnapshot);\n}\n","import type { FieldApi, FieldConfig, FieldMetaEntry, FormApi } from \"@formbar/core\";\nimport { useMemo, useRef } from \"react\";\nimport { useFormSelector } from \"./use-form-selector.js\";\n\ninterface FieldSnapshot {\n\treadonly value: unknown;\n\treadonly meta: FieldMetaEntry | undefined;\n}\n\nfunction fieldSnapshotEqual(a: FieldSnapshot, b: FieldSnapshot): boolean {\n\treturn a.value === b.value && a.meta === b.meta;\n}\n\n/**\n * React hook that subscribes to a specific field with fine-grained re-rendering.\n * Only re-renders when the field's value or metadata actually changes.\n *\n * @param form - The {@link FormApi} instance (from useForm or createForm).\n * @param path - Dot-path to the field (e.g., `\"user.email\"`).\n * @param config - Optional field configuration (label, validators, triggers).\n * @returns A {@link FieldApi} with reactive get/set, validation, and touch tracking.\n *\n * @example\n * ```typescript\n * function EmailField({ form }) {\n * const field = useField(form, \"email\");\n * return (\n * <div>\n * <input\n * value={field.get() ?? \"\"}\n * onChange={e => field.handleChange(e.target.value)}\n * onBlur={() => field.handleBlur()}\n * />\n * {field.issues().map(i => <span key={i.code}>{i.message}</span>)}\n * </div>\n * );\n * }\n * ```\n */\nexport function useField<TData, TUi, P extends string>(\n\tform: FormApi<TData, TUi>,\n\tpath: P,\n\tconfig?: FieldConfig,\n): FieldApi<TData, TUi, P> {\n\t// Stabilize config reference — inline objects create new references every render\n\tconst configRef = useRef(config);\n\tconst stableConfig = useMemo(() => {\n\t\tconst prev = configRef.current;\n\t\tif (prev === config) return prev;\n\t\tif (prev && config && JSON.stringify(prev) === JSON.stringify(config)) return prev;\n\t\tconfigRef.current = config;\n\t\treturn config;\n\t}, [config]);\n\n\tconst field = useMemo(() => form.fieldDynamic(path, stableConfig), [form, path, stableConfig]);\n\n\t// Subscribe to field value and touched state to trigger re-renders\n\tuseFormSelector(\n\t\tform,\n\t\t() => {\n\t\t\tconst state = form.getState();\n\t\t\tconst pathKey = field.path.segments.join(\".\");\n\t\t\tconst meta = (state.fieldMeta as Readonly<Record<string, FieldMetaEntry>>)[pathKey];\n\t\t\treturn { value: field.get(), meta } as FieldSnapshot;\n\t\t},\n\t\tfieldSnapshotEqual,\n\t);\n\n\treturn field as FieldApi<TData, TUi, P>;\n}\n","import type { CreateFormOptions } from \"@formbar/core\";\nimport type { UseFormOptions } from \"./use-form.js\";\n\nexport function getCoreFormOptions<TData, TUi>(\n\toptions: UseFormOptions<TData, TUi> | undefined,\n): CreateFormOptions<TData, TUi> | undefined {\n\tif (options === undefined) return undefined;\n\ttry {\n\t\tif (!Object.hasOwn(options, \"autoFocusOnError\")) return options;\n\t\tconst { autoFocusOnError: _reactOption, ...coreDescriptors } = Object.getOwnPropertyDescriptors(options);\n\t\treturn Object.create(Object.getPrototypeOf(options), coreDescriptors) as CreateFormOptions<TData, TUi>;\n\t} catch {\n\t\t// Option diagnostics must not make useForm less resilient than createForm.\n\t\treturn options;\n\t}\n}\n","import type { CreateFormOptions, FormApi, SubmitResult } from \"@formbar/core\";\nimport { createForm } from \"@formbar/core\";\nimport { useEffect, useMemo, useRef, useSyncExternalStore } from \"react\";\nimport { focusFirstError } from \"./a11y.js\";\nimport { getCoreFormOptions } from \"./core-form-options.js\";\n\n/** Options for useForm, extending core CreateFormOptions with React-specific behavior */\nexport interface UseFormOptions<TData, TUi> extends CreateFormOptions<TData, TUi> {\n\t/** Auto-focus the first error field on submit failure (default: true) */\n\treadonly autoFocusOnError?: boolean;\n}\n\n/**\n * React hook that creates and manages a form instance with automatic cleanup.\n * The form is created once on mount and disposed on unmount (StrictMode-safe).\n *\n * @param options - Form configuration (same as {@link createForm} options).\n * @returns A stable {@link FormApi} reference that persists across re-renders.\n *\n * @example\n * ```typescript\n * function ContactForm() {\n * const form = useForm({\n * initialData: { name: \"\", email: \"\" },\n * onSubmit: async ({ payload }) => {\n * await saveContact(payload);\n * return { ok: true, submitId: \"1\" };\n * },\n * });\n *\n * return <input value={form.field(\"name\").get()} onChange={e => form.field(\"name\").set(e.target.value)} />;\n * }\n * ```\n */\nexport function useForm<TData, TUi>(options?: UseFormOptions<TData, TUi>): FormApi<TData, TUi> {\n\tconst autoFocus = options?.autoFocusOnError ?? true;\n\tconst formRef = useRef<FormApi<TData, TUi> | null>(null);\n\tconst disposeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n\n\tif (formRef.current === null) {\n\t\tformRef.current = createForm<TData, TUi>(getCoreFormOptions(options));\n\t}\n\n\tconst form = formRef.current;\n\n\t// Adapt form.subscribe (which passes state) to useSyncExternalStore's expected signature\n\tconst subscribe = useRef((onStoreChange: () => void) => {\n\t\treturn form.subscribe(onStoreChange);\n\t}).current;\n\n\tuseSyncExternalStore(subscribe, () => form.getState());\n\n\t// Deferred disposal: schedule dispose in a macrotask so StrictMode remount can cancel it\n\tuseEffect(() => {\n\t\tif (disposeTimerRef.current !== null) {\n\t\t\tclearTimeout(disposeTimerRef.current);\n\t\t\tdisposeTimerRef.current = null;\n\t\t}\n\t\treturn () => {\n\t\t\tdisposeTimerRef.current = setTimeout(() => {\n\t\t\t\tformRef.current?.dispose();\n\t\t\t}, 0);\n\t\t};\n\t}, []);\n\n\t// Wrap the form API to auto-focus on submit errors (ADR §12)\n\tconst wrappedApi = useMemo((): FormApi<TData, TUi> => {\n\t\tif (!autoFocus) return form;\n\n\t\treturn {\n\t\t\t...form,\n\t\t\tsubmit: async (...args: Parameters<FormApi<TData, TUi>[\"submit\"]>): Promise<SubmitResult> => {\n\t\t\t\tconst result = await form.submit(...args);\n\t\t\t\tif (!result.ok && result.fieldIssues?.length) {\n\t\t\t\t\tfocusFirstError(result.fieldIssues);\n\t\t\t\t}\n\t\t\t\treturn result;\n\t\t\t},\n\t\t};\n\t}, [form, autoFocus]);\n\n\treturn wrappedApi;\n}\n","import type { ExpressionService, PropDefinitions, ResolvedProps } from \"@formbar/expressions\";\nimport { useMemo, useSyncExternalStore } from \"react\";\n\n/** Keep definitions stable with useMemo; subscription cleanup also invalidates retained setters. */\nexport function useExpressionProps(service: ExpressionService, definitions: PropDefinitions): ResolvedProps {\n\tconst binding = useMemo(() => service.resolveProps(definitions), [service, definitions]);\n\treturn useSyncExternalStore(binding.subscribe, binding.getSnapshot, binding.getSnapshot);\n}\n"]}
1
+ {"version":3,"sources":["../src/a11y.ts","../src/use-form-selector.ts","../src/use-field.ts","../src/core-form-options.ts","../src/use-form.ts","../src/use-expression-props.ts"],"names":["useRef","useCallback","useSyncExternalStore","useMemo","createForm","useEffect"],"mappings":"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","file":"index.cjs","sourcesContent":["import type { ValidationIssue } from \"@formbar/core\";\n\nconst DEFAULT_FIELD_PREFIX = \"field\";\n\n/** ARIA props for a form field */\nexport interface FieldA11yProps {\n\treadonly id: string;\n\treadonly \"aria-invalid\"?: boolean;\n\treadonly \"aria-describedby\"?: string;\n\treadonly \"aria-required\"?: boolean;\n\treadonly \"aria-errormessage\"?: string;\n}\n\n/** Label props for semantic association */\nexport interface LabelA11yProps {\n\treadonly htmlFor: string;\n}\n\n/** Description/error message props */\nexport interface DescriptionA11yProps {\n\treadonly id: string;\n\treadonly role?: \"alert\";\n}\n\n/** Generate a deterministic field ID from path */\nexport function fieldId(path: string, prefix: string = DEFAULT_FIELD_PREFIX): string {\n\treturn `${prefix}-${path\n\t\t.replace(/[.[\\]/]/g, \"-\")\n\t\t.replace(/-+/g, \"-\")\n\t\t.replace(/-$/, \"\")}`;\n}\n\n/** Generate description element ID */\nexport function descriptionId(path: string, prefix?: string): string {\n\treturn `${fieldId(path, prefix)}-description`;\n}\n\n/** Generate error element ID */\nexport function errorId(path: string, prefix?: string): string {\n\treturn `${fieldId(path, prefix)}-error`;\n}\n\n/** Get ARIA props for a field input element */\nexport function getFieldProps(\n\tpath: string,\n\toptions?: {\n\t\treadonly issues?: readonly ValidationIssue[];\n\t\treadonly required?: boolean;\n\t\treadonly hasDescription?: boolean;\n\t},\n): FieldA11yProps {\n\tconst id = fieldId(path);\n\tconst hasErrors = options?.issues?.some((i) => i.severity === \"error\") ?? false;\n\n\tconst describedBy: string[] = [];\n\tif (options?.hasDescription) describedBy.push(descriptionId(path));\n\tif (hasErrors) describedBy.push(errorId(path));\n\n\tconst props: FieldA11yProps = {\n\t\tid,\n\t\t...(hasErrors ? { \"aria-invalid\": true as const } : {}),\n\t\t...(describedBy.length > 0 ? { \"aria-describedby\": describedBy.join(\" \") } : {}),\n\t\t...(options?.required ? { \"aria-required\": true as const } : {}),\n\t\t...(hasErrors ? { \"aria-errormessage\": errorId(path) } : {}),\n\t};\n\n\treturn props;\n}\n\n/** Get label props for semantic association */\nexport function getLabelProps(path: string): LabelA11yProps {\n\treturn { htmlFor: fieldId(path) };\n}\n\n/** Get description element props */\nexport function getDescriptionProps(path: string): DescriptionA11yProps {\n\treturn { id: descriptionId(path) };\n}\n\n/** Get error message props */\nexport function getErrorProps(path: string): DescriptionA11yProps {\n\treturn { id: errorId(path), role: \"alert\" };\n}\n\n/** Find the first field path with errors (for focus management) */\nexport function findFirstErrorPath(issues: readonly ValidationIssue[]): string | undefined {\n\tconst firstError = issues.find((i) => i.severity === \"error\");\n\tif (!firstError) return undefined;\n\treturn firstError.path.segments.join(\".\");\n}\n\n/** Focus the first error field after submit (browser-only) */\nexport function focusFirstError(issues: readonly ValidationIssue[]): boolean {\n\tconst path = findFirstErrorPath(issues);\n\tif (!path) return false;\n\n\tif (typeof document === \"undefined\") return false;\n\n\tconst id = fieldId(path);\n\tconst element = document.getElementById(id);\n\tif (element) {\n\t\telement.focus();\n\t\treturn true;\n\t}\n\treturn false;\n}\n","import type { FormApi, FormState } from \"@formbar/core\";\nimport { useCallback, useRef, useSyncExternalStore } from \"react\";\n\n/**\n * Subscribe to a derived value from form state with fine-grained reactivity.\n * Only triggers re-render when the selected value changes (by equality function).\n *\n * @param form - The {@link FormApi} instance.\n * @param selector - Function that extracts a value from the full form state.\n * @param equalityFn - Optional equality comparator (defaults to `Object.is`).\n * @returns The current selected value, updated reactively.\n *\n * @example\n * ```typescript\n * const isValid = useFormSelector(form, (state) => state.issues.length === 0);\n * const submitCount = useFormSelector(form, (state) => state.meta.submitted);\n * ```\n */\nexport function useFormSelector<TData, TUi, T>(\n\tform: FormApi<TData, TUi>,\n\tselector: (state: FormState<TData, TUi>) => T,\n\tequalityFn?: (prev: T, next: T) => boolean,\n): T {\n\tconst eqRef = useRef(equalityFn ?? Object.is);\n\teqRef.current = equalityFn ?? Object.is;\n\n\tconst selectorRef = useRef(selector);\n\tselectorRef.current = selector;\n\n\tconst prevRef = useRef<{ readonly value: T; readonly initialized: boolean }>({\n\t\tvalue: undefined as T,\n\t\tinitialized: false,\n\t});\n\n\tconst subscribe = useCallback((onStoreChange: () => void) => form.subscribe(onStoreChange), [form]);\n\n\tconst getSnapshot = useCallback((): T => {\n\t\tconst next = selectorRef.current(form.getState());\n\t\tif (prevRef.current.initialized && eqRef.current(prevRef.current.value, next)) {\n\t\t\treturn prevRef.current.value;\n\t\t}\n\t\tprevRef.current = { value: next, initialized: true };\n\t\treturn next;\n\t}, [form]);\n\n\treturn useSyncExternalStore(subscribe, getSnapshot, getSnapshot);\n}\n","import type { FieldApi, FieldConfig, FieldMetaEntry, FormApi } from \"@formbar/core\";\nimport { useMemo, useRef } from \"react\";\nimport { useFormSelector } from \"./use-form-selector.js\";\n\ninterface FieldSnapshot {\n\treadonly value: unknown;\n\treadonly meta: FieldMetaEntry | undefined;\n}\n\nfunction fieldSnapshotEqual(a: FieldSnapshot, b: FieldSnapshot): boolean {\n\treturn a.value === b.value && a.meta === b.meta;\n}\n\n/**\n * React hook that subscribes to a specific field with fine-grained re-rendering.\n * Only re-renders when the field's value or metadata actually changes.\n *\n * @param form - The {@link FormApi} instance (from useForm or createForm).\n * @param path - Dot-path to the field (e.g., `\"user.email\"`).\n * @param config - Optional field configuration (label, validators, triggers).\n * @returns A {@link FieldApi} with reactive get/set, validation, and touch tracking.\n *\n * @example\n * ```typescript\n * function EmailField({ form }) {\n * const field = useField(form, \"email\");\n * return (\n * <div>\n * <input\n * value={field.get() ?? \"\"}\n * onChange={e => field.handleChange(e.target.value)}\n * onBlur={() => field.handleBlur()}\n * />\n * {field.issues().map(i => <span key={i.code}>{i.message}</span>)}\n * </div>\n * );\n * }\n * ```\n */\nexport function useField<TData, TUi, P extends string>(\n\tform: FormApi<TData, TUi>,\n\tpath: P,\n\tconfig?: FieldConfig,\n): FieldApi<TData, TUi, P> {\n\t// Stabilize config reference — inline objects create new references every render\n\tconst configRef = useRef(config);\n\tconst stableConfig = useMemo(() => {\n\t\tconst prev = configRef.current;\n\t\tif (prev === config) return prev;\n\t\tif (prev && config && JSON.stringify(prev) === JSON.stringify(config)) return prev;\n\t\tconfigRef.current = config;\n\t\treturn config;\n\t}, [config]);\n\n\tconst field = useMemo(() => form.fieldDynamic(path, stableConfig), [form, path, stableConfig]);\n\n\t// Subscribe to field value and touched state to trigger re-renders\n\tuseFormSelector(\n\t\tform,\n\t\t() => {\n\t\t\tconst state = form.getState();\n\t\t\tconst pathKey = field.path.segments.join(\".\");\n\t\t\tconst meta = (state.fieldMeta as Readonly<Record<string, FieldMetaEntry>>)[pathKey];\n\t\t\treturn { value: field.get(), meta } as FieldSnapshot;\n\t\t},\n\t\tfieldSnapshotEqual,\n\t);\n\n\treturn field as FieldApi<TData, TUi, P>;\n}\n","import type { CreateFormOptions } from \"@formbar/core\";\nimport type { UseFormOptions } from \"./use-form.js\";\n\nexport function getCoreFormOptions<TData, TUi>(\n\toptions: UseFormOptions<TData, TUi> | undefined,\n): CreateFormOptions<TData, TUi> | undefined {\n\tif (options === undefined) return undefined;\n\ttry {\n\t\tif (!Object.hasOwn(options, \"autoFocusOnError\")) return options;\n\t\tconst { autoFocusOnError: _reactOption, ...coreDescriptors } = Object.getOwnPropertyDescriptors(options);\n\t\treturn Object.create(Object.getPrototypeOf(options), coreDescriptors) as CreateFormOptions<TData, TUi>;\n\t} catch {\n\t\t// Option diagnostics must not make useForm less resilient than createForm.\n\t\treturn options;\n\t}\n}\n","import type { CreateFormOptions, FormApi, SubmitResult } from \"@formbar/core\";\nimport { createForm } from \"@formbar/core\";\nimport { useEffect, useMemo, useRef, useSyncExternalStore } from \"react\";\nimport { focusFirstError } from \"./a11y.js\";\nimport { getCoreFormOptions } from \"./core-form-options.js\";\n\n/** Options for useForm, extending core CreateFormOptions with React-specific behavior */\nexport interface UseFormOptions<TData, TUi> extends CreateFormOptions<TData, TUi> {\n\t/** Auto-focus the first error field on submit failure (default: true) */\n\treadonly autoFocusOnError?: boolean;\n}\n\n/**\n * React hook that creates and manages a form instance with automatic cleanup.\n * The form is created once on mount and disposed on unmount (StrictMode-safe).\n *\n * @param options - Form configuration (same as {@link createForm} options).\n * @returns A stable {@link FormApi} reference that persists across re-renders.\n *\n * @example\n * ```typescript\n * function ContactForm() {\n * const form = useForm({\n * initialData: { name: \"\", email: \"\" },\n * onSubmit: async ({ payload }) => {\n * await saveContact(payload);\n * return { ok: true, submitId: \"1\" };\n * },\n * });\n *\n * return <input value={form.field(\"name\").get()} onChange={e => form.field(\"name\").set(e.target.value)} />;\n * }\n * ```\n */\nexport function useForm<TData, TUi>(options?: UseFormOptions<TData, TUi>): FormApi<TData, TUi> {\n\tconst autoFocus = options?.autoFocusOnError ?? true;\n\tconst formRef = useRef<FormApi<TData, TUi> | null>(null);\n\tconst disposeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n\n\tif (formRef.current === null) {\n\t\tformRef.current = createForm<TData, TUi>(getCoreFormOptions(options));\n\t}\n\n\tconst form = formRef.current;\n\n\t// Adapt form.subscribe (which passes state) to useSyncExternalStore's expected signature\n\tconst subscribe = useRef((onStoreChange: () => void) => {\n\t\treturn form.subscribe(onStoreChange);\n\t}).current;\n\tconst getSnapshot = useRef(() => form.getState()).current;\n\n\tuseSyncExternalStore(subscribe, getSnapshot, getSnapshot);\n\n\t// Deferred disposal: schedule dispose in a macrotask so StrictMode remount can cancel it\n\tuseEffect(() => {\n\t\tif (disposeTimerRef.current !== null) {\n\t\t\tclearTimeout(disposeTimerRef.current);\n\t\t\tdisposeTimerRef.current = null;\n\t\t}\n\t\treturn () => {\n\t\t\tdisposeTimerRef.current = setTimeout(() => {\n\t\t\t\tformRef.current?.dispose();\n\t\t\t}, 0);\n\t\t};\n\t}, []);\n\n\t// Wrap the form API to auto-focus on submit errors (ADR §12)\n\tconst wrappedApi = useMemo((): FormApi<TData, TUi> => {\n\t\tif (!autoFocus) return form;\n\n\t\treturn {\n\t\t\t...form,\n\t\t\tsubmit: async (...args: Parameters<FormApi<TData, TUi>[\"submit\"]>): Promise<SubmitResult> => {\n\t\t\t\tconst result = await form.submit(...args);\n\t\t\t\tif (!result.ok && result.fieldIssues?.length) {\n\t\t\t\t\tfocusFirstError(result.fieldIssues);\n\t\t\t\t}\n\t\t\t\treturn result;\n\t\t\t},\n\t\t};\n\t}, [form, autoFocus]);\n\n\treturn wrappedApi;\n}\n","import type { ExpressionService, PropDefinitions, ResolvedProps } from \"@formbar/expressions\";\nimport { useMemo, useSyncExternalStore } from \"react\";\n\n/** Keep definitions stable with useMemo; subscription cleanup also invalidates retained setters. */\nexport function useExpressionProps(service: ExpressionService, definitions: PropDefinitions): ResolvedProps {\n\tconst binding = useMemo(() => service.resolveProps(definitions), [service, definitions]);\n\treturn useSyncExternalStore(binding.subscribe, binding.getSnapshot, binding.getSnapshot);\n}\n"]}
package/dist/index.js CHANGED
@@ -125,7 +125,8 @@ function useForm(options) {
125
125
  const subscribe = useRef((onStoreChange) => {
126
126
  return form.subscribe(onStoreChange);
127
127
  }).current;
128
- useSyncExternalStore(subscribe, () => form.getState());
128
+ const getSnapshot = useRef(() => form.getState()).current;
129
+ useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
129
130
  useEffect(() => {
130
131
  if (disposeTimerRef.current !== null) {
131
132
  clearTimeout(disposeTimerRef.current);
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/a11y.ts","../src/use-form-selector.ts","../src/use-field.ts","../src/core-form-options.ts","../src/use-form.ts","../src/use-expression-props.ts"],"names":["useRef","useSyncExternalStore","useMemo"],"mappings":"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","file":"index.js","sourcesContent":["import type { ValidationIssue } from \"@formbar/core\";\n\nconst DEFAULT_FIELD_PREFIX = \"field\";\n\n/** ARIA props for a form field */\nexport interface FieldA11yProps {\n\treadonly id: string;\n\treadonly \"aria-invalid\"?: boolean;\n\treadonly \"aria-describedby\"?: string;\n\treadonly \"aria-required\"?: boolean;\n\treadonly \"aria-errormessage\"?: string;\n}\n\n/** Label props for semantic association */\nexport interface LabelA11yProps {\n\treadonly htmlFor: string;\n}\n\n/** Description/error message props */\nexport interface DescriptionA11yProps {\n\treadonly id: string;\n\treadonly role?: \"alert\";\n}\n\n/** Generate a deterministic field ID from path */\nexport function fieldId(path: string, prefix: string = DEFAULT_FIELD_PREFIX): string {\n\treturn `${prefix}-${path\n\t\t.replace(/[.[\\]/]/g, \"-\")\n\t\t.replace(/-+/g, \"-\")\n\t\t.replace(/-$/, \"\")}`;\n}\n\n/** Generate description element ID */\nexport function descriptionId(path: string, prefix?: string): string {\n\treturn `${fieldId(path, prefix)}-description`;\n}\n\n/** Generate error element ID */\nexport function errorId(path: string, prefix?: string): string {\n\treturn `${fieldId(path, prefix)}-error`;\n}\n\n/** Get ARIA props for a field input element */\nexport function getFieldProps(\n\tpath: string,\n\toptions?: {\n\t\treadonly issues?: readonly ValidationIssue[];\n\t\treadonly required?: boolean;\n\t\treadonly hasDescription?: boolean;\n\t},\n): FieldA11yProps {\n\tconst id = fieldId(path);\n\tconst hasErrors = options?.issues?.some((i) => i.severity === \"error\") ?? false;\n\n\tconst describedBy: string[] = [];\n\tif (options?.hasDescription) describedBy.push(descriptionId(path));\n\tif (hasErrors) describedBy.push(errorId(path));\n\n\tconst props: FieldA11yProps = {\n\t\tid,\n\t\t...(hasErrors ? { \"aria-invalid\": true as const } : {}),\n\t\t...(describedBy.length > 0 ? { \"aria-describedby\": describedBy.join(\" \") } : {}),\n\t\t...(options?.required ? { \"aria-required\": true as const } : {}),\n\t\t...(hasErrors ? { \"aria-errormessage\": errorId(path) } : {}),\n\t};\n\n\treturn props;\n}\n\n/** Get label props for semantic association */\nexport function getLabelProps(path: string): LabelA11yProps {\n\treturn { htmlFor: fieldId(path) };\n}\n\n/** Get description element props */\nexport function getDescriptionProps(path: string): DescriptionA11yProps {\n\treturn { id: descriptionId(path) };\n}\n\n/** Get error message props */\nexport function getErrorProps(path: string): DescriptionA11yProps {\n\treturn { id: errorId(path), role: \"alert\" };\n}\n\n/** Find the first field path with errors (for focus management) */\nexport function findFirstErrorPath(issues: readonly ValidationIssue[]): string | undefined {\n\tconst firstError = issues.find((i) => i.severity === \"error\");\n\tif (!firstError) return undefined;\n\treturn firstError.path.segments.join(\".\");\n}\n\n/** Focus the first error field after submit (browser-only) */\nexport function focusFirstError(issues: readonly ValidationIssue[]): boolean {\n\tconst path = findFirstErrorPath(issues);\n\tif (!path) return false;\n\n\tif (typeof document === \"undefined\") return false;\n\n\tconst id = fieldId(path);\n\tconst element = document.getElementById(id);\n\tif (element) {\n\t\telement.focus();\n\t\treturn true;\n\t}\n\treturn false;\n}\n","import type { FormApi, FormState } from \"@formbar/core\";\nimport { useCallback, useRef, useSyncExternalStore } from \"react\";\n\n/**\n * Subscribe to a derived value from form state with fine-grained reactivity.\n * Only triggers re-render when the selected value changes (by equality function).\n *\n * @param form - The {@link FormApi} instance.\n * @param selector - Function that extracts a value from the full form state.\n * @param equalityFn - Optional equality comparator (defaults to `Object.is`).\n * @returns The current selected value, updated reactively.\n *\n * @example\n * ```typescript\n * const isValid = useFormSelector(form, (state) => state.issues.length === 0);\n * const submitCount = useFormSelector(form, (state) => state.meta.submitted);\n * ```\n */\nexport function useFormSelector<TData, TUi, T>(\n\tform: FormApi<TData, TUi>,\n\tselector: (state: FormState<TData, TUi>) => T,\n\tequalityFn?: (prev: T, next: T) => boolean,\n): T {\n\tconst eqRef = useRef(equalityFn ?? Object.is);\n\teqRef.current = equalityFn ?? Object.is;\n\n\tconst selectorRef = useRef(selector);\n\tselectorRef.current = selector;\n\n\tconst prevRef = useRef<{ readonly value: T; readonly initialized: boolean }>({\n\t\tvalue: undefined as T,\n\t\tinitialized: false,\n\t});\n\n\tconst subscribe = useCallback((onStoreChange: () => void) => form.subscribe(onStoreChange), [form]);\n\n\tconst getSnapshot = useCallback((): T => {\n\t\tconst next = selectorRef.current(form.getState());\n\t\tif (prevRef.current.initialized && eqRef.current(prevRef.current.value, next)) {\n\t\t\treturn prevRef.current.value;\n\t\t}\n\t\tprevRef.current = { value: next, initialized: true };\n\t\treturn next;\n\t}, [form]);\n\n\treturn useSyncExternalStore(subscribe, getSnapshot, getSnapshot);\n}\n","import type { FieldApi, FieldConfig, FieldMetaEntry, FormApi } from \"@formbar/core\";\nimport { useMemo, useRef } from \"react\";\nimport { useFormSelector } from \"./use-form-selector.js\";\n\ninterface FieldSnapshot {\n\treadonly value: unknown;\n\treadonly meta: FieldMetaEntry | undefined;\n}\n\nfunction fieldSnapshotEqual(a: FieldSnapshot, b: FieldSnapshot): boolean {\n\treturn a.value === b.value && a.meta === b.meta;\n}\n\n/**\n * React hook that subscribes to a specific field with fine-grained re-rendering.\n * Only re-renders when the field's value or metadata actually changes.\n *\n * @param form - The {@link FormApi} instance (from useForm or createForm).\n * @param path - Dot-path to the field (e.g., `\"user.email\"`).\n * @param config - Optional field configuration (label, validators, triggers).\n * @returns A {@link FieldApi} with reactive get/set, validation, and touch tracking.\n *\n * @example\n * ```typescript\n * function EmailField({ form }) {\n * const field = useField(form, \"email\");\n * return (\n * <div>\n * <input\n * value={field.get() ?? \"\"}\n * onChange={e => field.handleChange(e.target.value)}\n * onBlur={() => field.handleBlur()}\n * />\n * {field.issues().map(i => <span key={i.code}>{i.message}</span>)}\n * </div>\n * );\n * }\n * ```\n */\nexport function useField<TData, TUi, P extends string>(\n\tform: FormApi<TData, TUi>,\n\tpath: P,\n\tconfig?: FieldConfig,\n): FieldApi<TData, TUi, P> {\n\t// Stabilize config reference — inline objects create new references every render\n\tconst configRef = useRef(config);\n\tconst stableConfig = useMemo(() => {\n\t\tconst prev = configRef.current;\n\t\tif (prev === config) return prev;\n\t\tif (prev && config && JSON.stringify(prev) === JSON.stringify(config)) return prev;\n\t\tconfigRef.current = config;\n\t\treturn config;\n\t}, [config]);\n\n\tconst field = useMemo(() => form.fieldDynamic(path, stableConfig), [form, path, stableConfig]);\n\n\t// Subscribe to field value and touched state to trigger re-renders\n\tuseFormSelector(\n\t\tform,\n\t\t() => {\n\t\t\tconst state = form.getState();\n\t\t\tconst pathKey = field.path.segments.join(\".\");\n\t\t\tconst meta = (state.fieldMeta as Readonly<Record<string, FieldMetaEntry>>)[pathKey];\n\t\t\treturn { value: field.get(), meta } as FieldSnapshot;\n\t\t},\n\t\tfieldSnapshotEqual,\n\t);\n\n\treturn field as FieldApi<TData, TUi, P>;\n}\n","import type { CreateFormOptions } from \"@formbar/core\";\nimport type { UseFormOptions } from \"./use-form.js\";\n\nexport function getCoreFormOptions<TData, TUi>(\n\toptions: UseFormOptions<TData, TUi> | undefined,\n): CreateFormOptions<TData, TUi> | undefined {\n\tif (options === undefined) return undefined;\n\ttry {\n\t\tif (!Object.hasOwn(options, \"autoFocusOnError\")) return options;\n\t\tconst { autoFocusOnError: _reactOption, ...coreDescriptors } = Object.getOwnPropertyDescriptors(options);\n\t\treturn Object.create(Object.getPrototypeOf(options), coreDescriptors) as CreateFormOptions<TData, TUi>;\n\t} catch {\n\t\t// Option diagnostics must not make useForm less resilient than createForm.\n\t\treturn options;\n\t}\n}\n","import type { CreateFormOptions, FormApi, SubmitResult } from \"@formbar/core\";\nimport { createForm } from \"@formbar/core\";\nimport { useEffect, useMemo, useRef, useSyncExternalStore } from \"react\";\nimport { focusFirstError } from \"./a11y.js\";\nimport { getCoreFormOptions } from \"./core-form-options.js\";\n\n/** Options for useForm, extending core CreateFormOptions with React-specific behavior */\nexport interface UseFormOptions<TData, TUi> extends CreateFormOptions<TData, TUi> {\n\t/** Auto-focus the first error field on submit failure (default: true) */\n\treadonly autoFocusOnError?: boolean;\n}\n\n/**\n * React hook that creates and manages a form instance with automatic cleanup.\n * The form is created once on mount and disposed on unmount (StrictMode-safe).\n *\n * @param options - Form configuration (same as {@link createForm} options).\n * @returns A stable {@link FormApi} reference that persists across re-renders.\n *\n * @example\n * ```typescript\n * function ContactForm() {\n * const form = useForm({\n * initialData: { name: \"\", email: \"\" },\n * onSubmit: async ({ payload }) => {\n * await saveContact(payload);\n * return { ok: true, submitId: \"1\" };\n * },\n * });\n *\n * return <input value={form.field(\"name\").get()} onChange={e => form.field(\"name\").set(e.target.value)} />;\n * }\n * ```\n */\nexport function useForm<TData, TUi>(options?: UseFormOptions<TData, TUi>): FormApi<TData, TUi> {\n\tconst autoFocus = options?.autoFocusOnError ?? true;\n\tconst formRef = useRef<FormApi<TData, TUi> | null>(null);\n\tconst disposeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n\n\tif (formRef.current === null) {\n\t\tformRef.current = createForm<TData, TUi>(getCoreFormOptions(options));\n\t}\n\n\tconst form = formRef.current;\n\n\t// Adapt form.subscribe (which passes state) to useSyncExternalStore's expected signature\n\tconst subscribe = useRef((onStoreChange: () => void) => {\n\t\treturn form.subscribe(onStoreChange);\n\t}).current;\n\n\tuseSyncExternalStore(subscribe, () => form.getState());\n\n\t// Deferred disposal: schedule dispose in a macrotask so StrictMode remount can cancel it\n\tuseEffect(() => {\n\t\tif (disposeTimerRef.current !== null) {\n\t\t\tclearTimeout(disposeTimerRef.current);\n\t\t\tdisposeTimerRef.current = null;\n\t\t}\n\t\treturn () => {\n\t\t\tdisposeTimerRef.current = setTimeout(() => {\n\t\t\t\tformRef.current?.dispose();\n\t\t\t}, 0);\n\t\t};\n\t}, []);\n\n\t// Wrap the form API to auto-focus on submit errors (ADR §12)\n\tconst wrappedApi = useMemo((): FormApi<TData, TUi> => {\n\t\tif (!autoFocus) return form;\n\n\t\treturn {\n\t\t\t...form,\n\t\t\tsubmit: async (...args: Parameters<FormApi<TData, TUi>[\"submit\"]>): Promise<SubmitResult> => {\n\t\t\t\tconst result = await form.submit(...args);\n\t\t\t\tif (!result.ok && result.fieldIssues?.length) {\n\t\t\t\t\tfocusFirstError(result.fieldIssues);\n\t\t\t\t}\n\t\t\t\treturn result;\n\t\t\t},\n\t\t};\n\t}, [form, autoFocus]);\n\n\treturn wrappedApi;\n}\n","import type { ExpressionService, PropDefinitions, ResolvedProps } from \"@formbar/expressions\";\nimport { useMemo, useSyncExternalStore } from \"react\";\n\n/** Keep definitions stable with useMemo; subscription cleanup also invalidates retained setters. */\nexport function useExpressionProps(service: ExpressionService, definitions: PropDefinitions): ResolvedProps {\n\tconst binding = useMemo(() => service.resolveProps(definitions), [service, definitions]);\n\treturn useSyncExternalStore(binding.subscribe, binding.getSnapshot, binding.getSnapshot);\n}\n"]}
1
+ {"version":3,"sources":["../src/a11y.ts","../src/use-form-selector.ts","../src/use-field.ts","../src/core-form-options.ts","../src/use-form.ts","../src/use-expression-props.ts"],"names":["useRef","useSyncExternalStore","useMemo"],"mappings":"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","file":"index.js","sourcesContent":["import type { ValidationIssue } from \"@formbar/core\";\n\nconst DEFAULT_FIELD_PREFIX = \"field\";\n\n/** ARIA props for a form field */\nexport interface FieldA11yProps {\n\treadonly id: string;\n\treadonly \"aria-invalid\"?: boolean;\n\treadonly \"aria-describedby\"?: string;\n\treadonly \"aria-required\"?: boolean;\n\treadonly \"aria-errormessage\"?: string;\n}\n\n/** Label props for semantic association */\nexport interface LabelA11yProps {\n\treadonly htmlFor: string;\n}\n\n/** Description/error message props */\nexport interface DescriptionA11yProps {\n\treadonly id: string;\n\treadonly role?: \"alert\";\n}\n\n/** Generate a deterministic field ID from path */\nexport function fieldId(path: string, prefix: string = DEFAULT_FIELD_PREFIX): string {\n\treturn `${prefix}-${path\n\t\t.replace(/[.[\\]/]/g, \"-\")\n\t\t.replace(/-+/g, \"-\")\n\t\t.replace(/-$/, \"\")}`;\n}\n\n/** Generate description element ID */\nexport function descriptionId(path: string, prefix?: string): string {\n\treturn `${fieldId(path, prefix)}-description`;\n}\n\n/** Generate error element ID */\nexport function errorId(path: string, prefix?: string): string {\n\treturn `${fieldId(path, prefix)}-error`;\n}\n\n/** Get ARIA props for a field input element */\nexport function getFieldProps(\n\tpath: string,\n\toptions?: {\n\t\treadonly issues?: readonly ValidationIssue[];\n\t\treadonly required?: boolean;\n\t\treadonly hasDescription?: boolean;\n\t},\n): FieldA11yProps {\n\tconst id = fieldId(path);\n\tconst hasErrors = options?.issues?.some((i) => i.severity === \"error\") ?? false;\n\n\tconst describedBy: string[] = [];\n\tif (options?.hasDescription) describedBy.push(descriptionId(path));\n\tif (hasErrors) describedBy.push(errorId(path));\n\n\tconst props: FieldA11yProps = {\n\t\tid,\n\t\t...(hasErrors ? { \"aria-invalid\": true as const } : {}),\n\t\t...(describedBy.length > 0 ? { \"aria-describedby\": describedBy.join(\" \") } : {}),\n\t\t...(options?.required ? { \"aria-required\": true as const } : {}),\n\t\t...(hasErrors ? { \"aria-errormessage\": errorId(path) } : {}),\n\t};\n\n\treturn props;\n}\n\n/** Get label props for semantic association */\nexport function getLabelProps(path: string): LabelA11yProps {\n\treturn { htmlFor: fieldId(path) };\n}\n\n/** Get description element props */\nexport function getDescriptionProps(path: string): DescriptionA11yProps {\n\treturn { id: descriptionId(path) };\n}\n\n/** Get error message props */\nexport function getErrorProps(path: string): DescriptionA11yProps {\n\treturn { id: errorId(path), role: \"alert\" };\n}\n\n/** Find the first field path with errors (for focus management) */\nexport function findFirstErrorPath(issues: readonly ValidationIssue[]): string | undefined {\n\tconst firstError = issues.find((i) => i.severity === \"error\");\n\tif (!firstError) return undefined;\n\treturn firstError.path.segments.join(\".\");\n}\n\n/** Focus the first error field after submit (browser-only) */\nexport function focusFirstError(issues: readonly ValidationIssue[]): boolean {\n\tconst path = findFirstErrorPath(issues);\n\tif (!path) return false;\n\n\tif (typeof document === \"undefined\") return false;\n\n\tconst id = fieldId(path);\n\tconst element = document.getElementById(id);\n\tif (element) {\n\t\telement.focus();\n\t\treturn true;\n\t}\n\treturn false;\n}\n","import type { FormApi, FormState } from \"@formbar/core\";\nimport { useCallback, useRef, useSyncExternalStore } from \"react\";\n\n/**\n * Subscribe to a derived value from form state with fine-grained reactivity.\n * Only triggers re-render when the selected value changes (by equality function).\n *\n * @param form - The {@link FormApi} instance.\n * @param selector - Function that extracts a value from the full form state.\n * @param equalityFn - Optional equality comparator (defaults to `Object.is`).\n * @returns The current selected value, updated reactively.\n *\n * @example\n * ```typescript\n * const isValid = useFormSelector(form, (state) => state.issues.length === 0);\n * const submitCount = useFormSelector(form, (state) => state.meta.submitted);\n * ```\n */\nexport function useFormSelector<TData, TUi, T>(\n\tform: FormApi<TData, TUi>,\n\tselector: (state: FormState<TData, TUi>) => T,\n\tequalityFn?: (prev: T, next: T) => boolean,\n): T {\n\tconst eqRef = useRef(equalityFn ?? Object.is);\n\teqRef.current = equalityFn ?? Object.is;\n\n\tconst selectorRef = useRef(selector);\n\tselectorRef.current = selector;\n\n\tconst prevRef = useRef<{ readonly value: T; readonly initialized: boolean }>({\n\t\tvalue: undefined as T,\n\t\tinitialized: false,\n\t});\n\n\tconst subscribe = useCallback((onStoreChange: () => void) => form.subscribe(onStoreChange), [form]);\n\n\tconst getSnapshot = useCallback((): T => {\n\t\tconst next = selectorRef.current(form.getState());\n\t\tif (prevRef.current.initialized && eqRef.current(prevRef.current.value, next)) {\n\t\t\treturn prevRef.current.value;\n\t\t}\n\t\tprevRef.current = { value: next, initialized: true };\n\t\treturn next;\n\t}, [form]);\n\n\treturn useSyncExternalStore(subscribe, getSnapshot, getSnapshot);\n}\n","import type { FieldApi, FieldConfig, FieldMetaEntry, FormApi } from \"@formbar/core\";\nimport { useMemo, useRef } from \"react\";\nimport { useFormSelector } from \"./use-form-selector.js\";\n\ninterface FieldSnapshot {\n\treadonly value: unknown;\n\treadonly meta: FieldMetaEntry | undefined;\n}\n\nfunction fieldSnapshotEqual(a: FieldSnapshot, b: FieldSnapshot): boolean {\n\treturn a.value === b.value && a.meta === b.meta;\n}\n\n/**\n * React hook that subscribes to a specific field with fine-grained re-rendering.\n * Only re-renders when the field's value or metadata actually changes.\n *\n * @param form - The {@link FormApi} instance (from useForm or createForm).\n * @param path - Dot-path to the field (e.g., `\"user.email\"`).\n * @param config - Optional field configuration (label, validators, triggers).\n * @returns A {@link FieldApi} with reactive get/set, validation, and touch tracking.\n *\n * @example\n * ```typescript\n * function EmailField({ form }) {\n * const field = useField(form, \"email\");\n * return (\n * <div>\n * <input\n * value={field.get() ?? \"\"}\n * onChange={e => field.handleChange(e.target.value)}\n * onBlur={() => field.handleBlur()}\n * />\n * {field.issues().map(i => <span key={i.code}>{i.message}</span>)}\n * </div>\n * );\n * }\n * ```\n */\nexport function useField<TData, TUi, P extends string>(\n\tform: FormApi<TData, TUi>,\n\tpath: P,\n\tconfig?: FieldConfig,\n): FieldApi<TData, TUi, P> {\n\t// Stabilize config reference — inline objects create new references every render\n\tconst configRef = useRef(config);\n\tconst stableConfig = useMemo(() => {\n\t\tconst prev = configRef.current;\n\t\tif (prev === config) return prev;\n\t\tif (prev && config && JSON.stringify(prev) === JSON.stringify(config)) return prev;\n\t\tconfigRef.current = config;\n\t\treturn config;\n\t}, [config]);\n\n\tconst field = useMemo(() => form.fieldDynamic(path, stableConfig), [form, path, stableConfig]);\n\n\t// Subscribe to field value and touched state to trigger re-renders\n\tuseFormSelector(\n\t\tform,\n\t\t() => {\n\t\t\tconst state = form.getState();\n\t\t\tconst pathKey = field.path.segments.join(\".\");\n\t\t\tconst meta = (state.fieldMeta as Readonly<Record<string, FieldMetaEntry>>)[pathKey];\n\t\t\treturn { value: field.get(), meta } as FieldSnapshot;\n\t\t},\n\t\tfieldSnapshotEqual,\n\t);\n\n\treturn field as FieldApi<TData, TUi, P>;\n}\n","import type { CreateFormOptions } from \"@formbar/core\";\nimport type { UseFormOptions } from \"./use-form.js\";\n\nexport function getCoreFormOptions<TData, TUi>(\n\toptions: UseFormOptions<TData, TUi> | undefined,\n): CreateFormOptions<TData, TUi> | undefined {\n\tif (options === undefined) return undefined;\n\ttry {\n\t\tif (!Object.hasOwn(options, \"autoFocusOnError\")) return options;\n\t\tconst { autoFocusOnError: _reactOption, ...coreDescriptors } = Object.getOwnPropertyDescriptors(options);\n\t\treturn Object.create(Object.getPrototypeOf(options), coreDescriptors) as CreateFormOptions<TData, TUi>;\n\t} catch {\n\t\t// Option diagnostics must not make useForm less resilient than createForm.\n\t\treturn options;\n\t}\n}\n","import type { CreateFormOptions, FormApi, SubmitResult } from \"@formbar/core\";\nimport { createForm } from \"@formbar/core\";\nimport { useEffect, useMemo, useRef, useSyncExternalStore } from \"react\";\nimport { focusFirstError } from \"./a11y.js\";\nimport { getCoreFormOptions } from \"./core-form-options.js\";\n\n/** Options for useForm, extending core CreateFormOptions with React-specific behavior */\nexport interface UseFormOptions<TData, TUi> extends CreateFormOptions<TData, TUi> {\n\t/** Auto-focus the first error field on submit failure (default: true) */\n\treadonly autoFocusOnError?: boolean;\n}\n\n/**\n * React hook that creates and manages a form instance with automatic cleanup.\n * The form is created once on mount and disposed on unmount (StrictMode-safe).\n *\n * @param options - Form configuration (same as {@link createForm} options).\n * @returns A stable {@link FormApi} reference that persists across re-renders.\n *\n * @example\n * ```typescript\n * function ContactForm() {\n * const form = useForm({\n * initialData: { name: \"\", email: \"\" },\n * onSubmit: async ({ payload }) => {\n * await saveContact(payload);\n * return { ok: true, submitId: \"1\" };\n * },\n * });\n *\n * return <input value={form.field(\"name\").get()} onChange={e => form.field(\"name\").set(e.target.value)} />;\n * }\n * ```\n */\nexport function useForm<TData, TUi>(options?: UseFormOptions<TData, TUi>): FormApi<TData, TUi> {\n\tconst autoFocus = options?.autoFocusOnError ?? true;\n\tconst formRef = useRef<FormApi<TData, TUi> | null>(null);\n\tconst disposeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n\n\tif (formRef.current === null) {\n\t\tformRef.current = createForm<TData, TUi>(getCoreFormOptions(options));\n\t}\n\n\tconst form = formRef.current;\n\n\t// Adapt form.subscribe (which passes state) to useSyncExternalStore's expected signature\n\tconst subscribe = useRef((onStoreChange: () => void) => {\n\t\treturn form.subscribe(onStoreChange);\n\t}).current;\n\tconst getSnapshot = useRef(() => form.getState()).current;\n\n\tuseSyncExternalStore(subscribe, getSnapshot, getSnapshot);\n\n\t// Deferred disposal: schedule dispose in a macrotask so StrictMode remount can cancel it\n\tuseEffect(() => {\n\t\tif (disposeTimerRef.current !== null) {\n\t\t\tclearTimeout(disposeTimerRef.current);\n\t\t\tdisposeTimerRef.current = null;\n\t\t}\n\t\treturn () => {\n\t\t\tdisposeTimerRef.current = setTimeout(() => {\n\t\t\t\tformRef.current?.dispose();\n\t\t\t}, 0);\n\t\t};\n\t}, []);\n\n\t// Wrap the form API to auto-focus on submit errors (ADR §12)\n\tconst wrappedApi = useMemo((): FormApi<TData, TUi> => {\n\t\tif (!autoFocus) return form;\n\n\t\treturn {\n\t\t\t...form,\n\t\t\tsubmit: async (...args: Parameters<FormApi<TData, TUi>[\"submit\"]>): Promise<SubmitResult> => {\n\t\t\t\tconst result = await form.submit(...args);\n\t\t\t\tif (!result.ok && result.fieldIssues?.length) {\n\t\t\t\t\tfocusFirstError(result.fieldIssues);\n\t\t\t\t}\n\t\t\t\treturn result;\n\t\t\t},\n\t\t};\n\t}, [form, autoFocus]);\n\n\treturn wrappedApi;\n}\n","import type { ExpressionService, PropDefinitions, ResolvedProps } from \"@formbar/expressions\";\nimport { useMemo, useSyncExternalStore } from \"react\";\n\n/** Keep definitions stable with useMemo; subscription cleanup also invalidates retained setters. */\nexport function useExpressionProps(service: ExpressionService, definitions: PropDefinitions): ResolvedProps {\n\tconst binding = useMemo(() => service.resolveProps(definitions), [service, definitions]);\n\treturn useSyncExternalStore(binding.subscribe, binding.getSnapshot, binding.getSnapshot);\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@formbar/react",
3
- "version": "0.14.0",
3
+ "version": "0.14.1",
4
4
  "type": "module",
5
5
  "description": "React hooks and a11y utilities for @formbar/core",
6
6
  "license": "MIT",
@@ -0,0 +1,271 @@
1
+ // @vitest-environment jsdom
2
+ import type { FormApi, FormPlugin, FormState } from "@formbar/core";
3
+ import { StrictMode, act } from "react";
4
+ import type { ReactNode } from "react";
5
+ import { createRoot, hydrateRoot } from "react-dom/client";
6
+ import * as server from "react-dom/server";
7
+ import { afterEach, describe, expect, it, vi } from "vitest";
8
+ import { useForm } from "../index.js";
9
+
10
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
11
+
12
+ interface Data {
13
+ name: string;
14
+ nested: { count: number };
15
+ }
16
+
17
+ interface UiState {
18
+ theme: string;
19
+ }
20
+
21
+ interface Lifecycle {
22
+ readonly forms: Set<FormApi<Data, UiState>>;
23
+ readonly snapshots: Map<FormApi<Data, UiState>, readonly FormState<Data, UiState>[]>;
24
+ readonly subscribedForms: Set<FormApi<Data, UiState>>;
25
+ readonly disposeCalls: Map<FormApi<Data, UiState>, number>;
26
+ active: number;
27
+ maxActive: number;
28
+ subscriptions: number;
29
+ unsubscriptions: number;
30
+ actionCalls: number;
31
+ pluginEvaluations: number;
32
+ }
33
+
34
+ interface PluginSessions {
35
+ active: number;
36
+ maxActive: number;
37
+ created: number;
38
+ disposed: number;
39
+ }
40
+
41
+ function createLifecycle(): Lifecycle {
42
+ return {
43
+ forms: new Set(),
44
+ snapshots: new Map(),
45
+ subscribedForms: new Set(),
46
+ disposeCalls: new Map(),
47
+ active: 0,
48
+ maxActive: 0,
49
+ subscriptions: 0,
50
+ unsubscriptions: 0,
51
+ actionCalls: 0,
52
+ pluginEvaluations: 0,
53
+ };
54
+ }
55
+
56
+ function observeLifecycle(form: FormApi<Data, UiState>, lifecycle: Lifecycle): void {
57
+ if (lifecycle.forms.has(form)) return;
58
+ lifecycle.forms.add(form);
59
+ const subscribe = form.subscribe;
60
+ form.subscribe = (listener) => {
61
+ const unsubscribe = subscribe(listener);
62
+ let active = true;
63
+ lifecycle.subscribedForms.add(form);
64
+ lifecycle.subscriptions += 1;
65
+ lifecycle.active += 1;
66
+ lifecycle.maxActive = Math.max(lifecycle.maxActive, lifecycle.active);
67
+ return () => {
68
+ if (!active) return;
69
+ active = false;
70
+ lifecycle.unsubscriptions += 1;
71
+ lifecycle.active -= 1;
72
+ unsubscribe();
73
+ };
74
+ };
75
+ const dispose = form.dispose;
76
+ form.dispose = () => {
77
+ if (!form.isDisposed()) lifecycle.disposeCalls.set(form, (lifecycle.disposeCalls.get(form) ?? 0) + 1);
78
+ dispose();
79
+ };
80
+ }
81
+
82
+ function startPluginSession(sessions: PluginSessions): () => void {
83
+ sessions.active += 1;
84
+ sessions.created += 1;
85
+ sessions.maxActive = Math.max(sessions.maxActive, sessions.active);
86
+ return () => {
87
+ sessions.active -= 1;
88
+ sessions.disposed += 1;
89
+ };
90
+ }
91
+
92
+ function Snapshot(props: { readonly lifecycle: Lifecycle; readonly sessions?: PluginSessions }) {
93
+ const plugin: FormPlugin<Data, UiState> = {
94
+ id: "ssr-evidence",
95
+ evaluate: () => {
96
+ props.lifecycle.pluginEvaluations += 1;
97
+ },
98
+ ...(props.sessions ? { onInit: () => startPluginSession(props.sessions as PluginSessions) } : {}),
99
+ };
100
+ const initialData = { name: "Ada", nested: { count: 2 } };
101
+ const initialUiState = { theme: "night" };
102
+ const form = useForm<Data, UiState>({
103
+ initialData,
104
+ initialUiState,
105
+ plugins: [plugin],
106
+ autoFocusOnError: false,
107
+ onSubmit: async () => {
108
+ props.lifecycle.actionCalls += 1;
109
+ return { ok: true, submitId: "unexpected" };
110
+ },
111
+ });
112
+ observeLifecycle(form, props.lifecycle);
113
+ const first = form.getState();
114
+ const second = form.getState();
115
+ if (!props.lifecycle.snapshots.has(form)) props.lifecycle.snapshots.set(form, [first, second]);
116
+ return (
117
+ <output
118
+ data-name={first.data.name}
119
+ data-theme={first.uiState.theme}
120
+ data-issues={first.issues.length}
121
+ data-status={first.meta.submission?.status ?? "idle"}
122
+ data-validating={String(first.meta.validation.validating === true)}
123
+ data-submitted={String(first.meta.submitted === true)}
124
+ data-pristine={String(form.isPristine())}
125
+ data-dirty={String(form.isDirty())}
126
+ data-cloned={String(first.data !== initialData && first.uiState !== initialUiState)}
127
+ data-same-snapshot={String(first === second)}
128
+ >
129
+ {first.data.name}:{first.uiState.theme}
130
+ </output>
131
+ );
132
+ }
133
+
134
+ async function readableStreamHtml(element: ReactNode): Promise<string | undefined> {
135
+ if (!("renderToReadableStream" in server)) return undefined;
136
+ const stream = await server.renderToReadableStream(element);
137
+ return new Response(stream).text();
138
+ }
139
+
140
+ async function flushDisposalTimers(): Promise<void> {
141
+ await act(async () => {
142
+ await vi.runAllTimersAsync();
143
+ await Promise.resolve();
144
+ });
145
+ }
146
+
147
+ afterEach(() => {
148
+ vi.restoreAllMocks();
149
+ vi.useRealTimers();
150
+ document.body.replaceChildren();
151
+ });
152
+
153
+ describe("useForm SSR snapshots", () => {
154
+ it("renders authoritative stable initial state to strings and streams without subscribing or running actions", async () => {
155
+ const lifecycle = createLifecycle();
156
+ const firstHtml = server.renderToString(<Snapshot lifecycle={lifecycle} />);
157
+ const secondHtml = server.renderToString(<Snapshot lifecycle={lifecycle} />);
158
+ const streamedHtml = await readableStreamHtml(<Snapshot lifecycle={lifecycle} />);
159
+
160
+ expect(secondHtml).toBe(firstHtml);
161
+ if (streamedHtml !== undefined) expect(streamedHtml).toBe(firstHtml);
162
+ expect(firstHtml).toContain('data-name="Ada"');
163
+ expect(firstHtml).toContain('data-theme="night"');
164
+ expect(firstHtml).toContain('data-issues="0"');
165
+ expect(firstHtml).toContain('data-status="idle"');
166
+ expect(firstHtml).toContain('data-validating="false"');
167
+ expect(firstHtml).toContain('data-submitted="false"');
168
+ expect(firstHtml).toContain('data-pristine="true"');
169
+ expect(firstHtml).toContain('data-dirty="false"');
170
+ expect(firstHtml).toContain('data-cloned="true"');
171
+ expect(firstHtml).toContain('data-same-snapshot="true"');
172
+ expect(lifecycle.active).toBe(0);
173
+ expect(lifecycle.subscriptions).toBe(0);
174
+ expect(lifecycle.actionCalls).toBe(0);
175
+ expect(lifecycle.pluginEvaluations).toBe(0);
176
+
177
+ const forms = [...lifecycle.forms];
178
+ expect(forms).toHaveLength(streamedHtml === undefined ? 2 : 3);
179
+ const states = forms.map((form) => lifecycle.snapshots.get(form)?.[0]);
180
+ expect(new Set(states).size).toBe(states.length);
181
+ for (const form of forms) {
182
+ const snapshots = lifecycle.snapshots.get(form);
183
+ expect(snapshots?.[0]).toBe(snapshots?.[1]);
184
+ expect(snapshots?.[0]).toMatchObject({
185
+ data: { name: "Ada", nested: { count: 2 } },
186
+ uiState: { theme: "night" },
187
+ meta: { validation: { validating: false } },
188
+ fieldMeta: {},
189
+ fieldPolicy: [],
190
+ issues: [],
191
+ });
192
+ expect(snapshots?.[0]?.meta.submission).toBeUndefined();
193
+ expect(snapshots?.[0]?.meta.submitted).toBeUndefined();
194
+ expect(form.isTouched()).toBe(false);
195
+ expect(form.isSubmitting()).toBe(false);
196
+ form.dispose();
197
+ }
198
+ });
199
+
200
+ it("hydrates without diagnostics, subscribes after commit, and renders later transitions", async () => {
201
+ vi.useFakeTimers();
202
+ const serverLifecycle = createLifecycle();
203
+ const html = server.renderToString(<Snapshot lifecycle={serverLifecycle} />);
204
+ for (const form of serverLifecycle.forms) form.dispose();
205
+ const clientLifecycle = createLifecycle();
206
+ const container = document.createElement("div");
207
+ container.innerHTML = html;
208
+ document.body.append(container);
209
+ const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
210
+ const recoverable: unknown[] = [];
211
+ let root: ReturnType<typeof hydrateRoot>;
212
+ await act(async () => {
213
+ root = hydrateRoot(container, <Snapshot lifecycle={clientLifecycle} />, {
214
+ onRecoverableError: (error) => recoverable.push(error),
215
+ });
216
+ await Promise.resolve();
217
+ });
218
+ const [form] = [...clientLifecycle.subscribedForms];
219
+ const initial = form.getState();
220
+ expect(clientLifecycle.active).toBe(1);
221
+ expect(clientLifecycle.maxActive).toBe(1);
222
+ expect(consoleError).not.toHaveBeenCalled();
223
+ expect(recoverable).toEqual([]);
224
+
225
+ act(() => form.setValue("name", "Grace"));
226
+ expect(form.getState()).not.toBe(initial);
227
+ expect(container.querySelector("output")?.textContent).toBe("Grace:night");
228
+ expect(clientLifecycle.actionCalls).toBe(0);
229
+ act(() => root.unmount());
230
+ expect(clientLifecycle.active).toBe(0);
231
+ expect(clientLifecycle.unsubscriptions).toBe(clientLifecycle.subscriptions);
232
+ await flushDisposalTimers();
233
+ expect(clientLifecycle.disposeCalls.get(form)).toBe(1);
234
+ expect(form.isDisposed()).toBe(true);
235
+ });
236
+
237
+ it("keeps StrictMode subscriptions singular and committed cleanup balanced", async () => {
238
+ vi.useFakeTimers();
239
+ const lifecycle = createLifecycle();
240
+ const sessions: PluginSessions = { active: 0, maxActive: 0, created: 0, disposed: 0 };
241
+ const container = document.createElement("div");
242
+ document.body.append(container);
243
+ const root = createRoot(container);
244
+ act(() =>
245
+ root.render(
246
+ <StrictMode>
247
+ <Snapshot lifecycle={lifecycle} sessions={sessions} />
248
+ </StrictMode>,
249
+ ),
250
+ );
251
+ expect(lifecycle.active).toBe(1);
252
+ expect(lifecycle.maxActive).toBe(1);
253
+ expect(lifecycle.subscribedForms.size).toBe(1);
254
+ expect(sessions.active).toBe(1);
255
+ expect(sessions.maxActive).toBe(1);
256
+ for (const form of lifecycle.forms) {
257
+ if (!lifecycle.subscribedForms.has(form)) expect(lifecycle.snapshots.has(form)).toBe(true);
258
+ }
259
+
260
+ act(() => root.unmount());
261
+ expect(lifecycle.active).toBe(0);
262
+ expect(lifecycle.unsubscriptions).toBe(lifecycle.subscriptions);
263
+ await flushDisposalTimers();
264
+ for (const form of lifecycle.subscribedForms) {
265
+ expect(lifecycle.disposeCalls.get(form)).toBe(1);
266
+ expect(form.isDisposed()).toBe(true);
267
+ }
268
+ expect(sessions.active).toBe(0);
269
+ expect(sessions.disposed).toBe(sessions.created);
270
+ });
271
+ });
package/src/use-form.ts CHANGED
@@ -47,8 +47,9 @@ export function useForm<TData, TUi>(options?: UseFormOptions<TData, TUi>): FormA
47
47
  const subscribe = useRef((onStoreChange: () => void) => {
48
48
  return form.subscribe(onStoreChange);
49
49
  }).current;
50
+ const getSnapshot = useRef(() => form.getState()).current;
50
51
 
51
- useSyncExternalStore(subscribe, () => form.getState());
52
+ useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
52
53
 
53
54
  // Deferred disposal: schedule dispose in a macrotask so StrictMode remount can cancel it
54
55
  useEffect(() => {