a reactive (signals based) hypermedia web framework (wip) stormlightlabs.github.io/volt/
hypermedia frontend signals
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: effects & actions

+555 -29
+23 -11
index.html
··· 48 48 <h1 data-x-text="message">Loading...</h1> 49 49 50 50 <div class="card"> 51 - <h2>Reactive Counter</h2> 52 - <p>Current count: <strong data-x-text="count">0</strong></p> 53 - <button onclick="increment()">Increment</button> 54 - <button onclick="updateMessage()">Update Message</button> 51 + <h2>Event Bindings & Computed Values</h2> 52 + <p> 53 + Count: <strong data-x-text="count">0</strong><br /> 54 + Doubled: <strong data-x-text="doubled">0</strong> 55 + </p> 56 + <button data-x-on-click="increment">Increment</button> 57 + <button data-x-on-click="decrement">Decrement</button> 58 + <button data-x-on-click="reset">Reset</button> 59 + <button data-x-on-click="updateMessage">Update Message</button> 60 + </div> 61 + 62 + <div class="card"> 63 + <h2>Form Input</h2> 64 + <input 65 + type="text" 66 + data-x-on-input="handleInput" 67 + placeholder="Type something..." 68 + style="padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px; width: 100%" 69 + /> 70 + <p>You typed: <strong data-x-text="inputValue">nothing yet</strong></p> 55 71 </div> 56 72 57 73 <div class="card"> 58 74 <h2>Class Bindings</h2> 59 - <p data-x-class="classes"> 60 - This text has dynamic classes applied. 61 - </p> 75 + <p data-x-class="classes">This text has dynamic classes applied.</p> 62 76 <p> 63 77 Active: <span data-x-text="isActive">false</span> 64 78 </p> 65 - <button onclick="toggleActive()">Toggle Active</button> 79 + <button data-x-on-click="toggleActive">Toggle Active</button> 66 80 </div> 67 81 68 82 <div class="card"> 69 83 <h2>HTML Binding</h2> 70 - <div data-x-html="'<em>This is rendered as HTML</em>'"> 71 - Fallback content 72 - </div> 84 + <div data-x-html="'<em>This is rendered as HTML</em>'">Fallback content</div> 73 85 </div> 74 86 </div> 75 87 <script type="module" src="/src/main.ts"></script>
+35
src/core/binder.ts
··· 64 64 * @param value - Attribute value (expression) 65 65 */ 66 66 function bindAttribute(context: BindingContext, name: string, value: string): void { 67 + if (name.startsWith("on-")) { 68 + const eventName = name.slice(3); 69 + bindEvent(context, eventName, value); 70 + return; 71 + } 72 + 67 73 switch (name) { 68 74 case "text": { 69 75 bindText(context, value); ··· 162 168 const unsubscribe = signal.subscribe(update); 163 169 context.cleanups.push(unsubscribe); 164 170 } 171 + } 172 + 173 + /** 174 + * Bind data-x-on-* to attach event listeners. 175 + * Provides $el and $event in the scope for the event handler. 176 + * 177 + * @param context - Binding context 178 + * @param eventName - Event name (e.g., "click", "input") 179 + * @param expression - Expression to evaluate when event fires 180 + */ 181 + function bindEvent(context: BindingContext, eventName: string, expression: string): void { 182 + const handler = (event: Event) => { 183 + const eventScope: Scope = { ...context.scope, $el: context.element, $event: event }; 184 + 185 + try { 186 + const result = evaluate(expression, eventScope); 187 + if (typeof result === "function") { 188 + result(event); 189 + } 190 + } catch (error) { 191 + console.error(`Error in event handler (${eventName}):`, error); 192 + } 193 + }; 194 + 195 + context.element.addEventListener(eventName, handler); 196 + 197 + context.cleanups.push(() => { 198 + context.element.removeEventListener(eventName, handler); 199 + }); 165 200 } 166 201 167 202 /**
+121 -3
src/core/signal.ts
··· 1 1 /** 2 2 * A reactive primitive that notifies subscribers when its value changes. 3 - * Updates are batched in microtasks to avoid redundant notifications. 4 3 */ 5 4 export interface Signal<T> { 6 5 /** ··· 10 9 11 10 /** 12 11 * Update the signal's value. 13 - * If the new value differs from the current value, subscribers will be notified 14 - * asynchronously in a batched microtask. 12 + * If the new value differs from the current value, subscribers will be notified. 15 13 */ 16 14 set(value: T): void; 17 15 18 16 /** 19 17 * Subscribe to changes in the signal's value. 20 18 * The callback is invoked with the new value whenever it changes. 19 + * Returns an unsubscribe function to remove the subscription. 20 + */ 21 + subscribe(callback: (value: T) => void): () => void; 22 + } 23 + 24 + /** 25 + * A computed signal that derives its value from other signals. 26 + */ 27 + export interface ComputedSignal<T> { 28 + /** 29 + * Get the current computed value. 30 + */ 31 + get(): T; 32 + 33 + /** 34 + * Subscribe to changes in the computed value. 21 35 * Returns an unsubscribe function to remove the subscription. 22 36 */ 23 37 subscribe(callback: (value: T) => void): () => void; ··· 72 86 }, 73 87 }; 74 88 } 89 + 90 + /** 91 + * Creates a computed signal that derives its value from other signals. 92 + * The computation function is re-run whenever any of its dependencies change. 93 + * 94 + * @param compute - Function that computes the derived value 95 + * @param dependencies - Array of signals this computation depends on 96 + * @returns A ComputedSignal with get and subscribe methods 97 + * 98 + * @example 99 + * const count = signal(5); 100 + * const doubled = computed(() => count.get() * 2, [count]); 101 + * doubled.get(); // 10 102 + * count.set(10); 103 + * doubled.get(); // 20 104 + */ 105 + export function computed<T>( 106 + compute: () => T, 107 + dependencies: Array<Signal<unknown> | ComputedSignal<unknown>>, 108 + ): ComputedSignal<T> { 109 + let value = compute(); 110 + const subscribers = new Set<(value: T) => void>(); 111 + 112 + const notify = () => { 113 + for (const callback of subscribers) { 114 + try { 115 + callback(value); 116 + } catch (error) { 117 + console.error("Error in computed subscriber:", error); 118 + } 119 + } 120 + }; 121 + 122 + const recompute = () => { 123 + const newValue = compute(); 124 + if (value !== newValue) { 125 + value = newValue; 126 + notify(); 127 + } 128 + }; 129 + 130 + for (const dependency of dependencies) { 131 + dependency.subscribe(recompute); 132 + } 133 + 134 + return { 135 + get() { 136 + return value; 137 + }, 138 + 139 + subscribe(callback: (value: T) => void) { 140 + subscribers.add(callback); 141 + 142 + return () => { 143 + subscribers.delete(callback); 144 + }; 145 + }, 146 + }; 147 + } 148 + 149 + /** 150 + * Creates a side effect that runs when dependencies change. 151 + * Effects run immediately on creation and whenever dependencies update. 152 + * 153 + * @param effectFunction - Function to run as a side effect 154 + * @param dependencies - Array of signals this effect depends on 155 + * @returns Cleanup function to stop the effect 156 + * 157 + * @example 158 + * const count = signal(0); 159 + * const cleanup = effect(() => { 160 + * console.log('Count changed:', count.get()); 161 + * }, [count]); 162 + */ 163 + export function effect( 164 + effectFunction: () => void | (() => void), 165 + dependencies: Array<Signal<unknown> | ComputedSignal<unknown>>, 166 + ): () => void { 167 + let cleanup: (() => void) | void; 168 + 169 + const runEffect = () => { 170 + if (cleanup) { 171 + cleanup(); 172 + } 173 + try { 174 + cleanup = effectFunction(); 175 + } catch (error) { 176 + console.error("Error in effect:", error); 177 + } 178 + }; 179 + 180 + runEffect(); 181 + 182 + const unsubscribers = dependencies.map((dependency) => dependency.subscribe(runEffect)); 183 + 184 + return () => { 185 + if (cleanup) { 186 + cleanup(); 187 + } 188 + for (const unsubscribe of unsubscribers) { 189 + unsubscribe(); 190 + } 191 + }; 192 + }
+1 -1
src/index.ts
··· 4 4 * @packageDocumentation 5 5 */ 6 6 7 - export { signal, type Signal } from "./core/signal"; 8 7 export { mount } from "./core/binder"; 8 + export { computed, type ComputedSignal, effect, type Signal, signal } from "./core/signal";
+29 -13
src/main.ts
··· 1 - import { mount, signal } from "./index"; 1 + import { computed, effect, mount, signal } from "./index"; 2 2 3 3 const count = signal(0); 4 4 const message = signal("Welcome to Volt.js!"); 5 5 const isActive = signal(true); 6 + const inputValue = signal(""); 7 + 8 + const doubled = computed(() => count.get() * 2, [count]); 9 + 10 + effect(() => { 11 + console.log("Count changed:", count.get()); 12 + }, [count]); 6 13 7 14 const scope = { 8 15 count, 16 + doubled, 9 17 message, 10 18 isActive, 19 + inputValue, 11 20 classes: signal({ active: true, highlight: false }), 21 + increment: () => { 22 + count.set(count.get() + 1); 23 + }, 24 + decrement: () => { 25 + count.set(count.get() - 1); 26 + }, 27 + reset: () => { 28 + count.set(0); 29 + }, 30 + toggleActive: () => { 31 + isActive.set(!isActive.get()); 32 + }, 33 + updateMessage: () => { 34 + message.set(`Count is now ${count.get()}`); 35 + }, 36 + handleInput: (event: Event) => { 37 + const target = event.target as HTMLInputElement; 38 + inputValue.set(target.value); 39 + }, 12 40 }; 13 41 14 42 const app = document.querySelector("#app"); 15 43 if (app) { 16 44 mount(app, scope); 17 45 } 18 - 19 - globalThis.increment = () => { 20 - count.set(count.get() + 1); 21 - }; 22 - 23 - globalThis.toggleActive = () => { 24 - isActive.set(!isActive.get()); 25 - }; 26 - 27 - globalThis.updateMessage = () => { 28 - message.set(`Count is now ${count.get()}`); 29 - };
+162
test/core/events.test.ts
··· 1 + import { describe, expect, it, vi } from "vitest"; 2 + import { mount } from "../../src/core/binder"; 3 + import { signal } from "../../src/core/signal"; 4 + 5 + describe("event bindings", () => { 6 + it("binds click events", () => { 7 + const button = document.createElement("button"); 8 + button.dataset.xOnClick = "handleClick"; 9 + 10 + const handleClick = vi.fn(); 11 + mount(button, { handleClick }); 12 + 13 + button.click(); 14 + 15 + expect(handleClick).toHaveBeenCalledTimes(1); 16 + }); 17 + 18 + it("provides $event to the handler", () => { 19 + const button = document.createElement("button"); 20 + button.dataset.xOnClick = "handleClick"; 21 + 22 + const handleClick = vi.fn(); 23 + mount(button, { handleClick }); 24 + 25 + button.click(); 26 + 27 + expect(handleClick).toHaveBeenCalledWith(expect.any(Event)); 28 + }); 29 + 30 + it("provides $el in the scope", () => { 31 + const button = document.createElement("button"); 32 + button.dataset.xOnClick = "clicked"; 33 + 34 + const clicked = signal(false); 35 + button.dataset.xOnClick = "setClicked"; 36 + 37 + const setClicked = vi.fn(() => { 38 + clicked.set(true); 39 + }); 40 + 41 + mount(button, { setClicked, clicked }); 42 + 43 + button.click(); 44 + 45 + expect(clicked.get()).toBe(true); 46 + expect(setClicked).toHaveBeenCalled(); 47 + }); 48 + 49 + it("handles input events", () => { 50 + const input = document.createElement("input"); 51 + input.dataset.xOnInput = "handleInput"; 52 + 53 + const handleInput = vi.fn(); 54 + mount(input, { handleInput }); 55 + 56 + input.value = "test"; 57 + input.dispatchEvent(new Event("input")); 58 + 59 + expect(handleInput).toHaveBeenCalledTimes(1); 60 + }); 61 + 62 + it("cleans up event listeners on unmount", () => { 63 + const button = document.createElement("button"); 64 + button.dataset.xOnClick = "handleClick"; 65 + 66 + const handleClick = vi.fn(); 67 + const cleanup = mount(button, { handleClick }); 68 + 69 + button.click(); 70 + expect(handleClick).toHaveBeenCalledTimes(1); 71 + 72 + cleanup(); 73 + 74 + button.click(); 75 + expect(handleClick).toHaveBeenCalledTimes(1); 76 + }); 77 + 78 + it("supports multiple event bindings on the same element", () => { 79 + const button = document.createElement("button"); 80 + button.dataset.xOnClick = "handleClick"; 81 + button.dataset.xOnMouseover = "handleMouseover"; 82 + 83 + const handleClick = vi.fn(); 84 + const handleMouseover = vi.fn(); 85 + 86 + mount(button, { handleClick, handleMouseover }); 87 + 88 + button.click(); 89 + expect(handleClick).toHaveBeenCalledTimes(1); 90 + expect(handleMouseover).not.toHaveBeenCalled(); 91 + 92 + button.dispatchEvent(new MouseEvent("mouseover")); 93 + expect(handleMouseover).toHaveBeenCalledTimes(1); 94 + }); 95 + 96 + it("can update signals in event handlers", () => { 97 + const button = document.createElement("button"); 98 + button.dataset.xOnClick = "increment"; 99 + 100 + const count = signal(0); 101 + const increment = () => { 102 + count.set(count.get() + 1); 103 + }; 104 + 105 + mount(button, { increment, count }); 106 + 107 + expect(count.get()).toBe(0); 108 + 109 + button.click(); 110 + expect(count.get()).toBe(1); 111 + 112 + button.click(); 113 + expect(count.get()).toBe(2); 114 + }); 115 + 116 + it("handles submit events", () => { 117 + const form = document.createElement("form"); 118 + form.dataset.xOnSubmit = "handleSubmit"; 119 + 120 + const handleSubmit = vi.fn((event) => { 121 + event.preventDefault(); 122 + }); 123 + 124 + mount(form, { handleSubmit }); 125 + 126 + const submitEvent = new Event("submit", { cancelable: true }); 127 + form.dispatchEvent(submitEvent); 128 + 129 + expect(handleSubmit).toHaveBeenCalledTimes(1); 130 + expect(submitEvent.defaultPrevented).toBe(true); 131 + }); 132 + 133 + it("handles change events on inputs", () => { 134 + const input = document.createElement("input"); 135 + input.dataset.xOnChange = "handleChange"; 136 + 137 + const handleChange = vi.fn(); 138 + mount(input, { handleChange }); 139 + 140 + input.dispatchEvent(new Event("change")); 141 + 142 + expect(handleChange).toHaveBeenCalledTimes(1); 143 + }); 144 + 145 + it("can access element properties via $el", () => { 146 + const input = document.createElement("input"); 147 + input.value = "initial"; 148 + input.dataset.xOnInput = "updateValue"; 149 + 150 + const value = signal(""); 151 + const updateValue = vi.fn(() => { 152 + value.set((input as HTMLInputElement).value); 153 + }); 154 + 155 + mount(input, { updateValue, value }); 156 + 157 + input.value = "changed"; 158 + input.dispatchEvent(new Event("input")); 159 + 160 + expect(value.get()).toBe("changed"); 161 + }); 162 + });
+184 -1
test/core/signal.test.ts
··· 1 1 import { describe, expect, it, vi } from "vitest"; 2 - import { signal } from "../../src/core/signal"; 2 + import { computed, effect, signal } from "../../src/core/signal"; 3 3 4 4 describe("signal", () => { 5 5 it("creates a signal with an initial value", () => { ··· 122 122 expect(subscriber).toHaveBeenCalledWith(5); 123 123 }); 124 124 }); 125 + 126 + describe("computed", () => { 127 + it("computes initial value", () => { 128 + const count = signal(5); 129 + const doubled = computed(() => count.get() * 2, [count]); 130 + 131 + expect(doubled.get()).toBe(10); 132 + }); 133 + 134 + it("recomputes when dependency changes", () => { 135 + const count = signal(5); 136 + const doubled = computed(() => count.get() * 2, [count]); 137 + 138 + expect(doubled.get()).toBe(10); 139 + 140 + count.set(10); 141 + expect(doubled.get()).toBe(20); 142 + 143 + count.set(0); 144 + expect(doubled.get()).toBe(0); 145 + }); 146 + 147 + it("notifies subscribers when value changes", () => { 148 + const count = signal(5); 149 + const doubled = computed(() => count.get() * 2, [count]); 150 + const subscriber = vi.fn(); 151 + 152 + doubled.subscribe(subscriber); 153 + 154 + count.set(10); 155 + expect(subscriber).toHaveBeenCalledWith(20); 156 + expect(subscriber).toHaveBeenCalledTimes(1); 157 + }); 158 + 159 + it("does not notify when computed value is the same", () => { 160 + const count = signal(5); 161 + const isPositive = computed(() => count.get() > 0, [count]); 162 + const subscriber = vi.fn(); 163 + 164 + isPositive.subscribe(subscriber); 165 + 166 + count.set(10); 167 + expect(subscriber).not.toHaveBeenCalled(); 168 + 169 + count.set(-1); 170 + expect(subscriber).toHaveBeenCalledWith(false); 171 + expect(subscriber).toHaveBeenCalledTimes(1); 172 + }); 173 + 174 + it("supports multiple dependencies", () => { 175 + const a = signal(2); 176 + const b = signal(3); 177 + const sum = computed(() => a.get() + b.get(), [a, b]); 178 + 179 + expect(sum.get()).toBe(5); 180 + 181 + a.set(5); 182 + expect(sum.get()).toBe(8); 183 + 184 + b.set(10); 185 + expect(sum.get()).toBe(15); 186 + }); 187 + 188 + it("can depend on other computed signals", () => { 189 + const count = signal(2); 190 + const doubled = computed(() => count.get() * 2, [count]); 191 + const quadrupled = computed(() => doubled.get() * 2, [doubled]); 192 + 193 + expect(quadrupled.get()).toBe(8); 194 + 195 + count.set(5); 196 + expect(doubled.get()).toBe(10); 197 + expect(quadrupled.get()).toBe(20); 198 + }); 199 + 200 + it("allows unsubscribing", () => { 201 + const count = signal(5); 202 + const doubled = computed(() => count.get() * 2, [count]); 203 + const subscriber = vi.fn(); 204 + 205 + const unsubscribe = doubled.subscribe(subscriber); 206 + unsubscribe(); 207 + 208 + count.set(10); 209 + expect(subscriber).not.toHaveBeenCalled(); 210 + }); 211 + }); 212 + 213 + describe("effect", () => { 214 + it("runs immediately on creation", () => { 215 + const count = signal(0); 216 + const effectFunction = vi.fn(); 217 + 218 + effect(effectFunction, [count]); 219 + 220 + expect(effectFunction).toHaveBeenCalledTimes(1); 221 + }); 222 + 223 + it("runs when dependency changes", () => { 224 + const count = signal(0); 225 + const effectFunction = vi.fn(); 226 + 227 + effect(effectFunction, [count]); 228 + 229 + count.set(1); 230 + count.set(2); 231 + 232 + expect(effectFunction).toHaveBeenCalledTimes(3); 233 + }); 234 + 235 + it("can be cleaned up", () => { 236 + const count = signal(0); 237 + const effectFunction = vi.fn(); 238 + 239 + const cleanup = effect(effectFunction, [count]); 240 + 241 + expect(effectFunction).toHaveBeenCalledTimes(1); 242 + 243 + cleanup(); 244 + 245 + count.set(1); 246 + expect(effectFunction).toHaveBeenCalledTimes(1); 247 + }); 248 + 249 + it("runs cleanup function from previous effect", () => { 250 + const count = signal(0); 251 + const innerCleanup = vi.fn(); 252 + const effectFunction = vi.fn(() => innerCleanup); 253 + 254 + effect(effectFunction, [count]); 255 + 256 + expect(innerCleanup).not.toHaveBeenCalled(); 257 + 258 + count.set(1); 259 + expect(innerCleanup).toHaveBeenCalledTimes(1); 260 + 261 + count.set(2); 262 + expect(innerCleanup).toHaveBeenCalledTimes(2); 263 + }); 264 + 265 + it("runs final cleanup when effect is disposed", () => { 266 + const count = signal(0); 267 + const innerCleanup = vi.fn(); 268 + const effectFunction = vi.fn(() => innerCleanup); 269 + 270 + const cleanup = effect(effectFunction, [count]); 271 + 272 + count.set(1); 273 + expect(innerCleanup).toHaveBeenCalledTimes(1); 274 + 275 + cleanup(); 276 + expect(innerCleanup).toHaveBeenCalledTimes(2); 277 + }); 278 + 279 + it("supports multiple dependencies", () => { 280 + const a = signal(1); 281 + const b = signal(2); 282 + const effectFunction = vi.fn(); 283 + 284 + effect(effectFunction, [a, b]); 285 + 286 + expect(effectFunction).toHaveBeenCalledTimes(1); 287 + 288 + a.set(5); 289 + expect(effectFunction).toHaveBeenCalledTimes(2); 290 + 291 + b.set(10); 292 + expect(effectFunction).toHaveBeenCalledTimes(3); 293 + }); 294 + 295 + it("can depend on computed signals", () => { 296 + const count = signal(2); 297 + const doubled = computed(() => count.get() * 2, [count]); 298 + const effectFunction = vi.fn(); 299 + 300 + effect(effectFunction, [doubled]); 301 + 302 + expect(effectFunction).toHaveBeenCalledTimes(1); 303 + 304 + count.set(5); 305 + expect(effectFunction).toHaveBeenCalledTimes(2); 306 + }); 307 + });