@daz4126/helium 1.0.0-rc.1 → 1.0.0-rc.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -18,24 +18,24 @@ It's really simple to use - just sprinkle the magic @attributes into your HTML a
18
18
 
19
19
  Helium is designed for developers who want:
20
20
 
21
- - **Lightweight** - Just over 3KB minified and gzipped
21
+ - **Lightweight** - About 5.5–13.3KB minified and gzipped, depending on the build
22
22
  - **Powerful** - Declarative JavaScript in your HTML
23
23
  - **Zero build step** - Works directly in the browser with no compiling
24
24
  - **Easy to learn** - If you know HTML and basic JavaScript, you're ready
25
25
 
26
26
  ## Versions
27
27
 
28
- Helium comes in three versions so you can pick the right balance of features and size for your project:
28
+ Helium comes in three core versions, with SSE available as an optional add-on, so you can pick the right balance of features and size for your project:
29
29
 
30
30
  ### Helium (Standard)
31
31
 
32
32
  The full-featured version with everything included.
33
33
 
34
- - All directives (`@text`, `@html`, `@bind`, `@data`, `@ref`, `@calculate`, `@effect`, `@init`, etc.)
34
+ - All directives (`@text`, `@html`, `@for`, `@if`, `@bind`, `@data`, `@ref`, `@calculate`, `@effect`, `@init`, etc.)
35
35
  - Event handlers with modifiers
36
36
  - HTTP requests (`@get`, `@post`, `@put`, `@patch`, `@delete`)
37
37
  - Module imports (`@import`)
38
- - Server-Sent Events (SSE)
38
+ - Optional Server-Sent Events (SSE) add-on
39
39
  - Turbo/Hotwire integration
40
40
 
41
41
  **Best for:** Most projects where you want the full power of Helium without worrying about CSP restrictions.
@@ -50,7 +50,8 @@ A slimmed-down version with just the core reactivity features.
50
50
 
51
51
  - Core directives (`@text`, `@html`, `@bind`, `@data`, `@ref`, `@calculate`, `@effect`, `@init`)
52
52
  - Event handlers with modifiers
53
- - No HTTP requests, imports, SSE, or Turbo integration
53
+ - One global state namespace
54
+ - No local `@data`, keyed `@for`, HTTP requests, imports, SSE, or Turbo integration
54
55
 
55
56
  **Best for:** Simple interactive pages where you only need reactivity and don't need server communication or module imports.
56
57
 
@@ -60,11 +61,38 @@ import helium from "@daz4126/helium/lite"
60
61
 
61
62
  ### Helium CSP
62
63
 
63
- A Content Security Policy safe version that doesn't require `unsafe-eval`. It uses [jexpr](https://github.com/nicolo-ribaudo/jexpr) as its expression engine instead of `new Function()`, while keeping the full feature set.
64
+ A Content Security Policy safe version that doesn't require `unsafe-eval`. It uses [jexpr](https://github.com/nicolo-ribaudo/jexpr) as its expression engine instead of `new Function()`.
64
65
 
65
- - All the same features as the standard version
66
+ - All the same directives and HTTP/Turbo/`@import` features as the standard version
67
+ - Optional Server-Sent Events (SSE) add-on
68
+ - `&&` / `||` / `??` short-circuit correctly, and optional chaining (`a?.b`) is supported
66
69
  - CSP-compliant — no `unsafe-eval` needed
67
70
 
71
+ Because expressions run through a custom parser rather than the JS engine, a couple of syntax features differ from the standard build:
72
+
73
+ - No `new` keyword — use the `$Date(...)` and `$FormData(...)` helpers instead
74
+ - Expressions are single expressions, not arbitrary statements
75
+
76
+ Expressions also run in a **restricted scope**: only side-effect-free builtins
77
+ (`Math`, `Date`, `JSON`, `Object`, `Array`, `parseInt`, `URL`, and friends) are
78
+ available. Capability-bearing globals — `window`, `document`, `fetch`,
79
+ `localStorage`, `sessionStorage`, `navigator`, `location`, `history`, timers,
80
+ and dialogs — are not reachable from expressions, so markup injected by an
81
+ attacker can't use them to read cookies or storage. If a page legitimately
82
+ needs one, opt in explicitly by passing the reference:
83
+
84
+ ```javascript
85
+ import helium, { allowGlobals } from "@daz4126/helium/csp";
86
+
87
+ allowGlobals({ localStorage, fetch }); // expose to expressions
88
+ allowGlobals({ fetch: undefined }); // revoke again
89
+ ```
90
+
91
+ (`helium.allowGlobals(...)` works too when using the global script build.)
92
+ Note this reduces the attack surface but is not a sandbox: the `$get`/`$post`
93
+ helpers and `$`/`$el`/`$html` magics remain available to any expression that
94
+ can run at all.
95
+
68
96
  **Best for:** Projects with strict Content Security Policies that prohibit `unsafe-eval`.
69
97
 
