rune-form
Version:
Type-safe reactive form builder for Svelte 5
683 lines (682 loc) • 27.9 kB
JavaScript
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();
}
}