UNPKG

rune-form

Version:

Type-safe reactive form builder for Svelte 5

683 lines (682 loc) 27.9 kB
import { SvelteMap, SvelteSet } from 'svelte/reactivity'; import { createZodValidator } from './zodAdapter.js'; export class RuneForm { validator; initialData; _data = $state({}); errors = $state({}); customErrors = $state({}); touched = $state({}); isValid = $state(false); isValidating = $state(false); _errorCount = $state(0); // Unified cache for compiled paths and access functions _pathCache = new SvelteMap(); _fieldCache = new SvelteMap(); _validPaths; _isInternalUpdate = false; // Cache for Proxies to prevent memory leaks _proxyCache = new WeakMap(); // Cache for wrapped array methods to avoid recreating them _methodCache = new WeakMap(); // Set of array methods that modify the array (for O(1) lookup) static _MUTATING_ARRAY_METHODS = new SvelteSet([ 'splice', 'push', 'pop', 'shift', 'unshift', 'reverse', 'sort', 'fill' ]); // Track pending validation for array operations _pendingArrayValidation = new SvelteSet(); // Cache size limits to prevent memory leaks static _MAX_PATH_CACHE_SIZE = 1000; static _MAX_FIELD_CACHE_SIZE = 500; // Pre-compiled regex patterns for array path operations static _ARRAY_INDEX_PATTERN = /^(\d+)\./; static _ARRAY_FULL_PATTERN = /^(\d+)\.(.*)/; constructor(validator, initialData = {}) { this.validator = validator; this.initialData = initialData; this._data = this.safePopulate(this.initialData); const paths = this.validator.getPaths?.() ?? []; for (const path of paths) { this.compilePath(path); } // Precompute valid paths with array index normalization this._validPaths = new SvelteSet(paths); // Optimized validation effect with better tracking let validationTimeout; $effect(() => { // Track specific properties for changes $effect.tracking(); // eslint-disable-next-line @typescript-eslint/no-unused-expressions this._data && this.touched; // Clear existing timeout clearTimeout(validationTimeout); // Debounce validation to avoid excessive calls during rapid updates validationTimeout = setTimeout(() => { this.validateSchema(); }, 100); }); // Create a reactive data object that automatically marks fields as touched this.data = this.createReactiveData(this._data); } // Create a reactive data object that automatically tracks changes createReactiveData = (data, parentPath = '') => { // Check if we already have a Proxy for this object if (this._proxyCache.has(data)) { return this._proxyCache.get(data); } // Cache path prefix for better performance const pathPrefix = parentPath ? `${parentPath}.` : ''; const proxy = new Proxy(data, { get: (target, prop) => { const value = target[prop]; const currentPath = pathPrefix + String(prop); // If the value is an object (but not an array), make it reactive too if (value && typeof value === 'object' && !Array.isArray(value)) { return this.createReactiveData(value, currentPath); } // For arrays, we need to track array element changes if (Array.isArray(value)) { return this.createReactiveArray(value, currentPath); } return value; }, set: (target, prop, value) => { const oldValue = target[prop]; const currentPath = pathPrefix + String(prop); target[prop] = value; // Only mark as touched if this is not an internal update and the value actually changed if (!this._isInternalUpdate && oldValue !== value) { this._handleFieldChange(currentPath); } return true; } }); // Cache the Proxy this._proxyCache.set(data, proxy); return proxy; }; // Create a reactive array that tracks changes to array elements createReactiveArray = (array, parentPath) => { // Check if we already have a Proxy for this array if (this._proxyCache.has(array)) { return this._proxyCache.get(array); } // Cache path prefix for better performance const pathPrefix = `${parentPath}.`; const proxy = new Proxy(array, { get: (target, prop) => { const value = target[prop]; // If accessing an array element that's an object, make it reactive if (typeof prop === 'string' && /^\d+$/.test(prop)) { const elementPath = pathPrefix + prop; if (value && typeof value === 'object' && !Array.isArray(value)) { return this.createReactiveData(value, elementPath); } } // Intercept array methods to ensure reactivity if (typeof prop === 'string' && typeof value === 'function') { // Methods that modify the array if (RuneForm._MUTATING_ARRAY_METHODS.has(prop)) { // Check if we already have a cached wrapper for this method let methodCache = this._methodCache.get(array); if (!methodCache) { methodCache = new SvelteMap(); this._methodCache.set(array, methodCache); } let wrappedMethod = methodCache.get(prop); if (!wrappedMethod) { const method = value; wrappedMethod = (...args) => { const result = method.apply(target, args); // Mark the array as touched and trigger debounced validation if (!this._isInternalUpdate) { this.markTouched(parentPath); this._debouncedArrayValidation(parentPath); // Handle array method specific touched state syncing this._handleArrayMethodTouchedState(prop, args, target, parentPath); } return result; }; methodCache.set(prop, wrappedMethod); } return wrappedMethod; } } return value; }, set: (target, prop, value) => { // Skip read-only properties and symbols if (typeof prop === 'symbol' || prop === 'length') { return true; } const oldValue = target[prop]; const currentPath = pathPrefix + String(prop); // @ts-expect-error - Array properties are writable in practice target[prop] = value; // Only mark as touched if this is not an internal update and the value actually changed if (!this._isInternalUpdate && oldValue !== value) { this._handleFieldChange(currentPath); } return true; } }); // Cache the Proxy this._proxyCache.set(array, proxy); return proxy; }; // Public data getter that returns the reactive data get data() { return this._data; } // Public data setter that handles internal updates set data(value) { this._isInternalUpdate = true; this._data = value; this._isInternalUpdate = false; } static fromSchema(schema, initialData) { return new RuneForm(createZodValidator(schema), initialData); } getField(path) { // Use compiled access for better performance const compiled = this.compilePath(path); const fieldCached = this._fieldCache.get(path); if (fieldCached) return fieldCached; // Cache normalized path for validation const normalizedPath = path .split('.') .map((seg) => (/^\d+$/.test(seg) ? '0' : seg)) .join('.'); if (!this._validPaths.has(normalizedPath)) { return { value: undefined, error: undefined, errors: [], touched: false, constraints: {}, isValidating: false }; } const field = this._createFieldObject(path, compiled); // Limit field cache size to prevent memory leaks if (this._fieldCache.size >= RuneForm._MAX_FIELD_CACHE_SIZE) { // Clear oldest entries (simple FIFO approach) const firstKey = this._fieldCache.keys().next().value; if (firstKey) { this._fieldCache.delete(firstKey); } } this._fieldCache.set(path, field); return field; } compilePath(path) { // Check if we already have compiled access for this path const existing = this._pathCache.get(path); if (existing) { return existing; } // Limit cache size to prevent memory leaks if (this._pathCache.size >= RuneForm._MAX_PATH_CACHE_SIZE) { // Clear oldest entries (simple FIFO approach) const firstKey = this._pathCache.keys().next().value; if (firstKey) { this._pathCache.delete(firstKey); } } // Parse and cache the path - optimize by doing it once const segments = path.split('.'); const keys = new Array(segments.length); const isArrayIndex = new Array(segments.length); for (let i = 0; i < segments.length; i++) { const segment = segments[i]; const isIndex = /^\d+$/.test(segment); keys[i] = isIndex ? Number(segment) : segment; isArrayIndex[i] = isIndex; } const get = (obj) => { let current = obj; for (let i = 0; i < keys.length; i++) { if (current == null || typeof current !== 'object') return undefined; current = current[keys[i]]; } return current; }; const set = (obj, value) => { let current = obj; for (let i = 0; i < keys.length - 1; i++) { const key = keys[i]; const isNextIndex = isArrayIndex[i + 1]; if (typeof current !== 'object' || current === null || !(key in current) || typeof current[key] !== 'object') { current[key] = isNextIndex ? [] : {}; } current = current[key]; } const lastKey = keys[keys.length - 1]; if (typeof current === 'object' && current !== null) { current[lastKey] = value; } }; const compiled = { keys, isArrayIndex, get, set }; this._pathCache.set(path, compiled); return compiled; } safePopulate(data) { if (this.validator.resolveDefaults) { return this.validator.resolveDefaults(data); } try { return this.validator.parse(data); } catch { return data; } } async validateSchema() { this.isValidating = true; try { const result = this.validator.safeParseAsync ? await this.validator.safeParseAsync(this._data) : this.validator.safeParse(this._data); this.errors = result.success ? {} : result.errors; this.isValid = result.success; this._errorCount = result.success ? 0 : Object.keys(result.errors).length; } catch { this.isValid = false; this._errorCount = 1; } finally { this.isValidating = false; } } // Debounced validation for array operations to avoid excessive calls _debouncedArrayValidation(path) { if (this._pendingArrayValidation.has(path)) return; this._pendingArrayValidation.add(path); // Use microtask to batch validation calls queueMicrotask(() => { this._pendingArrayValidation.delete(path); if (!this._isInternalUpdate) { this.validateSchema(); } }); // Safety cleanup: remove stale entries after a timeout setTimeout(() => { if (this._pendingArrayValidation.has(path)) { this._pendingArrayValidation.delete(path); } }, 5000); // 5 second timeout } markTouched(path) { this.touched[path] = true; } markFieldAsPristine(path) { this.touched[path] = false; } markAllTouched() { for (const path of Object.keys(this.errors)) { this.touched[path] = true; } } markAllAsPristine() { this.touched = {}; } // Helper method to reduce array operation duplication _executeArrayOperation(path, operation) { const cached = this._pathCache.get(path); if (!cached) return; const arr = cached.get(this._data); if (Array.isArray(arr)) { // Create a copy of the array to avoid direct mutation const newArray = [...arr]; operation(newArray); // Use the Proxy's setter to ensure reactivity cached.set(this._data, newArray); this.markTouched(path); // Clear stale field cache entries related to this array path this._clearStaleFieldCacheEntries(path); // Recreate reactive proxies for the new array to ensure proper tracking this._isInternalUpdate = true; const updatedArray = cached.get(this._data); if (Array.isArray(updatedArray)) { const reactiveArray = this.createReactiveArray(updatedArray, path); cached.set(this._data, reactiveArray); } this._isInternalUpdate = false; } } reset() { this._isInternalUpdate = true; this._data = this.safePopulate(this.initialData); this._isInternalUpdate = false; this.errors = {}; this.customErrors = {}; this.touched = {}; this.isValid = false; // Clear caches to ensure fresh state this._proxyCache = new WeakMap(); this._methodCache = new WeakMap(); this._fieldCache.clear(); this._pendingArrayValidation.clear(); // Recreate reactive data to ensure fresh Proxies this.data = this.createReactiveData(this._data); } setCustomError(path, message) { this.customErrors[path] = [message]; } setCustomErrors(path, messages) { this.customErrors[path] = messages; } push(path, value) { this._executeArrayOperation(path, (arr) => arr.push(value)); } swap(path, i, j) { this._executeArrayOperation(path, (arr) => { // Swap the array elements [arr[i], arr[j]] = [arr[j], arr[i]]; // Sync touched state for swapped items this._syncTouchedStateForArraySwap(path, i, j); }); } // Method to safely use splice on arrays while maintaining reactivity splice(path, start, deleteCount, ...items) { this._executeArrayOperation(path, (arr) => { const actualDeleteCount = deleteCount ?? arr.length - start; const insertCount = items.length; if (deleteCount !== undefined) { arr.splice(start, deleteCount, ...items); } else { arr.splice(start); } // Sync touched state for array operations if (actualDeleteCount > 0) { this._syncTouchedStateForArrayRemoval(path, start, actualDeleteCount); } if (insertCount > 0) { this._syncTouchedStateForArrayInsertion(path, start, insertCount); } }); } _enhance = (options = {}) => (el) => { const handleSubmit = async (e) => { e.preventDefault(); await this.validateSchema(); if (this._errorCount === 0) { await options.onSubmit?.(this._data); } else { options.onError?.(this.errors); } }; el.addEventListener('submit', handleSubmit); return { destroy() { el.removeEventListener('submit', handleSubmit); } }; }; get enhance() { return this._enhance(); } // Factory for creating field objects to reduce memory allocation _createFieldObject(path, compiled) { // eslint-disable-next-line @typescript-eslint/no-this-alias const self = this; // Cache constraints to avoid repeated calls const constraints = this.validator.getInputAttributes?.(path) ?? {}; return { get value() { return compiled.get(self._data); }, set value(val) { compiled.set(self._data, val); if (!self._isInternalUpdate) { self.markTouched(path); // Trigger validation immediately for all changes to ensure reactivity self.validateSchema(); } }, get error() { return (self.errors[path]?.[0] ?? self.customErrors[path]?.[0] ?? undefined); }, set error(val) { self.customErrors[path] = [val]; }, get errors() { return [ ...(self.errors[path] ?? []), ...(self.customErrors[path] ?? []) ]; }, set errors(vals) { self.customErrors[path] = vals; }, get touched() { return self.touched[path] ?? false; }, set touched(val) { self.touched[path] = val; }, get constraints() { return constraints; }, get isValidating() { return self.isValidating; } }; } // Helper methods for syncing touched state during array operations _syncTouchedStateForArraySwap(arrayPath, i, j) { // Get all touched keys for the array const touchedKeys = Object.keys(this.touched).filter((key) => key.startsWith(`${arrayPath}.${i}.`) || key.startsWith(`${arrayPath}.${j}.`)); // Create a temporary map to store the touched states const tempTouched = new SvelteMap(); // Store current touched states for (const key of touchedKeys) { tempTouched.set(key, this.touched[key]); } // First, delete all existing keys to avoid conflicts for (const key of touchedKeys) { delete this.touched[key]; } // Then create the new swapped keys for (const key of touchedKeys) { if (key.startsWith(`${arrayPath}.${i}.`)) { const newKey = key.replace(`${arrayPath}.${i}.`, `${arrayPath}.${j}.`); this.touched[newKey] = tempTouched.get(key) ?? false; } else if (key.startsWith(`${arrayPath}.${j}.`)) { const newKey = key.replace(`${arrayPath}.${j}.`, `${arrayPath}.${i}.`); this.touched[newKey] = tempTouched.get(key) ?? false; } } } _syncTouchedStateForArrayRemoval(arrayPath, startIndex, deleteCount) { // Get all touched keys for the array const touchedKeys = this._getTouchedKeysForArray(arrayPath); // Remove touched state for deleted items for (let i = 0; i < deleteCount; i++) { const indexToRemove = startIndex + i; const prefix = `${arrayPath}.${indexToRemove}.`; const keysToRemove = touchedKeys.filter((key) => key.startsWith(prefix)); for (const key of keysToRemove) { delete this.touched[key]; } } // Shift remaining indices down this._shiftArrayIndices(touchedKeys, arrayPath, startIndex + deleteCount, -deleteCount); } _syncTouchedStateForArrayInsertion(arrayPath, startIndex, insertCount) { // Get all touched keys for the array const touchedKeys = this._getTouchedKeysForArray(arrayPath); // Shift existing indices up to make room for new items this._shiftArrayIndices(touchedKeys, arrayPath, startIndex, insertCount); } // Helper method to ensure array elements remain reactive after splice _ensureArrayElementsReactive(arrayPath, array) { // Check each array element and ensure it's reactive for (let i = 0; i < array.length; i++) { const element = array[i]; if (element && typeof element === 'object' && !Array.isArray(element)) { const elementPath = `${arrayPath}.${i}`; // Always create a reactive proxy for the element to ensure reactivity const reactiveElement = this.createReactiveData(element, elementPath); // Replace the element in the array array[i] = reactiveElement; } else if (Array.isArray(element)) { const elementPath = `${arrayPath}.${i}`; // Always create a reactive array for the nested array to ensure reactivity const reactiveArray = this.createReactiveArray(element, elementPath); // Replace the element in the array array[i] = reactiveArray; } } } // Helper method to get all touched keys for a specific array _getTouchedKeysForArray(arrayPath) { return Object.keys(this.touched).filter((key) => key.startsWith(`${arrayPath}.`)); } // Helper method to shift array indices in touched state _shiftArrayIndices(touchedKeys, arrayPath, startIndex, shiftAmount) { const indexPattern = new RegExp(`^${arrayPath}\\.(\\d+)\\.`); const fullPattern = new RegExp(`^${arrayPath}\\.(\\d+)\\.(.*)`); // Filter keys that need to be shifted const keysToShift = touchedKeys.filter((key) => { const match = key.match(indexPattern); if (!match) return false; const index = parseInt(match[1], 10); return index >= startIndex; }); // Sort by index in descending order to avoid conflicts (for insertion) if (shiftAmount > 0) { keysToShift.sort((a, b) => { const matchA = a.match(indexPattern); const matchB = b.match(indexPattern); if (!matchA || !matchB) return 0; return parseInt(matchB[1], 10) - parseInt(matchA[1], 10); }); } // Shift the keys for (const key of keysToShift) { const match = key.match(fullPattern); if (match) { const oldIndex = parseInt(match[1], 10); const restOfPath = match[2]; const newIndex = oldIndex + shiftAmount; const newKey = `${arrayPath}.${newIndex}.${restOfPath}`; this.touched[newKey] = this.touched[key]; delete this.touched[key]; } } } // Helper method to handle array method specific touched state syncing _handleArrayMethodTouchedState(methodName, args, target, parentPath) { switch (methodName) { case 'splice': { const [start, deleteCount = 0, ...insertItems] = args; const actualDeleteCount = deleteCount ?? target.length - start; const insertCount = insertItems.length; if (actualDeleteCount > 0) { this._syncTouchedStateForArrayRemoval(parentPath, start, actualDeleteCount); } if (insertCount > 0) { this._syncTouchedStateForArrayInsertion(parentPath, start, insertCount); } // Ensure remaining array elements are reactive after splice this._ensureArrayElementsReactive(parentPath, target); break; } case 'pop': { // Remove touched state for the last item const lastIndex = target.length - 1; if (lastIndex >= 0) { this._syncTouchedStateForArrayRemoval(parentPath, lastIndex, 1); } break; } case 'shift': { // Remove touched state for the first item and shift others down this._syncTouchedStateForArrayRemoval(parentPath, 0, 1); break; } case 'unshift': { // Shift existing items up to make room for new items const insertCount = args.length; this._syncTouchedStateForArrayInsertion(parentPath, 0, insertCount); break; } case 'push': // No need to sync touched state for push - new items aren't touched break; } } // Helper method to handle field changes consistently _handleFieldChange(path) { this.markTouched(path); // Trigger validation immediately for all changes to ensure reactivity this.validateSchema(); } // Helper method to clear stale field cache entries for array operations _clearStaleFieldCacheEntries(arrayPath) { const keysToRemove = []; // Find all field cache entries that start with the array path for (const key of this._fieldCache.keys()) { if (key.startsWith(arrayPath)) { keysToRemove.push(key); } } // Remove the stale entries for (const key of keysToRemove) { this._fieldCache.delete(key); } } /** * Cleanup method for Svelte's automatic resource disposal. * This method is called when the form instance goes out of scope. */ [Symbol.dispose]() { this.dispose(); } /** * Manual cleanup method to free resources and prevent memory leaks. * Call this when you're done with the form instance. */ dispose() { // Clear all caches to free memory this._pathCache.clear(); this._fieldCache.clear(); this._pendingArrayValidation.clear(); // Clear WeakMaps (they will be garbage collected automatically) this._proxyCache = new WeakMap(); this._methodCache = new WeakMap(); // Reset reactive state to prevent memory leaks this._data = {}; this.errors = {}; this.customErrors = {}; this.touched = {}; this.isValid = false; this.isValidating = false; this._errorCount = 0; this._isInternalUpdate = false; // Clear the valid paths set this._validPaths.clear(); } }