70
98
  ```javascript
@@ -76,12 +104,25 @@ import helium from "@daz4126/helium/csp"
76
104
  | Feature | Standard | Lite | CSP |
77
105
  |---------|----------|------|-----|
78
106
  | Core directives | ✅ | ✅ | ✅ |
107
+ | Element-local `@data` | ✅ | ❌ | ✅ |
108
+ | Keyed `@for` | ✅ | ❌ | ✅ |
109
+ | Conditional `@if` | ✅ | ❌ | ✅ |
79
110
  | Event handlers | ✅ | ✅ | ✅ |
80
111
  | HTTP requests | ✅ | ❌ | ✅ |
81
112
  | `@import` | ✅ | ❌ | ✅ |
82
- | SSE | ✅ | ❌ | ✅ |
113
+ | SSE | Add-on | ❌ | Add-on |
83
114
  | Turbo integration | ✅ | ❌ | ✅ |
84
115
  | CSP safe | ❌ | ❌ | ✅ |
116
+ | Approx. min+gzip size | 8.79KB | 5.52KB | 12.89KB |
117
+
118
+ Sizes are enforced against the production Terser builds with gzip level 9. The
119
+ CSP figure includes its expression parser. Adding SSE produces 9.17KB Standard
120
+ and 13.27KB CSP bundles.
121
+
122
+ Lite is generated from the same global-state reactive core as Standard. It has
123
+ identical deep/shared reactivity, lifecycle, binding, and cleanup behavior for
124
+ root state, but intentionally excludes element-local data and Standard's other
125
+ extensions.
85
126
 
86
127
  ## Installation
87
128
 
@@ -104,6 +145,11 @@ Just import from the CDN in a script tag directly in your HTML page:
104
145
  <script type="module">
105
146
  import helium from 'https://cdn.jsdelivr.net/gh/daz-codes/helium/helium-csp.js';
106
147
  </script>
148
+
149
+ <!-- Standard + SSE -->
150
+ <script type="module">
151
+ import helium from 'https://cdn.jsdelivr.net/gh/daz-codes/helium/helium-sse.js';
152
+ </script>
107
153
  ```
108
154
 
109
155
  ### NPM
@@ -115,14 +161,57 @@ npm install @daz4126/helium
115
161
  Then import the version you need:
116
162
 
117
163
  ```javascript
118
- import helium from "@daz4126/helium" // Standard
119
- import helium from "@daz4126/helium/lite" // Lite
120
- import helium from "@daz4126/helium/csp" // CSP
164
+ import helium from "@daz4126/helium" // Standard
165
+ import helium from "@daz4126/helium/lite" // Lite
166
+ import helium from "@daz4126/helium/csp" // CSP
167
+ import helium from "@daz4126/helium/sse" // Standard + SSE
168
+ import helium from "@daz4126/helium/csp/sse" // CSP + SSE
169
+ ```
170
+
171
+ For a pre-minified, self-contained bundle, append `/min` to an entry point:
172
+
173
+ ```javascript
174
+ import helium from "@daz4126/helium/min"
175
+ import helium from "@daz4126/helium/sse/min"
121
176
  ```
122
177
 
178
+ The source entry points remain browser-ready ES modules, so consuming Helium
179
+ does not require a build step.
180
+
123
181
  ### Automatic Initialization
124
182
 
125
- Helium automatically initializes on `DOMContentLoaded`, so you typically don't need to call `helium()` manually unless you're providing default values or functions.
183
+ Helium automatically initializes on `DOMContentLoaded`, so you typically don't need to call `helium()` manually. Calling it manually to provide initial values or functions suppresses the pending automatic initialization, so your state is not replaced when the DOM becomes ready.
184
+
185
+ ### Explicit Mounting
186
+
187
+ Use `helium.mount()` when a page has multiple independent Helium roots. It
188
+ accepts a selector or an element and returns the root's state plus an idempotent
189
+ `unmount()` function:
190
+
191
+ ```html
192
+ <section id="counter-a">
193
+ <button @click="count++">Increment</button>
194
+ <span @text="count"></span>
195
+ </section>
196
+
197
+ <section id="counter-b">
198
+ <button @click="count++">Increment</button>
199
+ <span @text="count"></span>
200
+ </section>
201
+ ```
202
+
203
+ ```javascript
204
+ const a = await helium.mount("#counter-a", { count: 0 })
205
+ const b = await helium.mount("#counter-b", { count: 10 })
206
+
207
+ a.state.count++ // Updates only #counter-a
208
+ a.unmount() // Safe to call more than once
209
+ ```
210
+
211
+ Explicit roots have independent state, bindings, observers, listeners, refs,
212
+ and cleanup. Calling `mount()` suppresses the pending automatic initialization;
213
+ existing single-root pages can continue relying on auto-initialization or
214
+ calling `helium(initialState)` directly.
126
215
 
127
216
  ## Helium Attributes
128
217
 
@@ -130,7 +219,10 @@ Helium uses custom attributes to add interactivity to HTML elements. To identify
130
219
 
