Skip to content

Commit b91d750

Browse files
fix: Plugin demo (#8)
* fix: ensure computed keys are emitted in kebab case in markup * added shorthand attribute forms * added CSS fallback for shift animations * fix: spread handling of signals * transformExpr automatically unwraps signals in obj * docs: updated internal docs to reflect proxy & iterator handling
1 parent e259f0d commit b91d750

File tree

22 files changed

+978
-274
lines changed

22 files changed

+978
-274
lines changed

docs/internals/proxies.md

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ When a consumer reads `proxy.key`:
3333
If it’s an object/function, we recursively call `reactive()` so nested access stays reactive.
3434
Otherwise we return `signal.get()` which unwraps the value.
3535

36-
This layered approach means `reactive()` objects are safe to embed in evaluator scopes—the same dangerous keys are filtered and every nested property remains reactive.
36+
This layered approach means `reactive()` objects are safe to embed in evaluator scopes. The same dangerous keys are filtered and every nested property remains reactive.
3737

3838
## Property Mutation (set trap)
3939

@@ -64,7 +64,7 @@ Methods that do not mutate (e.g. `slice`) pass through unwrapped.
6464

6565
## Integration with Signals
6666

67-
Every reactive property is backed by a `signal`. This keeps the proxy layer thin—core logic lives in `signal.ts`, and the proxy simply orchestrates reads/writes against those signals.
67+
Every reactive property is backed by a `signal`. This keeps the proxy layer thin. Core logic lives in `signal.ts`, and the proxy simply orchestrates reads/writes against those signals.
6868
Because signals already integrate with dependency tracking, reactive object reads automatically wire into computeds, effects, and DOM bindings without extra bookkeeping.
6969

7070
## Interop Utilities
@@ -82,6 +82,21 @@ Bindings and expressions run via the hardened evaluator. When it encounters a re
8282
- Signals returned from proxy properties expose `get`, `set`, and `subscribe`, but property reads on the signal proxy delegate back to the underlying value.
8383
- Primitive coercion works because the wrapper defines `valueOf`, `toString`, and `Symbol.toPrimitive` on demand.
8484
- Boolean negation (`!signal`) is rewritten to `!$unwrap(signal)` before compilation so reactive values behave like plain booleans.
85+
- **Iteration support** - Signal wrappers implement `Symbol.iterator` to enable spread operations on signals containing iterable values.
86+
87+
### Spread Operator Support
88+
89+
When a signal contains an iterable value (like an array), the wrapper proxy delegates the `Symbol.iterator` property to the unwrapped value.
90+
This enables the JavaScript spread operator to work transparently:
91+
92+
```javascript
93+
const todos = signal([{id: 1, text: "Learn"}, {id: 2, text: "Build"}]);
94+
const newTodos = [...todos, {id: 3, text: "Ship"}];
95+
```
96+
97+
Without this, the spread operator would fail because the JS runtime can't iterate over the signal wrapper.
98+
The implementation returns the iterator from the unwrapped array directly, ensuring spread operations receive raw values rather than wrapped proxies.
99+
This is critical for immutable update patterns where new arrays are constructed from existing signal values.
85100

86101
## Challenges & Lessons
87102

docs/internals/reactivity.md

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Reactivity Architecture
22

3-
VoltX’s reactivity system is built around a small set of primitivessignals, computed signals, and effectsthat coordinate via an explicit dependency tracker.
3+
VoltX’s reactivity system is built around a small set of primitives: signals, computed signals, and effects, that coordinate via an explicit dependency tracker.
44
This document explains how those pieces fit together, how updates flow through the system, and the trade-offs we made while hardening the implementation.
55

66
## Signals
@@ -88,6 +88,33 @@ The evaluator uses a scope proxy that wraps signal objects differently based on
8888

8989
This dual behavior is controlled by the `opts.unwrapSignals` parameter passed to `evaluate()`.
9090

91+
### Object Literal Unwrapping
92+
93+
A subtle challenge arises when event handlers create object literals using signal values. Consider this common pattern:
94+
95+
```html
96+
<button data-volt-on-click="todos.set([...todos, {id: todoId, text: newText, done: false}])">
97+
Add Todo
98+
</button>
99+
```
100+
101+
Without special handling, the object literal `{id: todoId, text: newText, done: false}` would capture **wrapped signal proxies** as property values instead of their unwrapped values. This breaks equality comparisons later when trying to match todos by ID.
102+
103+
To solve this, the `transformExpr` function applies a compile-time transformation: it automatically unwraps signal identifiers used directly as object property values. The expression above is rewritten to:
104+
105+
```javascript
106+
{id: $unwrap(todoId), text: $unwrap(newText), done: false}
107+
```
108+
109+
This transformation:
110+
111+
- Only applies to simple identifiers after `:` in object literals (e.g., `{key: identifier}`)
112+
- Does not affect method calls (e.g., `{text: newText.trim()}` remains unchanged)
113+
- Does not affect property access or computed values (e.g., `{id: obj.id}` remains unchanged)
114+
- Ensures object literals created in write-mode contexts contain primitive values, not wrapper proxies
115+
116+
This keeps the mental model simple: users write natural JavaScript object literals and the evaluator ensures signal values are materialized correctly, regardless of whether `unwrapSignals` is true or false.
117+
91118
## Scope Helpers
92119

93120
When a scope is mounted, VoltX injects several helpers that lean on the reactive core:
@@ -114,13 +141,11 @@ When batching is needed, use `$pulse` or wrap updates in a custom queue.
114141
## Challenges & Trade-offs
115142

116143
- **Minimal core vs features** - The system intentionally avoids hidden mutation queues or scheduler magic.
117-
This keeps mental models simple but means users must explicitly batch when necessary.
144+
This keeps mental models simple but means users must explicitly batch when necessary.
118145
- **Signal identity** - Equality checks are referential.
119146
While fast, it means that mutating nested objects without cloning can bypass change detection unless you touch the signal again.
120147
We emphasises immutable patterns or explicit `set()` calls with copies.
121148
- **Dependency discovery** - Parsing expressions to pre-collect dependencies (`extractDeps`) introduces heuristics (e.g. `$store.get()` handling).
122149
We balance accuracy with performance by focusing on common patterns and falling back to runtime evaluation if static analysis fails.
123150
- **Error resilience** - Subscriber callbacks, cleanup functions, and recompute bodies are wrapped in try/catch to prevent one failure from derailing the reactive loop.
124-
The trade-off is noisy console logs, but the alternative—silently swallowing issues—was harder to debug.
125-
126-
Despite the lightweight implementation, these primitives provide deterministic, traceable update flows that underpin VoltX’s declarative bindings and plugin ecosystem.
151+
The trade-off is noisy console logs, but the alternative (silent errors & no observability) was harder to debug.

docs/spec/plugin-spec.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,13 @@ Reads URL parameter on mount and sets signal value. Signal changes do not update
249249
<input data-volt-on-input="handleSearch" data-volt-url="sync:searchQuery" />
250250
```
251251

252+
You can also use the shorthand attribute form where the signal name is encoded in the attribute suffix:
253+
254+
```html
255+
<!-- Equivalent to data-volt-url="sync:searchQuery" -->
256+
<input data-volt-url:searchQuery="query" />
257+
```
258+
252259
Changes to signal update URL parameter, changes to URL update signal. Uses History API for clean URLs.
253260

254261
**Hash Routing:**
@@ -267,6 +274,8 @@ Keeps hash portion of URL in sync with signal. Useful for client-side routing.
267274
- Listens to `popstate` for browser back/forward
268275
- Debounces URL updates to avoid excessive history entries
269276
- Automatically serializes/deserializes values (strings, numbers, booleans)
277+
- Accepts `data-volt-url="mode:signal"` or `data-volt-url:signal="mode"` forms
278+
- Supports `query`, `hash`, and `history` mode aliases in shorthand attributes (e.g., `data-volt-url:filter="query"`)
270279

271280
## Implementation
272281

docs/usage/bindings.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,7 @@ The binding syntax is `data-volt-url:signalName="urlPart"` where URL part is:
364364

365365
- `query`: Sync with query parameter (e.g., `?page=1`)
366366
- `hash`: Sync with URL hash (e.g., `#section`)
367+
- `history`: Sync with the full pathname + search (e.g., `data-volt-url:route="history:/app"`)
367368

368369
Signal changes update the URL, and URL changes (back/forward navigation) update signals. This enables client-side routing without additional libraries.
369370

docs/usage/routing.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ This guide walks through building both hash-based and History API routers that s
3131
```
3232

3333
```ts
34-
// src/main.ts bundled projects
34+
// src/main.ts -> entry point for a bundled project
3535
import { charge, initNavigationListener, registerPlugin, urlPlugin } from "voltx.js";
3636

3737
registerPlugin("url", urlPlugin);

docs/usage/state.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ The name becomes a signal in the scope, and the attribute value is the computati
6464
```
6565

6666
Computed values defined this way follow the same rules as programmatic computed signals: they track dependencies and update automatically.
67+
For multi-word signal names, prefer kebab-case in the attribute (e.g., `data-volt-computed:active-todos`) — HTML lowercases attribute names and Volt converts kebab-case back to camelCase (`activeTodos`) automatically.
6768

6869
## Programmatic State
6970

@@ -105,6 +106,7 @@ VoltX automatically unwraps signals in read contexts, making expressions simpler
105106
```
106107

107108
**Read Contexts** (signals auto-unwrapped):
109+
108110
- `data-volt-text`, `data-volt-html`
109111
- `data-volt-if`, `data-volt-else`
110112
- `data-volt-for`
@@ -113,6 +115,7 @@ VoltX automatically unwraps signals in read contexts, making expressions simpler
113115
- `data-volt-computed:*` expressions
114116

115117
**Write Contexts** (signals not auto-unwrapped):
118+
116119
- `data-volt-on-*` event handlers
117120
- `data-volt-init` initialization code
118121
- `data-volt-model` (handles both read and write automatically)

lib/README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,9 @@ Plugins are opt-in and can be combined declaratively or registered programmatica
6666

6767
## VoltX.css
6868

69-
VoltX ships with an optional classless CSS framework inspired by Pico CSS and Tufte CSS. It provides beautiful, semantic styling without requiring any CSS classes—just write semantic HTML and it looks great. It's perfect for prototyping.
69+
VoltX ships with an optional classless CSS framework inspired by Pico CSS and Tufte CSS.
70+
It provides beautiful, semantic styling without requiring any CSS classes.
71+
Just write semantic HTML and it looks great. It's perfect for prototyping.
7072

7173
### Features
7274

@@ -94,7 +96,7 @@ Or include via CDN:
9496

9597
### Usage
9698

97-
No classes needed—just write semantic HTML:
99+
No classes needed. Just write semantic HTML:
98100

99101
```html
100102
<article>

lib/src/core/binder.ts

Lines changed: 67 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/**
2-
* Binder system for mounting and managing VoltX.js bindings
2+
* Binder system for mounting and managing VoltX bindings
33
*/
44

55
import { executeSurgeEnter, executeSurgeLeave, hasSurge } from "$plugins/surge";
@@ -46,8 +46,25 @@ export function registerDirective(name: string, handler: DirectiveHandler): void
4646
directiveRegistry.set(name, handler);
4747
}
4848

49+
function scheduleTransitionTask(cb: () => void): void {
50+
let executed = false;
51+
const wrapped = () => {
52+
if (executed) {
53+
return;
54+
}
55+
executed = true;
56+
cb();
57+
};
58+
59+
if (typeof requestAnimationFrame === "function") {
60+
requestAnimationFrame(wrapped);
61+
}
62+
63+
setTimeout(wrapped, 16);
64+
}
65+
4966
/**
50-
* Mount VoltX.js on a root element and its descendants and binds all data-volt-* attributes to the provided scope.
67+
* Mount VoltX on a root element and its descendants and binds all data-volt-* attributes to the provided scope.
5168
*
5269
* @param root - Root element to mount on
5370
* @param scope - Scope object containing signals and data
@@ -313,7 +330,7 @@ function bindShow(ctx: BindingContext, expr: string): void {
313330

314331
isTransitioning = true;
315332

316-
requestAnimationFrame(() => {
333+
scheduleTransitionTask(() => {
317334
void (async () => {
318335
try {
319336
if (shouldShow) {
@@ -874,6 +891,7 @@ function bindIf(ctx: BindingContext, expr: string): void {
874891
let currentCleanup: Optional<CleanupFunction>;
875892
let currentBranch: Optional<"if" | "else">;
876893
let isTransitioning = false;
894+
let pendingRender = false;
877895

878896
const render = () => {
879897
const condition = evaluate(expr, ctx.scope);
@@ -882,6 +900,9 @@ function bindIf(ctx: BindingContext, expr: string): void {
882900
const targetBranch = shouldShow ? "if" : (elseTempl ? "else" : undefined);
883901

884902
if (targetBranch === currentBranch || isTransitioning) {
903+
if (isTransitioning) {
904+
pendingRender = true;
905+
}
885906
return;
886907
}
887908

@@ -915,55 +936,57 @@ function bindIf(ctx: BindingContext, expr: string): void {
915936

916937
isTransitioning = true;
917938

918-
requestAnimationFrame(() => {
919-
void (async () => {
920-
try {
921-
if (currentElement) {
922-
const currentEl = currentElement as HTMLElement;
923-
const currentHasSurge = currentBranch === "if" ? ifHasSurge : elseHasSurge;
924-
925-
if (currentHasSurge) {
926-
await executeSurgeLeave(currentEl);
927-
}
928-
929-
if (currentCleanup) {
930-
currentCleanup();
931-
currentCleanup = undefined;
932-
}
933-
currentElement.remove();
934-
currentElement = undefined;
939+
void (async () => {
940+
try {
941+
if (currentElement) {
942+
const currentEl = currentElement as HTMLElement;
943+
const currentHasSurge = currentBranch === "if" ? ifHasSurge : elseHasSurge;
944+
945+
if (currentHasSurge) {
946+
await executeSurgeLeave(currentEl);
935947
}
936948

937-
if (targetBranch === "if") {
938-
currentElement = ifTempl.cloneNode(true) as Element;
939-
delete (currentElement as HTMLElement).dataset.voltIf;
940-
placeholder.before(currentElement);
949+
if (currentCleanup) {
950+
currentCleanup();
951+
currentCleanup = undefined;
952+
}
953+
currentElement.remove();
954+
currentElement = undefined;
955+
}
941956

942-
if (ifHasSurge) {
943-
await executeSurgeEnter(currentElement as HTMLElement);
944-
}
957+
if (targetBranch === "if") {
958+
currentElement = ifTempl.cloneNode(true) as Element;
959+
delete (currentElement as HTMLElement).dataset.voltIf;
960+
placeholder.before(currentElement);
945961

946-
currentCleanup = mount(currentElement, ctx.scope);
947-
currentBranch = "if";
948-
} else if (targetBranch === "else" && elseTempl) {
949-
currentElement = elseTempl.cloneNode(true) as Element;
950-
delete (currentElement as HTMLElement).dataset.voltElse;
951-
placeholder.before(currentElement);
962+
if (ifHasSurge) {
963+
await executeSurgeEnter(currentElement as HTMLElement);
964+
}
952965

953-
if (elseHasSurge) {
954-
await executeSurgeEnter(currentElement as HTMLElement);
955-
}
966+
currentCleanup = mount(currentElement, ctx.scope);
967+
currentBranch = "if";
968+
} else if (targetBranch === "else" && elseTempl) {
969+
currentElement = elseTempl.cloneNode(true) as Element;
970+
delete (currentElement as HTMLElement).dataset.voltElse;
971+
placeholder.before(currentElement);
956972

957-
currentCleanup = mount(currentElement, ctx.scope);
958-
currentBranch = "else";
959-
} else {
960-
currentBranch = undefined;
973+
if (elseHasSurge) {
974+
await executeSurgeEnter(currentElement as HTMLElement);
961975
}
962-
} finally {
963-
isTransitioning = false;
976+
977+
currentCleanup = mount(currentElement, ctx.scope);
978+
currentBranch = "else";
979+
} else {
980+
currentBranch = undefined;
964981
}
965-
})();
966-
});
982+
} finally {
983+
isTransitioning = false;
984+
if (pendingRender) {
985+
pendingRender = false;
986+
render();
987+
}
988+
}
989+
})();
967990
};
968991

969992
updateAndRegister(ctx, render, expr);

0 commit comments

Comments
 (0)