/** * FormFillContext — React context for form fill state management. * * Provider-agnostic: delegates data fetching/saving to an IFormDataProvider. * - PdfLibFormProvider: frontend-only, uses pdf-lib (for normal viewer mode) * - PdfBoxFormProvider: backend API via PDFBox (for dedicated formFill tool) * * The active provider can be switched at runtime via setProvider(). This allows * EmbedPdfViewer to auto-select: * - Normal viewer → PdfLibFormProvider (no backend calls for large PDFs) * - formFill tool → PdfBoxFormProvider (full-fidelity PDFBox handling) * * Performance Architecture: * Form values are stored in a FormValuesStore (external to React state) to * avoid full context re-renders on every keystroke. Individual widgets * subscribe to their specific field via useFieldValue() + useSyncExternalStore, * so only the active widget re-renders when its value changes. * * The UI components (FormFieldOverlay, FormFill, FormFieldSidebar) consume * this context regardless of which provider is active. */ import React, { createContext, useCallback, useContext, useMemo, useReducer, useRef, useState, useSyncExternalStore, } from 'react'; import { useDebouncedCallback } from '@mantine/hooks'; import type { FormField, FormFillState, WidgetCoordinates } from '@proprietary/tools/formFill/types'; import type { IFormDataProvider } from '@proprietary/tools/formFill/providers/types'; import { PdfLibFormProvider } from '@proprietary/tools/formFill/providers/PdfLibFormProvider'; import { PdfBoxFormProvider } from '@proprietary/tools/formFill/providers/PdfBoxFormProvider'; // --------------------------------------------------------------------------- // FormValuesStore — external store for field values (outside React state) // --------------------------------------------------------------------------- type Listener = () => void; /** * External store that holds form values outside of React state. * * This avoids triggering full context re-renders on every keystroke. * Components subscribe per-field via useSyncExternalStore, so only * the widget being edited re-renders. */ class FormValuesStore { private _fieldListeners = new Map>(); private _globalListeners = new Set(); private _values: Record = {}; get values(): Record { return this._values; } private _version = 0; get version(): number { return this._version; } getValue(fieldName: string): string { return this._values[fieldName] ?? ''; } setValue(fieldName: string, value: string): void { if (this._values[fieldName] === value) return; this._values[fieldName] = value; this._version++; this._fieldListeners.get(fieldName)?.forEach((l) => l()); this._globalListeners.forEach((l) => l()); } /** Replace all values (e.g., on fetch or reset) */ reset(values: Record = {}): void { this._values = values; this._version++; for (const listeners of this._fieldListeners.values()) { listeners.forEach((l) => l()); } this._globalListeners.forEach((l) => l()); } /** Subscribe to a single field's value changes */ subscribeField(fieldName: string, listener: Listener): () => void { if (!this._fieldListeners.has(fieldName)) { this._fieldListeners.set(fieldName, new Set()); } this._fieldListeners.get(fieldName)!.add(listener); return () => { this._fieldListeners.get(fieldName)?.delete(listener); }; } /** Subscribe to any value change */ subscribeGlobal(listener: Listener): () => void { this._globalListeners.add(listener); return () => { this._globalListeners.delete(listener); }; } } // --------------------------------------------------------------------------- // Reducer — handles everything EXCEPT values (which live in FormValuesStore) // --------------------------------------------------------------------------- type Action = | { type: 'FETCH_START' } | { type: 'FETCH_SUCCESS'; fields: FormField[] } | { type: 'FETCH_ERROR'; error: string } | { type: 'MARK_DIRTY' } | { type: 'SET_ACTIVE_FIELD'; fieldName: string | null } | { type: 'SET_VALIDATION_ERRORS'; errors: Record } | { type: 'CLEAR_VALIDATION_ERROR'; fieldName: string } | { type: 'MARK_CLEAN' } | { type: 'RESET' }; const initialState: FormFillState = { fields: [], values: {}, // kept for backward compat but canonical values live in FormValuesStore loading: false, error: null, activeFieldName: null, isDirty: false, validationErrors: {}, }; function reducer(state: FormFillState, action: Action): FormFillState { switch (action.type) { case 'FETCH_START': return { ...state, loading: true, error: null }; case 'FETCH_SUCCESS': { return { ...state, fields: action.fields, values: {}, // values managed by FormValuesStore loading: false, error: null, isDirty: false, }; } case 'FETCH_ERROR': return { ...state, loading: false, error: action.error }; case 'MARK_DIRTY': if (state.isDirty) return state; // avoid unnecessary re-render return { ...state, isDirty: true }; case 'SET_ACTIVE_FIELD': return { ...state, activeFieldName: action.fieldName }; case 'SET_VALIDATION_ERRORS': return { ...state, validationErrors: action.errors }; case 'CLEAR_VALIDATION_ERROR': { if (!state.validationErrors[action.fieldName]) return state; const { [action.fieldName]: _, ...rest } = state.validationErrors; return { ...state, validationErrors: rest }; } case 'MARK_CLEAN': return { ...state, isDirty: false }; case 'RESET': return initialState; default: return state; } } export interface FormFillContextValue { state: FormFillState; /** Fetch form fields for the given file using the active provider */ fetchFields: (file: File | Blob, fileId?: string) => Promise; /** Update a single field value */ setValue: (fieldName: string, value: string) => void; /** Set the currently focused field */ setActiveField: (fieldName: string | null) => void; /** Submit filled form and return the filled PDF blob */ submitForm: ( file: File | Blob, flatten?: boolean ) => Promise; /** Get field by name */ getField: (fieldName: string) => FormField | undefined; /** Get fields for a specific page index */ getFieldsForPage: (pageIndex: number) => FormField[]; /** Get the current value for a field (reads from external store) */ getValue: (fieldName: string) => string; /** Validate the current form state and return true if valid */ validateForm: () => boolean; /** Clear all form state (fields, values, errors) */ reset: () => void; /** Pre-computed map of page index to fields for performance */ fieldsByPage: Map; /** Name of the currently active provider ('pdf-lib' | 'pdfbox') */ activeProviderName: string; /** * Switch the active data provider. * Use 'pdflib' for frontend-only pdf-lib, 'pdfbox' for backend PDFBox. * Resets form state when switching providers. */ setProviderMode: (mode: 'pdflib' | 'pdfbox') => void; /** The file ID that the current form fields belong to (null if no fields loaded) */ forFileId: string | null; } const FormFillContext = createContext(null); /** * Separate context for the values store. * This allows useFieldValue() to subscribe without depending on the main context. */ const FormValuesStoreContext = createContext(null); export const useFormFill = (): FormFillContextValue => { const ctx = useContext(FormFillContext); if (!ctx) { throw new Error('useFormFill must be used within a FormFillProvider'); } return ctx; }; /** * Subscribe to a single field's value. Only re-renders when that specific * field's value changes — not when any other form value changes. * * Uses useSyncExternalStore for tear-free reads. */ export function useFieldValue(fieldName: string): string { const store = useContext(FormValuesStoreContext); if (!store) { throw new Error('useFieldValue must be used within a FormFillProvider'); } const subscribe = useCallback( (cb: () => void) => store.subscribeField(fieldName, cb), [store, fieldName] ); const getSnapshot = useCallback( () => store.getValue(fieldName), [store, fieldName] ); return useSyncExternalStore(subscribe, getSnapshot); } /** * Subscribe to all values (e.g., for progress counters or form submission). * Re-renders on every value change — use sparingly. */ export function useAllFormValues(): Record { const store = useContext(FormValuesStoreContext); if (!store) { throw new Error('useAllFormValues must be used within a FormFillProvider'); } const subscribe = useCallback( (cb: () => void) => store.subscribeGlobal(cb), [store] ); const getSnapshot = useCallback( () => store.values, [store] ); return useSyncExternalStore(subscribe, getSnapshot); } /** Singleton provider instances */ const pdfLibProvider = new PdfLibFormProvider(); const pdfBoxProvider = new PdfBoxFormProvider(); export function FormFillProvider({ children, provider: providerProp, }: { children: React.ReactNode; /** Override the initial provider. If not given, defaults to pdf-lib. */ provider?: IFormDataProvider; }) { const initialMode = providerProp?.name === 'pdfbox' ? 'pdfbox' : 'pdflib'; const [providerMode, setProviderModeState] = useState<'pdflib' | 'pdfbox'>(initialMode); const providerModeRef = useRef(initialMode as 'pdflib' | 'pdfbox'); providerModeRef.current = providerMode; const provider = providerProp ?? (providerMode === 'pdfbox' ? pdfBoxProvider : pdfLibProvider); const providerRef = useRef(provider); providerRef.current = provider; const [state, dispatch] = useReducer(reducer, initialState); const fieldsRef = useRef([]); fieldsRef.current = state.fields; // Version counter to cancel stale async fetch responses. // Incremented on every fetchFields() and reset() call. const fetchVersionRef = useRef(0); // Track which file the current fields belong to const forFileIdRef = useRef(null); const [forFileId, setForFileId] = useState(null); // External values store — values live HERE, not in the reducer. // This prevents full context re-renders on every keystroke. const [valuesStore] = useState(() => new FormValuesStore()); const fetchFields = useCallback(async (file: File | Blob, fileId?: string) => { // Increment version so any in-flight fetch for a previous file is discarded. // NOTE: setProviderMode() also increments fetchVersionRef to invalidate // in-flight fetches when switching providers. This is intentional — the // fetch started here captures the NEW version, so stale results are // correctly discarded. const version = ++fetchVersionRef.current; // Immediately clear previous state so FormFieldOverlay's stale-file guards // prevent rendering fields from a previous document during the fetch. forFileIdRef.current = null; setForFileId(null); valuesStore.reset({}); dispatch({ type: 'RESET' }); dispatch({ type: 'FETCH_START' }); try { const fields = await providerRef.current.fetchFields(file); // If another fetch or reset happened while we were waiting, discard this result if (fetchVersionRef.current !== version) { console.log('[FormFill] Discarding stale fetch result (version mismatch)'); return; } // Initialise values in the external store const values: Record = {}; for (const field of fields) { values[field.name] = field.value ?? ''; } valuesStore.reset(values); forFileIdRef.current = fileId ?? null; setForFileId(fileId ?? null); dispatch({ type: 'FETCH_SUCCESS', fields }); } catch (err: any) { if (fetchVersionRef.current !== version) return; // stale const msg = err?.response?.data?.message || err?.message || 'Failed to fetch form fields'; dispatch({ type: 'FETCH_ERROR', error: msg }); } }, [valuesStore]); const validateFieldDebounced = useDebouncedCallback((fieldName: string) => { const field = fieldsRef.current.find((f) => f.name === fieldName); if (!field || !field.required) return; const val = valuesStore.getValue(fieldName); if (!val || val.trim() === '' || val === 'Off') { dispatch({ type: 'SET_VALIDATION_ERRORS', errors: { ...state.validationErrors, [fieldName]: `${field.label} is required` }, }); } else { dispatch({ type: 'CLEAR_VALIDATION_ERROR', fieldName }); } }, 300); const validateForm = useCallback((): boolean => { const errors: Record = {}; for (const field of fieldsRef.current) { const val = valuesStore.getValue(field.name); if (field.required && (!val || val.trim() === '' || val === 'Off')) { errors[field.name] = `${field.label} is required`; } } dispatch({ type: 'SET_VALIDATION_ERRORS', errors }); return Object.keys(errors).length === 0; }, [valuesStore]); const setValue = useCallback( (fieldName: string, value: string) => { // Update external store (triggers per-field subscribers only) valuesStore.setValue(fieldName, value); // Mark form as dirty in React state (only triggers re-render once) dispatch({ type: 'MARK_DIRTY' }); validateFieldDebounced(fieldName); }, [valuesStore, validateFieldDebounced] ); const setActiveField = useCallback( (fieldName: string | null) => { dispatch({ type: 'SET_ACTIVE_FIELD', fieldName }); }, [] ); const submitForm = useCallback( async (file: File | Blob, flatten = false) => { const blob = await providerRef.current.fillForm(file, valuesStore.values, flatten); dispatch({ type: 'MARK_CLEAN' }); return blob; }, [valuesStore] ); const setProviderMode = useCallback( (mode: 'pdflib' | 'pdfbox') => { // Use the ref to check the current mode synchronously — avoids // relying on stale closure state and allows the early return. if (providerModeRef.current === mode) return; // provider (pdfbox vs pdflib). const newProvider = mode === 'pdfbox' ? pdfBoxProvider : pdfLibProvider; providerRef.current = newProvider; providerModeRef.current = mode; fetchVersionRef.current++; forFileIdRef.current = null; setForFileId(null); valuesStore.reset({}); dispatch({ type: 'RESET' }); setProviderModeState(mode); }, [valuesStore] ); const getField = useCallback( (fieldName: string) => fieldsRef.current.find((f) => f.name === fieldName), [] ); const getFieldsForPage = useCallback( (pageIndex: number) => fieldsRef.current.filter((f) => f.widgets?.some((w: WidgetCoordinates) => w.pageIndex === pageIndex) ), [] ); const getValue = useCallback( (fieldName: string) => valuesStore.getValue(fieldName), [valuesStore] ); const reset = useCallback(() => { // Increment version to invalidate any in-flight fetch fetchVersionRef.current++; forFileIdRef.current = null; setForFileId(null); valuesStore.reset({}); dispatch({ type: 'RESET' }); }, [valuesStore]); const fieldsByPage = useMemo(() => { const map = new Map(); for (const field of state.fields) { const pageIdx = field.widgets?.[0]?.pageIndex ?? 0; if (!map.has(pageIdx)) map.set(pageIdx, []); map.get(pageIdx)!.push(field); } return map; }, [state.fields]); // Context value — does NOT depend on values, so keystrokes don't // trigger re-renders of all context consumers. const value = useMemo( () => ({ state, fetchFields, setValue, setActiveField, submitForm, getField, getFieldsForPage, getValue, validateForm, reset, fieldsByPage, activeProviderName: providerRef.current.name, setProviderMode, forFileId, }), [ state, fetchFields, setValue, setActiveField, submitForm, getField, getFieldsForPage, getValue, validateForm, reset, fieldsByPage, providerMode, setProviderMode, forFileId, ] ); return ( {children} ); } export default FormFillContext;