131
220
  ### @helium
132
221
 
133
- This attribute sets the root element. Helium attributes can only be used on this element and its children. If not set then it defaults to `document.body`.
222
+ This attribute sets the automatically initialized root element. Helium
223
+ attributes are processed on that element and its children. If it is omitted,
224
+ automatic initialization defaults to `document.body`. Explicit
225
+ `helium.mount()` roots do not need this attribute.
134
226
 
135
227
  ```html
136
228
  <div @helium>
@@ -173,6 +265,69 @@ Similar to `@text`, but inserts HTML content into the element's innerHTML. Suppo
173
265
 
174
266
  **Alias:** `data-he-html`
175
267
 
268
+ ### @for with :key
269
+
270
+ Standard and CSP can render arrays and other iterables as keyed DOM rows. Put
271
+ `@for` and `:key` on a `<template>`; Lite deliberately excludes this feature.
272
+
273
+ ```html
274
+ <ul>
275
+ <template @for="item, index in items" :key="item.id">
276
+ <li>
277
+ <span @text="index + 1"></span>.
278
+ <span @text="item.name"></span>
279
+ <button @click="item.done = !item.done">Toggle</button>
280
+ </li>
281
+ </template>
282
+ </ul>
283
+ ```
284
+
285
+ The optional second alias is the current zero-based index:
286
+
287
+ ```html
288
+ <template @for="item in items" :key="item.id">
289
+ <!-- item is available to every Helium expression in this row -->
290
+ </template>
291
+ ```
292
+
293
+ Keys must be stable and unique. When the collection changes, Helium moves and
294
+ reuses rows with matching keys, creates new rows, and removes missing rows. This
295
+ preserves element identity, focus, listeners, and `@init` lifecycle state while
296
+ still updating `item`, `index`, and normal root-state dependencies. Cleanup
297
+ functions returned by `@init` run when their keyed row is removed.
298
+
299
+ `@html="items.map(...).join('')"` remains useful for simple generated markup,
300
+ but keyed `@for` avoids HTML-string interpolation and lets each row use normal
301
+ `@text`, dynamic attributes, events, and other directives.
302
+
303
+ **Alias:** `data-he-for` (the key remains `:key`)
304
+
305
+ ### @if
306
+
307
+ Standard and CSP can conditionally render a `<template>`'s content. Unlike
308
+ `@hidden`/`@visible`, which only toggle visibility, `@if` adds the content to
309
+ the DOM when the expression is truthy and fully removes it — bindings,
310
+ listeners, and `@init` cleanup included — when it becomes falsy:
311
+
312
+ ```html
313
+ <template @if="loggedIn">
314
+ <p>Welcome back, <span @text="user.name"></span>!</p>
315
+ <button @click="logout($data)">Log out</button>
316
+ </template>
317
+ ```
318
+
319
+ Content that is switched off costs nothing: its expressions never evaluate and
320
+ its listeners don't exist. Use `@if` for anything conditional-but-expensive
321
+ (modals, tab panels, admin-only markup) and `@hidden`/`@visible` for cheap
322
+ show/hide toggles where you want to preserve element state such as form input.
323
+
324
+ `@if` requires a `<template>` element (like `@for`, and like Alpine's `x-if`)
325
+ because template content is inert until inserted — that's what makes the "off"
326
+ state free. It cannot be combined with `@for` on the same template; nest one
327
+ template inside the other instead. Lite excludes this feature.
328
+
329
+ **Alias:** `data-he-if`
330
+
176
331
  ### @bind
177
332
 
178
333
  Creates a 2-way binding between an input element's value and a variable. Whatever is entered in the following input field will be stored as a variable called name:
@@ -187,6 +342,13 @@ Works with:
187
342
  - Radio buttons (binds to `value`, checking the one that matches)
188
343
  - Select elements (binds to `value`)
189
344
 
345
+ Nested paths work too — the parent object must already exist in state:
346
+
347
+ ```html
348
+ <input @bind="user.name">
349
+ <p @text="user.name"></p>
350
+ ```
351
+
190
352
  **Examples:**
191
353
  ```html
192
354
  <!-- Text input -->
@@ -209,7 +371,38 @@ Works with:
209
371
  </select>
