import React, { type ComponentType, type Ref, useMemo } from 'react'; import { useController, type FieldPath, type FieldValues, type UseControllerProps, } from 'react-hook-form'; import { Input } from '@mantine/core'; import { useTranslation } from '@repo/core-i18n'; import type { ZodI18nPayload, WithRHFOptions } from './types'; // --------------------------------------------------------------------------- // Helper: Attempt to parse a Zod error message as a JSON i18n payload // --------------------------------------------------------------------------- function tryParseI18nPayload(message: string): ZodI18nPayload | null { // Quick guard: JSON payloads always start with '{' if (!message.startsWith('{')) return null; try { const parsed: unknown = JSON.parse(message); if ( typeof parsed === 'object' && parsed !== null && 'key' in parsed && typeof (parsed as ZodI18nPayload).key === 'string' ) { return parsed as ZodI18nPayload; } } catch { // Not valid JSON — this is expected for plain string error messages } return null; } // --------------------------------------------------------------------------- // useTranslatedError — Hook that resolves a raw error message into a // user-facing translated string. // --------------------------------------------------------------------------- function useTranslatedError(rawMessage: string | undefined): string | undefined { // Always call useTranslation — React hook rules require stable call order. // The 'validation' namespace is used for Zod error keys. // Falls back to 'common' automatically via i18next's ns resolution. const { t, i18n } = useTranslation(); return useMemo(() => { if (!rawMessage) return undefined; const payload = tryParseI18nPayload(rawMessage); if (payload) { // Attempt to translate. If the key exists in i18n resources, we get // the translated string. Otherwise i18next returns the key itself, // and we fall back to the raw Zod message. const translated = t(payload.key, { ...payload.values, ns: 'validation', defaultValue: payload.key, // fallback to the key itself }); // If i18next couldn't find the key (returned the key unchanged), // try without namespace, then fall back to the raw Zod message. if (translated === payload.key) { const commonAttempt = t(payload.key, { ...payload.values, defaultValue: rawMessage, }); return commonAttempt; } return translated; } // Not a JSON payload — check if the raw message itself is a translation key if (i18n.exists(rawMessage, { ns: 'validation' })) { return t(rawMessage, { ns: 'validation' }); } // Plain string error message — pass through as-is return rawMessage; }, [rawMessage, t, i18n]); } // --------------------------------------------------------------------------- // withRHF — Higher-Order Component Factory // --------------------------------------------------------------------------- // // PERFORMANCE NOTES (ERP 1500+ field forms): // ------------------------------------------- // 1. `useController` creates a MICRO-SUBSCRIPTION for this field only. // The component will NOT re-render when unrelated fields change. // // 2. `React.memo` is applied on the OUTER wrapper component. This provides // a second defense layer: even if a parent component re-renders (e.g., // a layout grid reshuffles), this field will bail out of rendering if // its own props haven't changed. // // 3. Together, useController + React.memo gives us O(1) render cost per // keystroke regardless of total form size — critical for ERP-scale forms. // // WHY React.memo IS WARRANTED HERE: // In smaller forms (<50 fields), React.memo's shallow comparison cost is // negligible but unnecessary. However, in ERP forms with 1500+ fields // rendered in virtualized grids, each wasted render cascade can add // ~16ms of jank. The memo wrapper prevents this with near-zero overhead // (shallow prop comparison is O(n) on prop count, typically <10 props). // --------------------------------------------------------------------------- /** * Creates a React Hook Form-connected wrapper around any Mantine form component. * * @param displayName - The display name for the wrapped component (e.g., "FieldTextInput") * @param MantineComponent - The Mantine component to wrap * @param options - Configuration for special component types (checkbox, wrapper-needed, etc.) * * @example * ```tsx * import { TextInput } from '@mantine/core'; * import { withRHF } from './withRHF'; * * export const FieldTextInput = withRHF('FieldTextInput', TextInput); * ``` */ export function withRHF>( displayName: string, MantineComponent: ComponentType, options: WithRHFOptions = {}, ) { const { isCheckType = false, requiresWrapper = false } = options; type Props< TFieldValues extends FieldValues = FieldValues, TName extends FieldPath = FieldPath, > = Omit & UseControllerProps & { /** Optional ref forwarded to the underlying Mantine component */ ref?: Ref; }; // ----------------------------------------------------------------------- // The inner component — separated so React.memo can wrap it cleanly. // ----------------------------------------------------------------------- function FieldComponent< TFieldValues extends FieldValues = FieldValues, TName extends FieldPath = FieldPath, >(props: Props) { const { name, control, rules, shouldUnregister, defaultValue, disabled, ref, ...mantineProps } = props; const { field, fieldState: { error }, } = useController({ name, control, rules, shouldUnregister, defaultValue, disabled, }); // Translate the error message (handles JSON i18n payloads) const translatedError = useTranslatedError(error?.message); // Build the props to spread onto the Mantine component const componentProps: Record = { ...mantineProps, ref: ref ?? field.ref, onBlur: field.onBlur, disabled: field.disabled, }; if (isCheckType) { // Checkbox / Switch: use `checked` and boolean onChange componentProps['checked'] = !!field.value; componentProps['onChange'] = (event: React.ChangeEvent | boolean) => { if (typeof event === 'boolean') { field.onChange(event); } else { field.onChange(event.currentTarget.checked); } }; } else { // Standard components: use `value` and direct onChange componentProps['value'] = field.value ?? ''; componentProps['onChange'] = field.onChange; } // Components that lack a native `error` prop need Input.Wrapper if (requiresWrapper) { const { label, description, withAsterisk, ...innerProps } = componentProps as Record; return ( ); } // Standard path: pass error directly to the Mantine component componentProps['error'] = translatedError; return ; } // ----------------------------------------------------------------------- // Apply React.memo for render bailout in large forms. // // We use the default shallow comparison. For ERP forms, this means a // field component like will NOT // re-render when changes, because: // 1. useController isolates the subscription (different field path) // 2. React.memo catches any parent-driven re-renders where our own // props haven't changed (e.g., a Grid layout re-render) // ----------------------------------------------------------------------- const Memoized = React.memo(FieldComponent) as typeof FieldComponent; // Preserve the display name for React DevTools (Memoized as unknown as { displayName: string }).displayName = displayName; return Memoized; }