UNPKG

rune-form

Version:

Type-safe reactive form builder for Svelte 5

361 lines (360 loc) 13.7 kB
import { SvelteMap, SvelteSet } from 'svelte/reactivity'; import { createCustomValidator } from './customAdapter.js'; import { clearStaleFieldCacheEntries, clearStalePathCacheEntries, evictOldestFromMap, handleArrayMethodTouchedState, MUTATING_ARRAY_METHODS, parsePath, syncTouchedStateForArrayInsertion, syncTouchedStateForArrayRemoval, syncTouchedStateForArraySwap } from './helpers.js'; import { createZodValidator } from './zodAdapter.js'; export class RuneForm { validator; initialData; _data = $state({}); errors = $state({}); customErrors = $state({}); touched = $state({}); isValid = $state(false); isValidating = $state(false); _pathCache = new SvelteMap(); _fieldCache = new SvelteMap(); _validPaths; _isInternalUpdate = false; _validationTimeoutId; _proxyCache = new WeakMap(); _methodCache = new WeakMap(); _pendingArrayValidation = new SvelteSet(); static _MUTATING_ARRAY_METHODS = new SvelteSet(MUTATING_ARRAY_METHODS); static _MAX_CACHE_SIZE = { path: 1000, field: 500 }; constructor(validator, initialData = {}) { this.validator = validator; this.initialData = initialData; this._data = this.safePopulate(this.initialData); const paths = this.validator.getPaths?.() ?? []; paths.forEach((path) => this.compilePath(path)); this._validPaths = new SvelteSet(paths); $effect(() => { $effect.tracking(); // eslint-disable-next-line @typescript-eslint/no-unused-expressions this._data && this.touched; clearTimeout(this._validationTimeoutId); this._validationTimeoutId = setTimeout(() => this.validateSchema(), 100); }); this.data = this.createReactive(this._data); } // Unified reactive proxy creation for both objects and arrays createReactive = (value, parentPath = '') => { if (!value || typeof value !== 'object') { return value; } if (this._proxyCache.has(value)) { return this._proxyCache.get(value); } const pathPrefix = parentPath ? `${parentPath}.` : ''; const isArray = Array.isArray(value); const proxy = new Proxy(value, { get: (target, prop) => { const propValue = target[prop]; const currentPath = pathPrefix + String(prop); // Handle array element or nested object if (typeof prop === 'string' && (!isArray || /^\d+$/.test(prop))) { if (propValue && typeof propValue === 'object') { return this.createReactive(propValue, currentPath); } } // Handle array mutating methods if (isArray && typeof prop === 'string' && typeof propValue === 'function' && RuneForm._MUTATING_ARRAY_METHODS.has(prop)) { return this.wrapArrayMethod(target, prop, propValue, parentPath); } return propValue; }, set: (target, prop, newValue) => { if (typeof prop === 'symbol' || (isArray && prop === 'length')) return true; const oldValue = target[prop]; const currentPath = pathPrefix + String(prop); target[prop] = newValue; if (!this._isInternalUpdate && oldValue !== newValue) { this.markTouched(currentPath); this.validateSchema(); } return true; } }); this._proxyCache.set(value, proxy); return proxy; }; wrapArrayMethod(arr, methodName, method, parentPath) { let methodCache = this._methodCache.get(arr); if (!methodCache) { methodCache = new SvelteMap(); this._methodCache.set(arr, methodCache); } let wrapped = methodCache.get(methodName); if (!wrapped) { wrapped = (...args) => { const prevLength = arr.length; const result = method.apply(arr, args); if (!this._isInternalUpdate) { this.markTouched(parentPath); this.queueValidation(parentPath); const touchedResult = handleArrayMethodTouchedState(methodName, args, arr, prevLength); if (touchedResult.deleteCount > 0) { syncTouchedStateForArrayRemoval(this.touched, parentPath, touchedResult.start, touchedResult.deleteCount); } if (touchedResult.insertCount > 0) { syncTouchedStateForArrayInsertion(this.touched, parentPath, touchedResult.start, touchedResult.insertCount); } if (touchedResult.lastIndexBeforePop !== undefined && touchedResult.lastIndexBeforePop >= 0) { syncTouchedStateForArrayRemoval(this.touched, parentPath, touchedResult.lastIndexBeforePop, 1); } // Ensure array elements remain reactive after mutation for (let i = 0; i < arr.length; i++) { const element = arr[i]; if (element && typeof element === 'object') { arr[i] = this.createReactive(element, `${parentPath}.${i}`); } } clearStaleFieldCacheEntries(this._fieldCache, parentPath); } return result; }; methodCache.set(methodName, wrapped); } return wrapped; } queueValidation(path) { if (this._pendingArrayValidation.has(path)) return; this._pendingArrayValidation.add(path); queueMicrotask(() => { this._pendingArrayValidation.delete(path); if (!this._isInternalUpdate) this.validateSchema(); }); } get data() { return this._data; } set data(value) { this._isInternalUpdate = true; this._data = value; this._isInternalUpdate = false; } static fromSchema(schema, initialData) { return new RuneForm(createZodValidator(schema), initialData); } static fromCustom(validator, initialData) { return new RuneForm(createCustomValidator(validator), initialData); } getField(path) { const cached = this._fieldCache.get(path); if (cached) return cached; const normalizedPath = path .split('.') .map((seg) => (/^\d+$/.test(seg) ? '0' : seg)) .join('.'); const compiled = this.compilePath(path); // For non-existent paths, create field without constraints const field = !this._validPaths.has(normalizedPath) ? this.createField(path, compiled, false) : this.createField(path, compiled, true); if (this._fieldCache.size >= RuneForm._MAX_CACHE_SIZE.field) { evictOldestFromMap(this._fieldCache); } this._fieldCache.set(path, field); return field; } createField(path, compiled, getConstraints = true) { const pathStr = path; const constraints = getConstraints ? (this.validator.getInputAttributes?.(pathStr) ?? {}) : {}; // eslint-disable-next-line @typescript-eslint/no-this-alias const self = this; return { get value() { return compiled.get(self._data); }, set value(val) { compiled.set(self._data, val); if (!self._isInternalUpdate) { self.markTouched(path); self.validateSchema(); } }, get error() { return self.errors[pathStr]?.[0] ?? self.customErrors[pathStr]?.[0]; }, set error(val) { self.customErrors[pathStr] = [val]; }, get errors() { return [...(self.errors[pathStr] ?? []), ...(self.customErrors[pathStr] ?? [])]; }, set errors(vals) { self.customErrors[pathStr] = vals; }, get touched() { return self.touched[pathStr] ?? false; }, set touched(val) { self.touched[pathStr] = val; }, constraints, get isValidating() { return self.isValidating; } }; } compilePath(path) { let compiled = this._pathCache.get(path); if (compiled) return compiled; if (this._pathCache.size >= RuneForm._MAX_CACHE_SIZE.path) { evictOldestFromMap(this._pathCache); } const keys = parsePath(path); const isArrayIndex = keys.map((seg) => typeof seg === 'number'); compiled = { keys, isArrayIndex, get: (obj) => { let current = obj; for (const key of keys) { if (current == null || typeof current !== 'object') return undefined; current = current[key]; } return current; }, set: (obj, value) => { // Don't create paths for non-existent fields const normalizedPath = path .split('.') .map((seg) => (/^\d+$/.test(seg) ? '0' : seg)) .join('.'); if (!this._validPaths.has(normalizedPath)) return; let current = obj; for (let i = 0; i < keys.length - 1; i++) { const key = keys[i]; if (typeof current !== 'object' || current === null || !(key in current) || typeof current[key] !== 'object') { current[key] = isArrayIndex[i + 1] ? [] : {}; } current = current[key]; } if (typeof current === 'object' && current !== null) { current[keys[keys.length - 1]] = value; } } }; 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; } catch { this.isValid = false; } finally { this.isValidating = false; } } markTouched(path) { this.touched[path] = true; } markFieldAsPristine(path) { this.touched[path] = false; } markAllTouched() { Object.keys(this.errors).forEach((path) => (this.touched[path] = true)); } markAllAsPristine() { this.touched = {}; } executeArrayOp(path, op) { // Ensure the path is compiled const compiled = this.compilePath(path); const arr = compiled.get(this._data); if (Array.isArray(arr)) { op(arr); this.markTouched(path); clearStaleFieldCacheEntries(this._fieldCache, path); clearStalePathCacheEntries(this._pathCache, path); } } push(path, value) { this.executeArrayOp(path, (arr) => arr.push(value)); } swap(path, i, j) { this.executeArrayOp(path, (arr) => { [arr[i], arr[j]] = [arr[j], arr[i]]; syncTouchedStateForArraySwap(this.touched, path, i, j); }); } splice(path, start, deleteCount, ...items) { this.executeArrayOp(path, (arr) => { if (deleteCount !== undefined) { arr.splice(start, deleteCount, ...items); } else { arr.splice(start); } }); } setCustomError(path, message) { this.customErrors[path] = [message]; } setCustomErrors(path, messages) { this.customErrors[path] = messages; } clearAll() { clearTimeout(this._validationTimeoutId); this._validationTimeoutId = undefined; this.errors = {}; this.customErrors = {}; this.touched = {}; this.isValid = false; this.isValidating = false; this._proxyCache = new WeakMap(); this._methodCache = new WeakMap(); this._fieldCache.clear(); this._pendingArrayValidation.clear(); this._pathCache.clear(); } reset() { this._isInternalUpdate = true; this._data = this.safePopulate(this.initialData); this._isInternalUpdate = false; this.clearAll(); this.data = this.createReactive(this._data); } dispose() { this.clearAll(); this._data = {}; this._isInternalUpdate = false; this._validPaths.clear(); } [Symbol.dispose]() { this.dispose(); } }