210
372
  ```
211
373
 
212
- **Alias:** `data-he-bind`
374
+ **`.number` modifier:** append `.number` to coerce the bound value to a number
375
+ (handy for `type="number"` inputs, which otherwise bind as strings). An empty
376
+ field stays `""`, and non-numeric input is left untouched so partial typing
377
+ still works.
378
+
379
+ ```html
380
+ <input type="number" @bind.number="age">
381
+ <p @text="age + 1"></p> <!-- numeric addition, not string concatenation -->
382
+ ```
383
+
384
+ **`.trim` modifier:** trim leading and trailing whitespace before storing user
385
+ input in state:
386
+
387
+ ```html
388
+ <input @bind.trim="name">
389
+ ```
390
+
391
+ **`.lazy` modifier:** update state on the element's `change` event rather than
392
+ on every `input` event. For text fields this normally means when the edit is
393
+ committed or the field loses focus:
394
+
395
+ ```html
396
+ <input @bind.lazy="search">
397
+ ```
398
+
399
+ Modifiers can be combined in any order:
400
+
401
+ ```html
402
+ <input @bind.lazy.trim.number="amount">
403
+ ```
404
+
405
+ **Alias:** `data-he-bind` (for example `data-he-bind.lazy.trim`)
213
406
 
214
407
  ### @hidden & @visible
215
408
 
@@ -224,32 +417,76 @@ Makes the element hidden or visible depending on the result of a JavaScript expr
224
417
 
225
418
  ### @data
226
419
 
227
- Initializes variables that can be used in JavaScript expressions. This is useful for setting up initial state.
420
+ Initializes variables that can be used in JavaScript expressions. In Standard
421
+ and CSP, `@data` behaves like Alpine's `x-data`: root-level `@data` initializes
422
+ the mounted root state, while nested `@data` creates local state for that
423
+ element and its descendants. Lite always uses one global state namespace, so
424
+ every `@data` merges into root state.
228
425
 
229
426
  ```html
230
- <div @data="{ count: 0, open: false, name: 'Helium' }"></div>
427
+ <main @helium @data="{ count: 100, name: 'Helium' }">
428
+ <p @text="name + ': ' + count"></p>
429
+ </main>
231
430
  ```
232
431
 
233
- You can then use these variables in other Helium attributes:
432
+ Nested `@data` is local in Standard/CSP:
234
433
 
235
434
  ```html
236
- <div @data="{ count: 0 }">
237
- <button @click="count++">Increment</button>
238
- <p @text="count"></p>
435
+ <main @helium @data="{ count: 100 }">
436
+ <section @data="{ count: 0, open: false }">
437
+ <button @click="count++">Increment local count</button>
438
+ <button @click="open = !open">Toggle</button>
439
+ <p @text="count"></p>
440
+ <p @visible="open">Local content</p>
441
+ </section>
442
+
443
+ <p @text="count"></p> <!-- Still 100 -->
444
+ </main>
445
+ ```
446
+
447
+ The object is evaluated once in its parent context, so local defaults can use
448
+ outer values. Nested data objects inherit outer names, and assignment updates
449
+ the nearest data object that declares that name:
450
+
451
+ ```html
452
+ <div @data="{ count: startingCount, label: 'outer' }">
453
+ <div @data="{ label: 'inner' }">
454
+ <button @click="count++">Updates the outer local count</button>
455
+ <span @text="label"></span> <!-- inner -->
456
+ </div>
239
457
  </div>
240
458
  ```
241
459
 
242
- **Alias:** `data-he-data`
460
+ Declare every name that should remain local. Assigning an undeclared name writes
461
+ to root state. The same applies to `@bind` and `@calculate` targets: declare
462
+ their target names in local `@data` when they should be local. Inside the
463
+ subtree, `$data` is the scoped state view. Local values are not returned from
464
+ `helium()` and are not included in root `@local-storage` persistence.
465
+
466
+ **Alias:** `data-he-data` (the legacy bare `data-he` alias is also accepted)
243
467
 
244
468
  ### @ref
245
469
 
246
- Creates a reference to the element that can be used in JavaScript expressions. References are prefixed with `$` when accessed.
470
+ Creates a scoped reference to an element. The first matching element is
471
+ available through `$name`, and every matching element is available as the
472
+ always-array `$refs.name` in current DOM order:
247
473
 
248
474
  ```html
249
- <ul @ref="list"></ul>
475
+ <section @data="{ items: [] }">
476
+ <ul @ref="list"></ul>
477
+ <div @ref="row"></div>
478
+ <div @ref="row"></div>
479
+ <button @click="console.log($list, $refs.row)">Inspect refs</button>
480
+ </section>
250
481
  ```
251
482
 
252
- This element can then be accessed in other JavaScript expressions as `$list`:
483
+ Ref lookup starts in the nearest explicit `@data` scope and falls back through
484
+ parent scopes. A ref with the same name in a nested scope shadows the outer
485
+ name. Lite has one root scope, so all of its refs belong to the mounted root.
486
+ When refs are removed or reordered, singular and plural lookup follows the
487
+ current DOM.
488
+
489
+ This element can be accessed in other JavaScript expressions as `$list`:
253
490
 
254
491
  ```html
255
492
  <button @click="$list.appendChild($html('<li>New item</li>'))">Add Task</button>
@@ -266,6 +503,22 @@ A JavaScript expression that will run once when Helium initializes. Useful for s
266
503
  <div @init="console.log('Helium initialized!')"></div>
