a reactive (signals based) hypermedia web framework (wip) stormlightlabs.github.io/volt/
hypermedia frontend signals
at main 5.4 kB view raw
1/** 2 * Deep reactive proxy system for objects and arrays. 3 * 4 * Uses JavaScript Proxies to create reactive objects where property access and mutations 5 * automatically trigger updates. Internally uses signals for each property, created lazily 6 * on first access. 7 * 8 * Unlike signal() which wraps a single value, reactive() creates a transparent proxy where 9 * property access feels natural while maintaining reactivity. 10 */ 11 12import type { Signal } from "$types/volt"; 13import { signal } from "./signal"; 14import { recordDep } from "./tracker"; 15 16/** 17 * WeakMap to store the raw object for each reactive proxy. 18 * Used by toRaw() to unwrap proxies. 19 */ 20const reactiveToRaw = new WeakMap<object, object>(); 21 22/** 23 * WeakMap to store the reactive proxy for each raw object. 24 * Ensures we only create one proxy per object. 25 */ 26const rawToReactive = new WeakMap<object, object>(); 27 28/** 29 * WeakMap to store signals for each property of a reactive object. 30 * Key is the target object, value is a Map of property name to signal. 31 */ 32const targetToSignals = new WeakMap<object, Map<string | symbol, Signal<unknown>>>(); 33 34/** 35 * Array methods that mutate the array and should trigger updates. 36 */ 37const arrayMutators = new Set(["push", "pop", "shift", "unshift", "splice", "sort", "reverse", "fill", "copyWithin"]); 38 39/** 40 * Check if a value is an object (not null, not primitive). 41 */ 42function isObject(value: unknown): value is object { 43 return value !== null && typeof value === "object"; 44} 45 46/** 47 * Check if a value is already a reactive proxy. 48 */ 49export function isReactive(value: unknown): boolean { 50 return isObject(value) && reactiveToRaw.has(value); 51} 52 53/** 54 * Get the raw (non-reactive) object from a reactive proxy. 55 * If the value is not reactive, returns it as-is. 56 */ 57export function toRaw<T>(value: T): T { 58 if (!isObject(value)) { 59 return value; 60 } 61 62 return (reactiveToRaw.get(value) as T) || value; 63} 64 65/** 66 * Get or create a signal for a specific property on a reactive object. 67 */ 68function getPropertySignal(target: object, key: string | symbol): Signal<unknown> { 69 let signals = targetToSignals.get(target); 70 71 if (!signals) { 72 signals = new Map(); 73 targetToSignals.set(target, signals); 74 } 75 76 let sig = signals.get(key); 77 78 if (!sig) { 79 const initialValue = Reflect.get(target, key); 80 sig = signal(initialValue); 81 signals.set(key, sig); 82 } 83 84 return sig; 85} 86 87/** 88 * Create a reactive proxy for an object or array. 89 * 90 * Property access and mutations are tracked automatically. Nested objects and arrays 91 * are recursively wrapped in proxies for deep reactivity. 92 * 93 * @param target - The object or array to make reactive 94 * @returns A reactive proxy of the target 95 * 96 * @example 97 * const state = reactive({ count: 0, nested: { value: 1 } }); 98 * const doubled = computed(() => state.count * 2); 99 * state.count++; // triggers recomputation of doubled 100 * state.nested.value = 2; // also reactive 101 */ 102export function reactive<T extends object>(target: T): T { 103 if (!isObject(target)) { 104 console.warn("[reactive] Can only make objects and arrays reactive, got:", typeof target); 105 return target; 106 } 107 108 if (isReactive(target)) { 109 return target; 110 } 111 112 const existingProxy = rawToReactive.get(target); 113 if (existingProxy) { 114 return existingProxy as T; 115 } 116 117 const proxy = new Proxy(target, { 118 get(target, key, receiver) { 119 if (key === "__v_raw") { 120 return target; 121 } 122 123 if (key === "__v_isReactive") { 124 return true; 125 } 126 127 if (Array.isArray(target) && arrayMutators.has(key as string)) { 128 return function(this: unknown[], ...args: unknown[]) { 129 // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type 130 const method = Reflect.get(target, key) as Function; 131 const oldLength = target.length; 132 const result = method.apply(target, args); 133 134 const newLength = target.length; 135 const maxLength = Math.max(oldLength, newLength); 136 137 for (let i = 0; i < maxLength; i++) { 138 const sig = getPropertySignal(target, String(i)); 139 sig.set(Reflect.get(target, i)); 140 } 141 142 if (oldLength !== newLength) { 143 const lengthSig = getPropertySignal(target, "length"); 144 lengthSig.set(newLength); 145 } 146 147 return result; 148 }; 149 } 150 151 const sig = getPropertySignal(target, key); 152 recordDep(sig); 153 154 const value = Reflect.get(target, key, receiver); 155 156 if (isObject(value)) { 157 return reactive(value); 158 } 159 160 return sig.get(); 161 }, 162 163 set(target, key, value, receiver) { 164 const oldValue = Reflect.get(target, key, receiver); 165 166 const result = Reflect.set(target, key, value, receiver); 167 168 if (oldValue !== value) { 169 const sig = getPropertySignal(target, key); 170 sig.set(value); 171 } 172 173 return result; 174 }, 175 176 deleteProperty(target, key) { 177 const hadKey = Reflect.has(target, key); 178 const result = Reflect.deleteProperty(target, key); 179 180 if (hadKey && result) { 181 const sig = getPropertySignal(target, key); 182 sig.set(undefined); 183 } 184 185 return result; 186 }, 187 188 has(target, key) { 189 const sig = getPropertySignal(target, key); 190 recordDep(sig); 191 192 return Reflect.has(target, key); 193 }, 194 }); 195 196 reactiveToRaw.set(proxy, target); 197 rawToReactive.set(target, proxy); 198 199 return proxy as T; 200}