micra.js 2.1.0 → 2.2.1

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/CHANGELOG.md ADDED
@@ -0,0 +1,244 @@
1
+ # Changelog
2
+
3
+ All notable changes to Micra.js will be documented in this file. Format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), versioning follows
5
+ [SemVer](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [2.2.1] — 2026-05-28
8
+
9
+ ### Performance — batched first list render
10
+
11
+ - **First render of a keyed `data-each` list now inserts in a single DOM
12
+ operation.** `renderKeyed` previously appended each new row with an
13
+ individual `anchor.after(node)` call — N insertions for an N-row list. On the
14
+ initial render (no previous rows to diff against), all freshly-cloned rows are
15
+ now collected into one `DocumentFragment` and inserted with a single
16
+ `marker.after()`, skipping the LIS reorder pass entirely. The update, swap, and
17
+ reorder paths are unchanged.
18
+ - No public-API change. Bundle stays at **5.4 KB gzip**.
19
+
20
+ ## [2.2.0] — 2026-05-27
21
+
22
+ ### Performance — single-pass DOM scan
23
+
24
+ - **Mount cost cut roughly in half.** Internal `applyDirectives`,
25
+ `bindDataOn`, `bindAtEvents`, `bindModels`, `collectRefs`, and
26
+ `renderList` used to walk the DOM 10+ times per render via separate
27
+ `querySelectorAll` calls. They now consume a single pre-computed
28
+ `ScanIndex` built by one `TreeWalker` traversal. The walker
29
+ `FILTER_REJECT`s subtrees rooted at nested `[data-component]` — those
30
+ subtrees aren't even visited.
31
+ - Cross-library benchmark numbers on Firefox 150 / Mac (median of 7 runs):
32
+
33
+ | Scenario | Before | After | Vs Alpine.js | Vs petite-vue |
34
+ | ------------------------- | ------: | --------: | ------------: | ------------: |
35
+ | Mount 100 components | 10.8 ms | **5.6 ms**| × 4.9 faster | × 3.6 faster |
36
+ | Mount 1000 components |128.3 ms |**65.4 ms**| × 7.0 faster | × 2.4 faster |
37
+ | Update 5 of 1000 rows | — | **1 ms** | × 886 faster | × 1002 faster |
38
+ | 10,000 state writes | — | **1 ms** | × 980 faster | × 983 faster |
39
+ | First render 1000 keyed | — | **12 ms** | × 79 faster | × 82 faster |
40
+ | Swap first ↔ last of 1000 | — | **7 ms** | × 131 faster | × 143 faster |
41
+
42
+ Bundle stays at **5.0 KB gzip** — the rewrite removed code, not added it.
43
+
44
+ ### TypeScript — full inference from your component literal
45
+
46
+ - **Method-level type inference.** Both `S` (state shape) and `M`
47
+ (method set) are now inferred from the object literal passed to
48
+ `Micra.define` / `Micra.mount`. Inside method bodies and lifecycle
49
+ hooks **both** `this.state.X` and `this.someMethod()` are fully typed:
50
+
51
+ ```ts
52
+ Micra.define('counter', {
53
+ state: { count: 0 },
54
+ inc() {
55
+ this.state.count++ // ✓ number
56
+ this.dec() // ✓ inferred sibling method
57
+ // this.foo() // ❌ Property 'foo' does not exist
58
+ },
59
+ dec() { this.state.count-- },
60
+ })
61
+ ```
62
+
63
+ - Public `ComponentInstance<S, M>` and `ComponentDefinition<S, M>` now
64
+ take a second generic parameter for methods. `mount()` returns a fully
65
+ typed instance — `inst.inc()` and `inst.state.count` are both checked
66
+ at the call site.
67
+ - New `ComponentMethods` and `ComponentBuiltins` types exported for
68
+ advanced typing.
69
+
70
+ ### Breaking — internal only
71
+
72
+ - The internal directive scan format changed (`DirectiveCache` →
73
+ `ScanIndex`). Internal-only — no public-API change. If you reached
74
+ into internals via deep imports, switch to consuming
75
+ `el.__micraScan` instead of `el.__micraCache`.
76
+
77
+ ## [2.1.0] — 2026-05-25
78
+
79
+ ### Added
80
+
81
+ - **`this.fetch(url, { signal })` now forwards `AbortSignal` to the native
82
+ `fetch()`.** Previously the `signal` option was treated as any other
83
+ GET-option and serialized into the URL as `&signal=[object AbortSignal]`,
84
+ while never reaching the underlying request — so abort silently did
85
+ nothing. After this release:
86
+ - `signal` passes through verbatim to native `fetch()`.
87
+ - `signal` is excluded from the GET-querystring serialization loop.
88
+ - `AbortController#abort()` rejects the in-flight request with an
89
+ `AbortError`, matching native semantics.
90
+ - Enables the canonical search-debounce pattern (drop a stale request when
91
+ a fresher query arrives) without dropping to native `fetch` manually.
92
+ - Migration: none — purely additive, the previous URL-serialization
93
+ behaviour was a bug.
94
+
95
+ ### Tests
96
+
97
+ - 76 new tests for the components and recipes shipped on the docs site
98
+ (14 components + 6 recipes). Total suite: 235 tests across 13 files.
99
+
100
+ ## [2.0.0] — 2026-05-24
101
+
102
+ ### Breaking
103
+
104
+ - **`data-if` now truly unmounts the element from the DOM.** Previously
105
+ `data-if` and `data-show` were aliases — both toggled `style.display`.
106
+ Now `data-if` detaches the element (replacing it with a Comment placeholder)
107
+ when falsy and re-inserts it when truthy. `data-show` keeps the old
108
+ `style.display` behaviour and is the way to express cheap visibility
109
+ toggling.
110
+ - Side effect: `this.refs.X` is `undefined` while the element is detached.
111
+ - DOM listeners on the detached node survive — re-insert preserves identity.
112
+ - `<template data-each>` inside a `data-if=false` subtree is suspended and
113
+ re-renders cleanly when the ancestor returns.
114
+ - **Migration:** if you relied on `data-if` keeping the element in the DOM
115
+ (e.g. you were reading `this.refs.X` while hidden, or animating
116
+ `display` transitions), replace those `data-if` attributes with
117
+ `data-show`.
118
+
119
+ ### Fixed
120
+
121
+ - **`@event` shorthand no longer crosses nested `data-component` boundaries.**
122
+ `bindAtEvents` previously walked all descendants via `queryAll('*')`,
123
+ attaching parent-component handlers to elements owned by a nested child
124
+ component. It now uses `queryOwnAll` like `data-on`/`data-model` already do.
125
+ - **`this.fetch(url, { method: 'POST' })` without a `body` no longer sends
126
+ the options object as the body.** Previously `body` was set to
127
+ `JSON.stringify(options)` (which serialized `{"method":"POST"}` to the
128
+ server). Now the body is omitted unless `options.body` is provided.
129
+
130
+ ### Added
131
+
132
+ - **`queryOwnAll(root, selector)`** in `src/dom/query.ts` — selector variant
133
+ of `queryOwn` for cases where there is no attribute to query by (e.g.
134
+ scanning `*` for `@`-prefixed attribute names).
135
+ - **Recipe: `docs/recipes/sse.md`** — server-sent events pattern using
136
+ `onCreate` + native `EventSource` + `onDestroy` cleanup. No new library
137
+ surface; just the canonical pattern for live data on top of Micra.
138
+
139
+ ### Docs
140
+
141
+ - `docs/directives.md` — full split between `data-if` (unmount) and
142
+ `data-show` (display).
143
+ - `docs/llm-guide.md`, `PROMPT.md`, `llms.txt`, `llms-full.txt` — updated
144
+ the directive table and added a "when to pick which" rule for AI agents.
145
+
146
+ ---
147
+
148
+ ## [1.1.0] — 2026-05-24
149
+
150
+ ### Security
151
+
152
+ - **Directive expressions now shadow non-whitelisted globals.** Identifiers in
153
+ `data-text`, `data-if`, `data-bind`, etc. resolve to state keys, instance
154
+ methods, or one of the whitelisted globals: `Math`, `JSON`, `Date`, `String`,
155
+ `Number`, `Boolean`, `Array`, `Object`, `parseInt`, `parseFloat`, `isNaN`,
156
+ `isFinite`, `NaN`, `Infinity`, `undefined`. Everything else (`window`,
157
+ `document`, `fetch`, `eval`, `setTimeout`, `constructor`, `__proto__`, ...)
158
+ resolves to `undefined`. This blocks the common
159
+ `constructor.constructor("...")()` chain and accidental access to browser
160
+ globals from directive markup. See `docs/directives.md → Security model` for
161
+ the full contract.
162
+ - **`data-html` is now explicitly documented as XSS-prone.** Inline JSDoc
163
+ warning + Security model section in the docs. Sanitize untrusted input on
164
+ the server before binding.
165
+
166
+ ### Fixed
167
+
168
+ - **`destroy()` actually unmounts.** Every DOM listener attached by
169
+ `data-on` / `@event` / `data-model` is now tracked on the instance and
170
+ removed in `destroy()`. Scheduled re-renders after destroy are no-ops.
171
+ Per-element bookkeeping flags are cleared so re-mounting the same DOM
172
+ rebinds cleanly.
173
+ - **Instance methods called from directive expressions now have `this`
174
+ bound to the component.** `data-text="doneCount() + ' done'"` where
175
+ `doneCount` reads `this.state.items` now works as written (previously
176
+ silently returned `undefined` due to `with()` semantics).
177
+ - **`data-model` on focused inputs syncs programmatic state changes.**
178
+ `this.state.q = ''` while the input has focus now clears the field.
179
+ Live typing remains a no-op (state already matches value after the input
180
+ event, so no write happens).
181
+ - **`data-model` on `<input type="number">` / `<input type="range">`
182
+ writes a `number`, not a string.** Empty inputs write `null`. Checkbox
183
+ inputs continue to write booleans.
184
+ - **Duplicate `data-each` keys produce a warning.** Previously rows
185
+ silently collided.
186
+ - **`null` / `undefined` `data-each` keys warn once per render**
187
+ instead of once per item.
188
+ - **`@event` shorthand re-scans the subtree on every render.**
189
+ Replaces the root-level `__micraAtScanned` flag with per-element
190
+ `__micraAtBound`, so `@click` attributes inside markup injected via
191
+ `data-html` get bound on the next render.
192
+ - **`data-bind="class:..."` + `data-class` on the same element now warns**
193
+ via `validateDirectives` — the two directives fight on every render.
194
+ - **`bus.off()` cleans up empty event Sets** instead of leaving them in
195
+ the map.
196
+
197
+ ### Added
198
+
199
+ - **Dev warnings (deduped):**
200
+ - re-entrant `render()` call (typically: a directive expression that
201
+ mutated state) — warns once per instance.
202
+ - runtime errors in directive expressions — warns once per unique
203
+ expression string.
204
+
205
+ ### Changed
206
+
207
+ - **Internal:** `data-bind` and `data-class` specs are pre-parsed once
208
+ into `CachedPairBinding.pairs` in `DirectiveCache` — re-renders skip
209
+ the comma+colon split.
210
+ - **Internal:** `Object.prototype` key membership is pre-computed once
211
+ at module load and cached in a `Set` (faster `safeStateHas`).
212
+ - **Internal:** `data-each` no-key warning is deduped per template
213
+ via `__micraNoKeyWarned`.
214
+
215
+ ### Docs
216
+
217
+ - `docs/directives.md` — new "Security model" section.
218
+ - `docs/llm-guide.md` — Security model, `Micra.off` reference,
219
+ "Things Micra does NOT support" (key modifiers, nested `data-model`,
220
+ `data-if` keeps element in DOM).
221
+ - `docs/examples.md` — inline-edit example now has `data-ref="input"`
222
+ and uses `e.key === 'Enter'` instead of unsupported `@keydown.enter`.
223
+
224
+ ### Bundle size
225
+
226
+ - ~3.7 KB → 4.8 KB gzip. Cost of the security hardening + listener
227
+ cleanup tracking.
228
+
229
+ ### Migration notes
230
+
231
+ - If any directive expression relied on `constructor`, `window`, `fetch`,
232
+ or other non-whitelisted globals, it now resolves to `undefined`. Move
233
+ the access into a component method.
234
+ - If you held onto an instance after calling `destroy()`, you can now
235
+ safely re-mount the same DOM with a new definition.
236
+
237
+ ---
238
+
239
+ ## [1.0.0] — initial release
240
+
241
+ Reactive shallow `Proxy` state, batched microtask rendering, DOM directives
242
+ (`data-text`, `data-html`, `data-if`, `data-show`, `data-bind`, `data-model`,
243
+ `data-class`, `data-on`, `@event`), keyed `data-each` list rendering, event
244
+ bus, SSR `prop()`, `fetch()` helper, idempotent `start()`.
package/README.md CHANGED
@@ -2,21 +2,31 @@
2
2
 
3
3
  Micra.js is a lightweight reactive TypeScript framework for small sites and SaaS apps. It gives you reactive state, DOM directives, keyed list rendering, an event bus, SSR-friendly props, and auto-mounting in about 5 KB gzip.
4
4
 
5
- ## What is Micra.js?
5
+ ## When to use Micra.js
6
6
 
7
- Micra.js is designed for server-rendered apps and small frontends that need a little reactivity without a large framework.
7
+ Built for **server-rendered apps** (Rails, Laravel, Django, Phoenix, ASP.NET) and small SaaS frontends that need a sprinkle of reactivity without a build step.
8
8
 
9
- Use it when you want:
9
+ Reach for Micra.js instead of React/Vue when:
10
10
 
11
- - JS expressions in directives like `data-if="count > 0"`
12
- - keyed list diffing with `data-each` + `data-key`
13
- - auto-mounting with `data-component`, `Micra.define()`, and `Micra.start()`
11
+ - ~5 KB gzip matters (full bundle, not "core")
12
+ - you want to drop a `<script>` tag on an existing page and go — no toolchain
13
+ - you have HTML rendered by your server template engine that needs reactive directives
14
+ - you don't need client-side routing or a full SPA
15
+ - you want **htmx + reactive client state** in the same page
16
+
17
+ Compared to Alpine.js: smaller surface, no `x-*` shorthand soup, AST-validated expressions (no global `window` / `fetch` access from markup), cleaner LLM ergonomics — fewer anti-patterns to fall into.
18
+
19
+ What you get:
20
+
21
+ - reactive `state` via a shallow `Proxy` (top-level writes only)
22
+ - JS expressions in directives: `data-if="count > 0"`
23
+ - keyed list diffing: `data-each` + `data-key`
24
+ - auto-mounting via `data-component` + `Micra.start()`
14
25
  - SSR props from `data-*` attributes via `this.prop()`
15
- - a built-in `this.fetch()` helper
16
- - a small global event bus with `Micra.on()` and `Micra.emit()`
17
- - DOM refs via `data-ref`
18
- - additive class toggling with `data-class`
19
- - simple lifecycle hooks: `onCreate`, `onDestroy`
26
+ - built-in `this.fetch()` helper with `AbortSignal` support
27
+ - global event bus: `Micra.on()` / `Micra.emit()`
28
+ - DOM refs via `data-ref`, additive class toggling via `data-class`
29
+ - lifecycle hooks: `onCreate`, `onDestroy`
20
30
 
21
31
  ## Quick Start
22
32
 
@@ -102,8 +112,8 @@ Micra.start();
102
112
  | ------------ | ---------------------------------------- | ------------------------- |
103
113
  | `data-text` | `data-text="name"` | Set `textContent` |
104
114
  | `data-html` | `data-html="content"` | Set `innerHTML` |
105
- | `data-if` | `data-if="count > 0"` | Toggle display |
106
- | `data-show` | `data-show="loaded"` | Alias of `data-if` |
115
+ | `data-if` | `data-if="count > 0"` | Mount / unmount from DOM |
116
+ | `data-show` | `data-show="loaded"` | Toggle `style.display` |
107
117
  | `data-bind` | `data-bind="href:url, disabled:loading"` | Bind attributes |
108
118
  | `data-model` | `data-model="search"` | Two-way input binding |
109
119
  | `data-each` | `data-each="items" data-key="id"` | List rendering |
@@ -153,13 +163,14 @@ this.refs
153
163
  this.render()
154
164
  this.destroy()
155
165
  this.prop(name, default?)
156
- this.fetch(url, options?)
166
+ this.fetch(url, options?) // supports AbortSignal in options.signal
157
167
  this.emit(event, payload?)
158
168
  this.on(event, handler)
159
169
  ```
160
170
 
161
171
  ## Documentation
162
172
 
173
+ - **AI / LLM code generation:** [`llms.txt`](./llms.txt) (overview) · [`llms-full.txt`](./llms-full.txt) (10 inline recipes + anti-pattern reference) · [`docs/llm-guide.md`](./docs/llm-guide.md) (full guide)
163
174
  - [Getting started](./docs/getting-started.md)
164
175
  - [Core concepts](./docs/concepts.md)
165
176
  - [Directives](./docs/directives.md)
@@ -171,3 +182,17 @@ this.on(event, handler)
171
182
  - Recipes:
172
183
  - [Todo app](./docs/recipes/todo-app.md)
173
184
  - [Server-sent events (SSE)](./docs/recipes/sse.md)
185
+
186
+ ## Code generation with LLMs
187
+
188
+ Micra has a small surface area, but LLMs default to jQuery / vanilla-JS or React patterns that defeat the framework. When generating Micra code (in Claude artifacts, ChatGPT canvas, Cursor, Copilot, etc.), follow these rules:
189
+
190
+ 1. **Lists** go through `<template data-each="items" data-key="id">`. Never `getElementById` / `innerHTML` for component output.
191
+ 2. **Derived values** (counts, totals, filtered subsets) are **methods**, not state fields. State holds raw data only.
192
+ 3. **Event handlers** use `@event` / `data-on`. Never `addEventListener` inside methods — it leaks past `destroy()`. Document-level listeners go in `onCreate` and are removed in `onDestroy`.
193
+ 4. **No manual re-render.** Micra batches a microtask render on every state write — no `this.refresh()` / `this.update()` / `this.renderList()`.
194
+ 5. **State proxy is shallow.** Replace top-level: `state.user = { ...state.user, name: x }`, not `state.user.name = x`.
195
+ 6. **No modifier syntax** like `@keydown.enter` — branch on `e.key === 'Enter'` inside the handler.
196
+ 7. **Use jsDelivr, not unpkg** — `cdn.jsdelivr.net` is in the CSP allowlist of Claude artifacts / ChatGPT canvas; `unpkg.com` is blocked there.
197
+
198
+ Full anti-pattern reference with side-by-side examples: [`docs/llm-guide.md`](./docs/llm-guide.md) and [`llms-full.txt`](./llms-full.txt).
@@ -11,17 +11,23 @@
11
11
  * mount() is called by both the public Micra.mount() API and by start()
12
12
  * (which scans the DOM for [data-component] elements).
13
13
  */
14
- import type { ComponentDefinition, ComponentInstance, StateRecord } from '../types';
14
+ import type { ComponentDefinition, ComponentInstance, StateRecord } from "../types";
15
15
  /**
16
16
  * Mount a component definition onto a DOM element.
17
17
  * Returns the component instance, or null if the root element is not found.
18
18
  *
19
19
  * Already-mounted elements return the existing instance.
20
20
  *
21
+ * Both `S` (state) and `M` (methods) are inferred from the literal — the
22
+ * returned instance is fully typed: `inst.state.X` and `inst.someMethod()`
23
+ * are checked.
24
+ *
21
25
  * @example
22
26
  * const instance = Micra.mount('#counter', {
23
27
  * state: { count: 0 },
24
28
  * inc() { this.state.count++ },
25
29
  * })
30
+ * instance?.inc()
31
+ * instance?.state.count
26
32
  */
27
- export declare function mount<S extends StateRecord>(selector: string | HTMLElement, definition: ComponentDefinition<S>): ComponentInstance<S> | null;
33
+ export declare function mount<S extends StateRecord, M>(selector: string | HTMLElement, definition: ComponentDefinition<S, M>): ComponentInstance<S, M> | null;
@@ -18,7 +18,7 @@ import type { StateRecord } from '../types';
18
18
  * const state = createReactiveState(raw, render)
19
19
  * state.count = 5 // triggers render() in next microtask
20
20
  */
21
- export declare function createReactiveState<S extends StateRecord>(obj: S, schedule: () => void): S;
21
+ export declare function createReactiveState<S extends StateRecord>(obj: S, schedule: () => void, onKey?: (key: string) => void): S;
22
22
  /**
23
23
  * Return a debounce function that defers `render` to the next microtask.
24
24
  * Multiple calls within the same tick collapse to a single render.
@@ -14,13 +14,22 @@ export declare const _instances: Map<HTMLElement, InternalInstance<StateRecord>>
14
14
  /**
15
15
  * Register a component definition under `name`.
16
16
  *
17
+ * Both state shape (`S`) and method set (`M`) are inferred from the literal,
18
+ * so `this.state.X` and `this.someMethod()` are fully typed inside the
19
+ * method bodies and lifecycle hooks.
20
+ *
17
21
  * @example
18
- * define('counter', { state: { count: 0 }, inc() { this.state.count++ } })
22
+ * define('counter', {
23
+ * state: { count: 0 },
24
+ * inc() { this.state.count++ }, // this.state.count: number ✓
25
+ * reset() { this.state.count = 0 }, // this.reset() is also typed ✓
26
+ * onCreate() { this.inc() }, // ✓
27
+ * })
19
28
  */
20
- export declare function define<S extends StateRecord>(name: string, definition: ComponentDefinition<S>): void;
29
+ export declare function define<S extends StateRecord, M>(name: string, definition: ComponentDefinition<S, M>): void;
21
30
  /**
22
31
  * Type-helper — returns `definition` unchanged but lets TypeScript infer `S`
23
- * from the `state` literal so all methods are typed with the correct `this`.
32
+ * and `M` from the literal so all methods are typed with the correct `this`.
24
33
  *
25
34
  * Use this when defining a component outside a `define()` call.
26
35
  *
@@ -31,7 +40,7 @@ export declare function define<S extends StateRecord>(name: string, definition:
31
40
  * })
32
41
  * Micra.define('counter', counter)
33
42
  */
34
- export declare function defineComponent<S extends StateRecord>(definition: ComponentDefinition<S>): ComponentDefinition<S>;
43
+ export declare function defineComponent<S extends StateRecord, M>(definition: ComponentDefinition<S, M>): ComponentDefinition<S, M>;
35
44
  /**
36
45
  * Returns a read-only view of all live instances (keyed by root element).
37
46
  * Useful for DevTools / debugging.
@@ -4,36 +4,32 @@
4
4
  * Responsibilities:
5
5
  * - data-text, data-html, data-if, data-show, data-bind, data-model
6
6
  * - data-class (additive class toggling)
7
- * - Directive result cache (built once per element, reused on re-renders)
8
7
  *
9
- * LLM NOTE: applyDirectives() is called on every render. The directive cache
10
- * (DirectiveCache on el.__micraCache) avoids repeated querySelectorAll on
11
- * re-renders cache is built lazily on the first call for each root element.
8
+ * LLM NOTE: applyDirectives() is called on every render. It consumes a
9
+ * pre-computed ScanIndex (built once by scan.ts and cached on the element).
10
+ * The scan replaced 10+ querySelectorAll calls with a single TreeWalker pass.
12
11
  *
13
12
  * Important: this module does NOT handle data-each — see dom/each.ts.
14
13
  */
15
- import type { InternalInstance, StateRecord } from '../types';
14
+ import type { InternalInstance, ScanIndex, StateRecord } from '../types';
16
15
  import { warn } from '../utils/expr';
17
16
  /**
18
17
  * Apply all non-each directives to a component subtree.
19
18
  *
20
- * For regular Elements: directive bindings are cached in `el.__micraCache`
21
- * after the first call subsequent re-renders skip querySelectorAll entirely.
19
+ * Consumes a pre-computed ScanIndex. data-if runs first so subsequent
20
+ * directives don't write into a tree that's about to be detached this tick.
22
21
  *
23
- * For DocumentFragments (no-key each clones): always re-scan because these
24
- * fragments are new clones on every render.
25
- *
26
- * @param root - Component root Element or DocumentFragment (no-key each clone)
22
+ * @param scan - Pre-computed scan from scan.ts (cached per element)
27
23
  * @param state - Expression state (may include item/index for each rows)
28
24
  * @param rawState - Raw (non-proxy) state for model sync
29
- * @param instance - Component instance (unused here, kept for future hooks)
30
25
  */
31
- export declare function applyDirectives<S extends StateRecord>(root: Element | DocumentFragment, state: StateRecord, rawState: StateRecord, _instance: InternalInstance<S>): void;
26
+ export declare function applyDirectives<S extends StateRecord>(scan: ScanIndex, state: StateRecord, rawState: StateRecord, _instance: InternalInstance<S>): void;
32
27
  /**
33
28
  * Validate directive usage and emit dev warnings.
34
- * Called once after the initial render of a component.
29
+ * Called once after the initial render of a component, with the already-built
30
+ * scan so we don't walk the DOM again.
35
31
  *
36
32
  * @internal
37
33
  */
38
- export declare function validateDirectives(root: Element): void;
34
+ export declare function validateDirectives(scan: ScanIndex): void;
39
35
  export { warn };
@@ -8,18 +8,21 @@
8
8
  * - Apply directives to each row with a scoped itemState
9
9
  *
10
10
  * LLM NOTE: renderList() is called on every render cycle AFTER applyDirectives().
11
- * Only <template> elements with data-each are processed.
11
+ * The template list comes pre-scanned from scan.ts — no DOM queries here.
12
+ * Each row node gets its own ScanIndex cached on `node.__micraScan` so
13
+ * re-renders of that row don't re-walk the DOM.
12
14
  * Keyed mode (data-key present) mutates the DOM in-place — nodes are
13
15
  * created once and reused. Non-keyed mode removes all nodes and re-clones.
14
16
  */
15
17
  import type { InternalInstance, StateRecord } from '../types';
16
18
  /**
17
- * Process all `<template data-each>` elements owned by `root`.
19
+ * Process all `<template data-each>` elements found by the scanner.
18
20
  * Scoped itemState makes `item`, `index`, `$index` available in row expressions.
19
21
  *
20
- * @param root - Component root Element
21
- * @param state - Expression state (proxy merging rawState + instance)
22
- * @param rawState - Raw (non-proxy) state — used for model binding
23
- * @param instance - Component instance (for event binding)
22
+ * @param templates - Pre-scanned list of <template data-each> elements
23
+ * @param state - Expression state (proxy merging rawState + instance)
24
+ * @param rawState - Raw (non-proxy) state — used for model binding
25
+ * @param instance - Component instance (for event binding)
26
+ * @param triggerKey - Which state key triggered this render (null = initial, 'MULTIPLE' = batch)
24
27
  */
25
- export declare function renderList<S extends StateRecord>(root: Element, state: StateRecord, rawState: StateRecord, instance: InternalInstance<S>): void;
28
+ export declare function renderList<S extends StateRecord>(templates: Element[], state: StateRecord, rawState: StateRecord, instance: InternalInstance<S>, triggerKey: string | null | 'MULTIPLE'): void;
@@ -9,29 +9,36 @@
9
9
  * LLM NOTE: Every listener attached here is also recorded in
10
10
  * instance.__micraListeners so destroy() can remove it cleanly.
11
11
  * Re-render skips already-bound elements via per-element __micra* flags.
12
+ *
13
+ * All three binders accept pre-computed element lists from scan.ts —
14
+ * no DOM queries here.
12
15
  */
13
- import type { InternalInstance, StateRecord } from '../types';
16
+ import type { CachedBinding, InternalInstance, StateRecord } from '../types';
14
17
  /**
15
18
  * Bind `data-on="event:method[,event2:method2]"` listeners.
16
19
  * Listeners are bound once — re-render calls are no-ops for already-bound elements.
17
20
  *
18
21
  * Supports modifiers: `click.prevent`, `click.stop`, `click.self`.
19
22
  *
23
+ * @param els - Pre-computed list of [data-on] elements from scan.ts
24
+ *
20
25
  * @example
21
26
  * <button data-on="click:save">Save</button>
22
27
  * <form data-on="submit.prevent:handleSubmit">
23
28
  */
24
- export declare function bindDataOn<S extends StateRecord>(root: Element, instance: InternalInstance<S>): void;
29
+ export declare function bindDataOn<S extends StateRecord>(els: Element[], instance: InternalInstance<S>): void;
25
30
  /**
26
31
  * Bind `@event="method"` shorthand attributes (Stimulus-style).
27
32
  * Bound once per element via `__micraAtBound` — re-renders are no-ops.
28
- * Supports the same modifiers as data-on: `@click.prevent="submit"`.
33
+ *
34
+ * @param els - Pre-computed list of elements with at least one @-prefixed attr
35
+ * (from scan.ts — replaces the old `querySelectorAll('*')` walk)
29
36
  *
30
37
  * @example
31
38
  * <button @click="increment">+</button>
32
39
  * <form @submit.prevent="handleSubmit">
33
40
  */
34
- export declare function bindAtEvents<S extends StateRecord>(root: Element, instance: InternalInstance<S>): void;
41
+ export declare function bindAtEvents<S extends StateRecord>(els: Element[], instance: InternalInstance<S>): void;
35
42
  /**
36
43
  * Two-way binding: `data-model="key"` wires <input>/<select>/<textarea>
37
44
  * to `state[key]`. Binding is attached once per element.
@@ -39,8 +46,11 @@ export declare function bindAtEvents<S extends StateRecord>(root: Element, insta
39
46
  * Numeric inputs (`type="number"` / `type="range"`) write numbers, not strings.
40
47
  * Checkbox inputs write booleans. Everything else writes strings.
41
48
  *
49
+ * @param bindings - Pre-computed model bindings from scan.ts
50
+ * (each carries { el, expr } where expr is the state key)
51
+ *
42
52
  * @example
43
53
  * <input data-model="search"> // updates state.search on every keystroke
44
54
  * <select data-model="sortBy"> // updates state.sortBy on change
45
55
  */
46
- export declare function bindModels<S extends StateRecord>(root: Element, instance: InternalInstance<S>): void;
56
+ export declare function bindModels<S extends StateRecord>(bindings: CachedBinding[], instance: InternalInstance<S>): void;
@@ -2,22 +2,22 @@
2
2
  * src/dom/refs.ts — data-ref collection.
3
3
  *
4
4
  * Responsibilities:
5
- * - After each render, scan for `[data-ref]` elements (owned by this component)
6
- * - Populate `instance.refs` so methods can do `this.refs.chart` etc.
5
+ * - Populate `instance.refs` from a pre-scanned list of [data-ref] elements.
7
6
  *
8
7
  * LLM NOTE: This module is PURE relative to state — it only reads DOM attributes
9
8
  * and writes to instance.refs. It does NOT trigger renders.
10
9
  */
11
10
  import type { InternalInstance, StateRecord } from '../types';
12
11
  /**
13
- * Collect all `[data-ref="name"]` elements owned by this component root into
14
- * `instance.refs`.
12
+ * Build `instance.refs` from the pre-scanned [data-ref] elements.
15
13
  *
16
14
  * Called once after the initial render and again on every re-render (refs may
17
15
  * point to newly created elements after an each-list update).
18
16
  *
17
+ * @param els - List of [data-ref] elements from scan.ts
18
+ *
19
19
  * @example
20
20
  * // HTML: <canvas data-ref="chart">
21
21
  * // JS: this.refs.chart → HTMLCanvasElement
22
22
  */
23
- export declare function collectRefs<S extends StateRecord>(root: Element, instance: InternalInstance<S>): void;
23
+ export declare function collectRefs<S extends StateRecord>(els: Element[], instance: InternalInstance<S>): void;
@@ -0,0 +1,34 @@
1
+ /**
2
+ * src/dom/scan.ts — Single-pass directive/event/ref scanner.
3
+ *
4
+ * Replaces 10+ querySelectorAll calls per render with ONE TreeWalker
5
+ * traversal that classifies every directive attribute in a single visit.
6
+ *
7
+ * Boundaries:
8
+ * - REJECT (skip subtree) on nested [data-component] — same semantics as
9
+ * the old `filterOwn` helper, but applied during the walk so we don't
10
+ * even *visit* those nodes.
11
+ * - <template> contents are not visited (browser TreeWalker default).
12
+ * `<template data-each>` itself IS visited and classified into scan.each;
13
+ * its children are processed by each.ts on every render via scanFragment.
14
+ *
15
+ * Hot-path notes:
16
+ * - We read `el.attributes` once and switch by suffix. No allocations per
17
+ * non-matching attr.
18
+ * - Pair-parsing (`data-bind`, `data-class`) happens here, once, at scan
19
+ * time. Reused on every render.
20
+ */
21
+ import type { ScanIndex } from "../types";
22
+ /**
23
+ * Scan an Element subtree owned by one component. Skips nested
24
+ * [data-component] subtrees entirely. Visits the root itself.
25
+ *
26
+ * Cached on `el.__micraScan` after the first call — subsequent renders
27
+ * are free.
28
+ */
29
+ export declare function scanComponent(root: Element): ScanIndex;
30
+ /**
31
+ * Scan a DocumentFragment (no-key each clone). Not cached — these fragments
32
+ * are temporary and re-cloned every render.
33
+ */
34
+ export declare function scanFragment(frag: DocumentFragment): ScanIndex;
package/dist/index.d.ts CHANGED
@@ -21,7 +21,7 @@
21
21
  *
22
22
  * @module Micra
23
23
  */
24
- export type { StateRecord, UnsubFn, EventHandler, FetchOptions, ComponentInstance, ComponentDefinition, } from './types';
24
+ export type { StateRecord, UnsubFn, EventHandler, FetchOptions, ComponentMethods, ComponentBuiltins, ComponentInstance, ComponentDefinition, } from './types';
25
25
  export { FetchError } from './utils/fetch';
26
26
  export { define, defineComponent, instances, registry, debug } from './core/registry';
27
27
  export { mount } from './core/mount';