267
504
  ```
268
505
 
506
+ If the expression returns a function, Helium calls it when the element is removed or when Helium is torn down. This is useful for releasing timers, observers, subscriptions, and other resources:
507
+
508
+ ```html
509
+ <div @init="start_clock($el, $data)"></div>
510
+ ```
511
+
512
+ ```js
513
+ function start_clock(element, data) {
514
+ const timer = setInterval(() => {
515
+ element.textContent = new Date().toLocaleTimeString()
516
+ }, 1000)
517
+
518
+ return () => clearInterval(timer)
519
+ }
520
+ ```
521
+
269
522
  **Alias:** `data-he-init`
270
523
 
271
524
  ### @calculate
@@ -423,11 +676,22 @@ You can add modifiers by appending them with a dot (`.`) after the event name:
423
676
  - **once** - Only runs the event handler once, then removes the listener
424
677
  - **outside** - Only fires when the event happens outside the element
425
678
  - **document** - Attaches the listener to the document instead of the element
679
+ - **window** - Attaches the listener to `window` instead of the element
680
+ - **self** - Only fires when the element itself is the event target
681
+ - **capture** - Handles the event during the capture phase
682
+ - **passive** - Registers a native passive listener
426
683
  - **debounce** - Debounces the event handler (default 300ms)
427
684
  - **debounce:500** - Debounces with custom delay in milliseconds
685
+ - **throttle** - Runs immediately, then at most once per interval (default 300ms)
686
+ - **throttle:500** - Throttles with a custom interval in milliseconds
428
687
  - **shift, ctrl, alt, meta** - Only fires if the modifier key is pressed
429
688
  - **Key names** - For keyboard events, specify which key (e.g., `enter`, `esc`, `space`)
430
689
 
690
+ Throttle uses leading and trailing calls: the first matching event runs
691
+ immediately, and events received during the interval are coalesced into one
692
+ final call using the latest event. `.passive.prevent` is invalid because a
693
+ passive listener cannot cancel an event; Helium warns and does not register it.
694
+
431
695
  **Examples:**
432
696
 
433
697
  ```html
@@ -457,9 +721,15 @@ You can add modifiers by appending them with a dot (`.`) after the event name:
457
721
 
458
722
  <!-- Listen on document level -->
459
723
  <div @keydown.document.esc="closeModal()">Press ESC anywhere</div>
724
+
725
+ <!-- Window listener with throttled updates -->
726
+ <div @resize.window.throttle:100="measureLayout()"></div>
727
+
728
+ <!-- Ignore clicks that originated in descendants -->
729
+ <button @click.self="select()"><span>Select</span></button>
460
730
  ```
461
731
 
462
- **Alias:** Prepend the event name with `data-he-on`, for example `data-he-onclick="count++"`
732
+ **Alias:** Prepend the event name with `data-he-`, for example `data-he-click="count++"`
463
733
 
464
734
  ## HTTP Requests
465
735
 
@@ -643,6 +913,44 @@ Additional fetch options (as an object):
643
913
  - `data-he-loading`
644
914
  - `data-he-options`
645
915
 
916
+ ### Server-Sent Events (optional)
917
+
918
+ SSE parsing is kept out of the core build. Select the add-on entry point when a
919
+ page needs it:
920
+
921
+ ```javascript
922
+ import helium from "@daz4126/helium/sse"
923
+ // Strict CSP: import helium from "@daz4126/helium/csp/sse"
924
+ ```
925
+
926
+ Use the normal HTTP directives. An explicit `@target` is applied to every SSE
927
+ message:
928
+
929
+ ```html
930
+ <button @get="/api/events" @target="#events:append">Connect</button>
931
+ <div id="events"></div>
932
+ ```
933
+
934
+ When `@target` is omitted, an SSE `event:` field can name a CSS selector or a
935
+ state property:
936
+
937
+ ```text
938
+ event: #status
939
+ data: Connected
940
+ ```
941
+
942
+ Set `retryMode` and an optional initial `retry` delay through `@options` to
943
+ reconnect after the stream closes or fails. A server-supplied `retry:` field
944
+ updates the delay and `id:` is sent back as `Last-Event-ID` on reconnection.
945
+
946
+ ```html
947
+ <button @get="/api/events"
948
+ @target="#events:append"
949
+ @options="{ retryMode: 'error', retry: 3000 }">
950
+ Connect
951
+ </button>
952
+ ```
953
+
646
954
  ### Complete Example
647
955
 
648
956
  ```html
@@ -737,13 +1045,26 @@ You can also use a string:
737
1045
  These special variables are available in all JavaScript expressions:
738
1046
 
739
1047
  ### $
740
- Alias for `document.querySelector` - quickly select elements:
1048
+ The `$` query helper preserves `document.querySelector` as its default and adds
1049
+ array-returning plural and scoped forms:
741
1050
 
742
1051
  ```html
743
1052
  <div @click="$('#header').classList.add('active')">Activate Header!</div>
744
1053
  <button @click="$('.sidebar').style.display = 'none'">Hide Sidebar</button>
745
1054
  ```
746
1055
 
