a reactive (signals based) hypermedia web framework (wip)
stormlightlabs.github.io/volt/
hypermedia
frontend
signals
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}