1056
+ ```js
1057
+ $(selector) // first match in document
1058
+ $.all(selector) // all matches in document
1059
+ $.root(selector) // first descendant match in $root
1060
+ $.root.all(selector) // all descendant matches in $root
1061
+ $.scope(selector) // first descendant match in $scope
1062
+ $.scope.all(selector) // all descendant matches in $scope
1063
+ ```
1064
+
1065
+ The plural forms return normal arrays. Like `querySelector`, the root and scope
1066
+ forms search descendants and do not include `$root` or `$scope` themselves.
1067
+
747
1068
  ### $el
748
1069
  Reference to the current element:
749
1070
 
@@ -753,6 +1074,17 @@ Reference to the current element:
753
1074
  <input @input="console.log($el.value)">
754
1075
  ```
755
1076
 
1077
+ ### $root
1078
+
1079
+ The actual root element owned by the current Helium instance. This is the
1080
+ element selected by `@helium`/`data-helium`, the element passed to
1081
+ `helium.mount()`, or `document.body` for the default fallback root.
1082
+
1083
+ ### $scope
1084
+
1085
+ The nearest element that declares local `@data` state, falling back to `$root`.
1086
+ Lite has no local data scopes, so `$scope` and `$root` are always the same.
1087
+
756
1088
  ### $event
757
1089
  The event object (available in event handlers):
758
1090
 
@@ -762,6 +1094,21 @@ The event object (available in event handlers):
762
1094
  <form @submit="$event.preventDefault(); handleSubmit()">
763
1095
  ```
764
1096
 
1097
+ ### $dispatch
1098
+
1099
+ Dispatch a bubbling, cancelable `CustomEvent` from the current element. The
1100
+ event is returned, so callers can inspect `defaultPrevented`:
1101
+
1102
+ ```html
1103
+ <section @cart:add="total += $event.detail.price">
1104
+ <button @click="$dispatch('cart:add', { price: 12 })">Add</button>
1105
+ </section>
1106
+ ```
1107
+
1108
+ The signature is `$dispatch(name, detail = {}, options = {})`. Options can set
1109
+ `target` (default `$el`), `bubbles` (default `true`), and `cancelable` (default
1110
+ `true`).
1111
+
765
1112
  ### $data
766
1113
  The reactive data object containing all Helium variables:
767
1114
 
@@ -795,12 +1142,19 @@ HTTP request functions that can be called programmatically:
795
1142
 
796
1143
  The arguments are `url`,`params` (not for `$get`) and `options`. `options` is an object that can include the properties `loading`,`target`, `template`
797
1144
 
798
- ### $refs
799
- Object containing all elements marked with `@ref` (prefixed with `$`):
1145
+ ### Named refs
1146
+
1147
+ The first element marked with `@ref="name"` in the nearest defining scope is
1148
+ available as `$name`. `$refs.name` is an array of every matching ref in that
1149
+ scope, in current DOM order:
800
1150
 
801
1151
  ```html
802
1152
  <input @ref="username">
803
1153
  <button @click="console.log($username.value)">Log Username</button>
1154
+
1155
+ <div @ref="item"></div>
1156
+ <div @ref="item"></div>
1157
+ <button @click="console.log($refs.item.length)">Log item count</button>
804
1158
  ```
805
1159
 
806
1160
  ## Functions
@@ -899,6 +1253,29 @@ This is the recommended approach when you need to update variables:
899
1253
 
900
1254
  ## Advanced Features
901
1255
 
1256
+ ### Batched Updates
1257
+
1258
+ State changes are batched: a burst of synchronous mutations triggers a single
1259
+ DOM update on the next microtask, and each affected binding runs once no matter
1260
+ how many mutations fed it. This keeps loops over state cheap — updating 100
1261
+ items in a keyed list re-renders the list once, not 100 times:
1262
+
1263
+ ```javascript
1264
+ // One DOM update, not 100
1265
+ for (let i = 0; i < 100; i++) state.items[i].done = true;
1266
+ ```
1267
+
1268
+ Two practical consequences:
1269
+
1270
+ - **Reading the DOM right after a mutation shows the old value.** Await a
1271
+ microtask first: `state.count = 5; await Promise.resolve();` (in tests, a
1272
+ `setTimeout(0)` tick also covers content inserted by `@if`/`@for`, which is
1273
+ processed asynchronously by the MutationObserver).
1274
+ - **Assigning a value that is already current is a no-op** — it doesn't
1275
+ re-trigger bindings, so effects that write unchanged state settle instead of
1276
+ looping. A binding cascade that *never* stops writing new values (e.g.
1277
+ `@effect:*="count++"`) is detected and reported after 100 passes.
1278
+
902
1279
  ### DOM Morphing with Idiomorph
903
1280
 
904
1281
  By default, when you update innerHTML with `@html`, Helium replaces the entire content. This can cause issues like losing focus, resetting scroll positions, or interrupting animations.
@@ -1004,7 +1381,11 @@ The token is automatically included in POST, PUT, PATCH, and DELETE requests to
1004
1381
 
1005
1382
  ### Content Security Policy
1006
1383
 
1007
- If you're using a Content Security Policy, note that Helium uses `new Function()` to evaluate expressions. You'll need to allow `unsafe-eval` or use a build step to pre-compile expressions (coming in a future version).
1384
+ The standard and lite builds use `new Function()` and therefore require `unsafe-eval`. For a strict Content Security Policy, use the CSP build instead:
1385
+
1386
+ ```js
1387
+ import helium from "@daz4126/helium/csp"
1388
+ ```
1008
1389
 
1009
1390
  ## Best Practices
1010
1391
 
@@ -1037,6 +1418,18 @@ If you're using a Content Security Policy, note that Helium uses `new Function()
1037
1418
  <div @effect:page="analytics.track('page_view', { page })"></div>
1038
1419
  ```
1039
1420
 
1421
+ **Benchmarking:**
1422
+
1423
+ The repo includes a head-to-head runtime benchmark against Alpine.js, React,
1424
+ and Datastar (keyed list create/update/swap/clear plus rapid counter updates,
1425
+ run in real Chromium; Datastar only runs the counter test since it renders
1426
+ lists server-side). Run it with:
1427
+
1428
+ ```bash
1429
+ npm run bench # defaults: 1000 rows, 7 runs
1430
+ node scripts/benchmark.mjs --rows 5000 --runs 5
1431
+ ```
1432
+
1040
1433
  ### Structuring Larger Apps
1041
1434
 
1042
1435
  **Organize state at the root:**
@@ -1093,22 +1486,19 @@ If you're using a Content Security Policy, note that Helium uses `new Function()
1093
1486
 
1094
1487
  ### Common Pitfalls
1095
1488
 
1096
- **❌ Don't mutate arrays/objects without triggering reactivity:**
1489
+ **Arrays and objects are reactive:**
1097
1490
 
1098
- ```javascript
1099
- helium({
1100
- addItem(items, item) {
1101
- items.push(item); // ❌ Won't trigger updates
1102
- }
1103
- })
1491
+ ```html
1492
+ <button @click="items.push(newItem)">Add item</button>
1493
+ <span @text="items.length"></span>
1104
1494
  ```
1105
1495
 
1106
- **✅ Pass $data and update through it:**
1496
+ Nested mutations made through `$data` are reactive too:
1107
1497
 
1108
1498
  ```javascript
1109
1499
  helium({
1110
1500
  addItem(data, item) {
1111
- data.items.push(item); // ✅ Triggers updates
1501
+ data.items.push(item); // Triggers updates
1112
1502
  }
1113
1503
  })
1114
1504
  ```
@@ -1141,58 +1531,15 @@ helium({
1141
1531
  <button @click="goodFunction($data)">Works!</button>
1142
1532
  ```
1143
1533
 
1144
- ## Security Considerations
1145
-
1146
- ### XSS Protection
1147
-
1148
- **Always sanitize user input when using @html:**
1149
-
1150
- ```html
1151
- <!-- ❌ Dangerous if userInput contains scripts -->
1152
- <div @html="userInput"></div>
1153
-
1154
- <!-- ✅ Sanitize first -->
1155
- <div @html="sanitize(userInput)"></div>
1156
- ```
1157
-
1158
- Consider using a sanitization library like [DOMPurify](https://github.com/cure53/DOMPurify):
1159
-
1160
- ```javascript
1161
- import DOMPurify from 'dompurify';
1162
-
1163
- helium({
1164
- sanitize(html) {
1165
- return DOMPurify.sanitize(html);
1166
- }
1167
- });
1168
- ```
1169
-
1170
- **Use @text for plain text:**
1171
-
1172
- ```html
1173
- <!-- ✅ Safe - automatically escapes HTML -->
1174
- <div @text="userInput"></div>
1175
- ```
1176
-
1177
- ### CSRF Protection
1178
-
1179
- Helium automatically includes CSRF tokens in same-origin requests. Add this meta tag to your HTML:
1180
-
1181
- ```html
1182
- <meta name="csrf-token" content="your-csrf-token">
1183
- ```
1184
-
1185
- All POST, PUT, PATCH, and DELETE requests will include the `X-CSRF-Token` header automatically.
1186
-
1187
- ### Content Security Policy
1188
-
1189
- If you're using a strict CSP, you may need to allow `'unsafe-eval'` since Helium uses `new Function()` to compile expressions, or use a hash/nonce for the script.
1190
-
1191
1534
  ## Error Handling
1192
1535
 
1193
1536
  ### JavaScript Expression Errors
1194
1537
 
1195
- If an expression throws an error, Helium catches it silently and continues. Check the browser console for error messages.
1538
+ If a binding expression throws at runtime, Helium logs the error and continues
1539
+ without updating that binding. Failed compilation keeps Helium's literal-string
1540
+ ergonomics: plain text such as `@text="Loading..."` stays quiet, while
1541
+ expression-looking failures such as `@text="user.name."` or
1542
+ `@click="count +"` emit a `console.warn` with the attribute and expression.
1196
1543
 
1197
1544
  ```html
1198
1545
  <!-- If items is undefined, this won't crash the page -->
@@ -1201,13 +1548,12 @@ If an expression throws an error, Helium catches it silently and continues. Chec
1201
1548
 
1202
1549
  ### HTTP Request Errors
1203
1550
 
1204
- Failed requests log errors to the console. Handle them in your expressions:
1551
+ HTTP directives log failed requests to the console. Programmatic helpers return
1552
+ promises, so you can handle failures explicitly:
1205
1553
 
1206
1554
  ```html
1207
- <button
1208
- @post="/api/save"
1209
- @params="{ data: formData }"
1210
- @target="#message">
1555
+ <button @click="$post('/api/save', { data: formData }, '#message')
1556
+ .catch(error => saveError = error.message)">
1211
1557
  Save
1212
1558
  </button>
1213
1559
 
@@ -1216,8 +1562,50 @@ Failed requests log errors to the console. Handle them in your expressions:
1216
1562
 
1217
1563
  ### Invalid Attribute Syntax
1218
1564
 
1219
- Helium gracefully handles invalid syntax. If an expression can't be compiled, it treats it as a literal value.
1220
-
1565
+ Helium keeps plain malformed value expressions as literal values. If a failed
1566
+ expression looks like broken JavaScript, Helium emits a warning with directive
1567
+ context. Event handlers, HTTP options, `@data`, `@for`, `@if`, `@calculate`,
1568
+ `@effect`, and `@init` are treated strictly and warn on compile failure.
1569
+
1570
+
1571
+ ## Roadmap & Known Limitations
1572
+
1573
+ Helium aims to stay tiny, so some conveniences found in larger frameworks are
1574
+ intentionally absent (or still on the roadmap). Current gaps:
1575
+
1576
+ ### Not yet supported
1577
+
1578
+ - **No `$watch`/`$nextTick`, and no cross-root shared store** — each mounted
1579
+ instance has its own isolated state namespace.
1580
+ - **No transition helper** — use CSS transitions/classes directly with
1581
+ `@hidden`, `@visible`, or `@if`.
1582
+
1583
+ ### Roadmap
1584
+
1585
+ Ordered by recommended implementation sequence:
1586
+
1587
+ 1. **Document function and `this` context limitations** alongside the existing
1588
+ magic-variable guidance, with correct patterns for passing `$data`, `$el`,
1589
+ and other context explicitly.
1590
+ 2. **Add opt-in required-CSRF validation for POST/PUT/PATCH** — fail before the
1591
+ request when an application declares that a token is mandatory, without
1592
+ breaking tokenless APIs and cross-origin requests.
1593
+ 3. **Add request cancellation and explicit success/error hooks** using
1594
+ `AbortController`; define the request lifecycle before expanding loading and
1595
+ error-state APIs.
1596
+ 4. **Document a form-validation pattern first**, using native constraint
1597
+ validation and Helium state; add helpers only if examples reveal recurring
1598
+ boilerplate.
1599
+ 5. **Create a complete error-state example** covering validation, HTTP failure,
1600
+ retry, accessible messaging, and clearing stale errors.
1601
+ 6. **Complete HTTP loading-state lifecycle behavior** — `@loading` already
1602
+ exists, so define consistent success, error, cancellation, and restoration
1603
+ behavior rather than adding a second loading mechanism.
1604
+ 7. **Add debounced two-way binding updates if needed** after reproducing a real
1605
+ feedback/caret loop; event-handler debouncing already covers ordinary input
1606
+ throttling.
1607
+ 8. **Add a minimal watch/middleware system** together with a clear size budget;
1608
+ avoid adopting Alpine-scale surface area without demonstrated use cases.
1221
1609
 
1222
1610
  ## Contributing
1223
1611
 
@@ -1227,6 +1615,18 @@ Helium is open source! Contributions, issues, and feature requests are welcome.
1227
1615
  - Report issues: Create an issue on GitHub
1228
1616
  - Suggest features: Open a discussion on GitHub
1229
1617
 
1618
+ Before submitting a change, run `npm run check`. It executes the full jsdom test
1619
+ suite, creates the production bundles, smoke-tests them, and fails if any gzip
1620
+ size budget is exceeded. `npm run test:browser` runs the Chromium smoke suite
1621
+ used by CI for CSP, Turbo, SSE, keyed focus preservation, late imports, Lite
1622
+ generation, and browser entry imports. `npm run check:ci` runs both paths.
1623
+ `npm run size` also reports raw, gzip, and Brotli sizes.
1624
+
1625
+ `helium.js` contains the shared runtime plus sections marked Standard-only.
1626
+ `npm run generate` derives `helium-lite.js`; do not edit the generated Lite file
1627
+ directly. Standard keeps keyed lists, HTTP, imports, and Turbo, while SSE remains
1628
+ an optional module.
1629
+
1230
1630
  ## License
1231
1631
 
1232
1632
  MIT License - feel free to use Helium in personal and commercial projects.