micra.js 2.2.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/llms-full.txt CHANGED
@@ -7,7 +7,7 @@ This file follows the llmstxt.org "expanded" convention: it inlines code recipes
7
7
  ## Install
8
8
 
9
9
  ```bash
10
- npm install micra.js@^2.2.0
10
+ npm install micra.js@^2.2.1
11
11
  ```
12
12
 
13
13
  ```ts
@@ -17,7 +17,7 @@ import * as Micra from 'micra.js'
17
17
  Or CDN (no build step):
18
18
 
19
19
  ```html
20
- <script src="https://cdn.jsdelivr.net/npm/micra.js@2.2.0/dist/micra.min.js"></script>
20
+ <script src="https://cdn.jsdelivr.net/npm/micra.js@2.2.1/dist/micra.min.js"></script>
21
21
  ```
22
22
 
23
23
  This exposes a global `Micra` object.
@@ -146,7 +146,7 @@ Everything else (`window`, `document`, `fetch`, `eval`, `constructor`, `setTimeo
146
146
  <button @click="inc">+</button>
147
147
  </div>
148
148
 
149
- <script src="https://cdn.jsdelivr.net/npm/micra.js@2.2.0/dist/micra.min.js"></script>
149
+ <script src="https://cdn.jsdelivr.net/npm/micra.js@2.2.1/dist/micra.min.js"></script>
150
150
  <script>
151
151
  Micra.define('counter', {
152
152
  state: { count: 0 },
@@ -190,7 +190,7 @@ Everything else (`window`, `document`, `fetch`, `eval`, `constructor`, `setTimeo
190
190
  </footer>
191
191
  </div>
192
192
 
193
- <script src="https://cdn.jsdelivr.net/npm/micra.js@2.2.0/dist/micra.min.js"></script>
193
+ <script src="https://cdn.jsdelivr.net/npm/micra.js@2.2.1/dist/micra.min.js"></script>
194
194
  <script>
195
195
  Micra.define('todo-app', {
196
196
  state: {
@@ -262,7 +262,7 @@ Everything else (`window`, `document`, `fetch`, `eval`, `constructor`, `setTimeo
262
262
  <p data-if="filtered().length === 0">No matches.</p>
263
263
  </div>
264
264
 
265
- <script src="https://cdn.jsdelivr.net/npm/micra.js@2.2.0/dist/micra.min.js"></script>
265
+ <script src="https://cdn.jsdelivr.net/npm/micra.js@2.2.1/dist/micra.min.js"></script>
266
266
  <script>
267
267
  Micra.define('users-table', {
268
268
  state: {
@@ -303,7 +303,7 @@ Everything else (`window`, `document`, `fetch`, `eval`, `constructor`, `setTimeo
303
303
  <p data-if="success">Invitation sent ✓</p>
304
304
  </form>
305
305
 
306
- <script src="https://cdn.jsdelivr.net/npm/micra.js@2.2.0/dist/micra.min.js"></script>
306
+ <script src="https://cdn.jsdelivr.net/npm/micra.js@2.2.1/dist/micra.min.js"></script>
307
307
  <script>
308
308
  Micra.define('invite-form', {
309
309
  state: { email: '', loading: false, error: '', success: false },
@@ -345,7 +345,7 @@ Everything else (`window`, `document`, `fetch`, `eval`, `constructor`, `setTimeo
345
345
  </div>
346
346
  </div>
347
347
 
348
- <script src="https://cdn.jsdelivr.net/npm/micra.js@2.2.0/dist/micra.min.js"></script>
348
+ <script src="https://cdn.jsdelivr.net/npm/micra.js@2.2.1/dist/micra.min.js"></script>
349
349
  <script>
350
350
  Micra.define('open-modal-btn', {
351
351
  open() {
@@ -394,7 +394,7 @@ Everything else (`window`, `document`, `fetch`, `eval`, `constructor`, `setTimeo
394
394
  <section data-if="tab === 'security'">Security content</section>
395
395
  </div>
396
396
 
397
- <script src="https://cdn.jsdelivr.net/npm/micra.js@2.2.0/dist/micra.min.js"></script>
397
+ <script src="https://cdn.jsdelivr.net/npm/micra.js@2.2.1/dist/micra.min.js"></script>
398
398
  <script>
399
399
  Micra.define('tabs', {
400
400
  state: { tab: 'overview' },
@@ -414,7 +414,7 @@ Everything else (`window`, `document`, `fetch`, `eval`, `constructor`, `setTimeo
414
414
  <button @click="upgrade" data-if="plan !== 'enterprise'">Upgrade</button>
415
415
  </div>
416
416
 
417
- <script src="https://cdn.jsdelivr.net/npm/micra.js@2.2.0/dist/micra.min.js"></script>
417
+ <script src="https://cdn.jsdelivr.net/npm/micra.js@2.2.1/dist/micra.min.js"></script>
418
418
  <script>
419
419
  Micra.define('user-card', {
420
420
  state: { name: '', plan: '' },
@@ -445,7 +445,7 @@ Everything else (`window`, `document`, `fetch`, `eval`, `constructor`, `setTimeo
445
445
  <p data-if="!loading && results.length === 0 && query">No results.</p>
446
446
  </div>
447
447
 
448
- <script src="https://cdn.jsdelivr.net/npm/micra.js@2.2.0/dist/micra.min.js"></script>
448
+ <script src="https://cdn.jsdelivr.net/npm/micra.js@2.2.1/dist/micra.min.js"></script>
449
449
  <script>
450
450
  Micra.define('search', {
451
451
  state: { query: '', results: [], loading: false },
@@ -479,7 +479,7 @@ Everything else (`window`, `document`, `fetch`, `eval`, `constructor`, `setTimeo
479
479
  <p data-if="loading">Loading chart…</p>
480
480
  </div>
481
481
 
482
- <script src="https://cdn.jsdelivr.net/npm/micra.js@2.2.0/dist/micra.min.js"></script>
482
+ <script src="https://cdn.jsdelivr.net/npm/micra.js@2.2.1/dist/micra.min.js"></script>
483
483
  <script>
484
484
  Micra.define('revenue-chart', {
485
485
  state: { loading: true },
@@ -511,7 +511,7 @@ Everything else (`window`, `document`, `fetch`, `eval`, `constructor`, `setTimeo
511
511
  <p data-if="!loading && rows.length === 0">No results.</p>
512
512
  </div>
513
513
 
514
- <script src="https://cdn.jsdelivr.net/npm/micra.js@2.2.0/dist/micra.min.js"></script>
514
+ <script src="https://cdn.jsdelivr.net/npm/micra.js@2.2.1/dist/micra.min.js"></script>
515
515
  <script>
516
516
  Micra.define('search-bar', {
517
517
  state: { query: '' },
@@ -569,9 +569,9 @@ import { ref, computed } from 'vue'
569
569
  import Alpine from 'alpinejs'
570
570
 
571
571
  // ❌ unpkg CDN — blocked by Claude artifacts and most AI sandbox CSPs
572
- <script src="https://unpkg.com/micra.js@2.2.0/dist/micra.min.js"></script>
572
+ <script src="https://unpkg.com/micra.js@2.2.1/dist/micra.min.js"></script>
573
573
  // ✅ Use jsDelivr instead — it auto-mirrors npm and is CSP-allowlisted everywhere
574
- <script src="https://cdn.jsdelivr.net/npm/micra.js@2.2.0/dist/micra.min.js"></script>
574
+ <script src="https://cdn.jsdelivr.net/npm/micra.js@2.2.1/dist/micra.min.js"></script>
575
575
  ```
576
576
 
577
577
  # Final checklist
package/llms.txt CHANGED
@@ -25,7 +25,7 @@ import * as Micra from 'micra.js'
25
25
  Or via CDN (no build step):
26
26
 
27
27
  ```html
28
- <script src="https://cdn.jsdelivr.net/npm/micra.js@2.2.0/dist/micra.min.js"></script>
28
+ <script src="https://cdn.jsdelivr.net/npm/micra.js@2.2.1/dist/micra.min.js"></script>
29
29
  ```
30
30
 
31
31
  This exposes a global `Micra` object.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "micra.js",
3
- "version": "2.2.0",
3
+ "version": "2.2.1",
4
4
  "description": "Lightweight reactive UI framework for server-rendered pages — reactive state, directives, event bus. < 5 KB gzip.",
5
5
  "type": "module",
6
6
  "main": "./dist/micra.cjs.js",
package/src/core/mount.ts CHANGED
@@ -19,6 +19,7 @@ import type {
19
19
  EventHandler,
20
20
  InternalInstance,
21
21
  MicraElement,
22
+
22
23
  StateRecord,
23
24
  UnsubFn,
24
25
  } from "../types";
@@ -107,8 +108,14 @@ export function mount<S extends StateRecord, M>(
107
108
 
108
109
  // ── Render ────────────────────────────────────────────────────────────────
109
110
  let isRendering = false;
111
+ // Track which state key triggered the current render cycle.
112
+ // 'MULTIPLE' means more than one key was written before the microtask fired.
113
+ let _triggerKey: string | null | "MULTIPLE" = null;
110
114
  const schedule = createScheduler(() => instance.render());
111
- instance.state = createReactiveState(rawState, schedule) as S;
115
+ instance.state = createReactiveState(rawState, schedule, (key) => {
116
+ if (_triggerKey === null) _triggerKey = key;
117
+ else if (_triggerKey !== key) _triggerKey = "MULTIPLE";
118
+ }) as S;
112
119
 
113
120
  // Expression state: proxy that falls back to instance methods so expressions
114
121
  // like `data-text="formatDate(item.date)"` can call component methods.
@@ -149,6 +156,8 @@ export function mount<S extends StateRecord, M>(
149
156
  let warnedReentry = false;
150
157
  instance.render = function () {
151
158
  if (instance.__micraDestroyed) return;
159
+ const triggerKey = _triggerKey;
160
+ _triggerKey = null;
152
161
  if (isRendering) {
153
162
  if (!warnedReentry) {
154
163
  warn(
@@ -166,7 +175,7 @@ export function mount<S extends StateRecord, M>(
166
175
  const scan =
167
176
  mRoot.__micraScan ?? (mRoot.__micraScan = scanComponent(root));
168
177
  applyDirectives(scan, exprState, rawState, instance);
169
- renderList(scan.each, exprState, rawState, instance);
178
+ renderList(scan.each, exprState, rawState, instance, triggerKey);
170
179
  bindDataOn(scan.on, instance);
171
180
  bindAtEvents(scan.atEvents, instance);
172
181
  bindModels(scan.model, instance);
@@ -187,7 +196,7 @@ export function mount<S extends StateRecord, M>(
187
196
  );
188
197
  instance.__micraListeners = [];
189
198
 
190
- // Clear per-element flags & cached directive scan so a future re-mount of the same DOM works.
199
+ // Clear per-element flags & cached scan so a future re-mount of the same DOM works.
191
200
  const clearFlags = (el: Element) => {
192
201
  const m = el as MicraElement;
193
202
  delete m.__micraEvents;
@@ -20,11 +20,12 @@ import type { StateRecord } from '../types'
20
20
  * const state = createReactiveState(raw, render)
21
21
  * state.count = 5 // triggers render() in next microtask
22
22
  */
23
- export function createReactiveState<S extends StateRecord>(obj: S, schedule: () => void): S {
23
+ export function createReactiveState<S extends StateRecord>(obj: S, schedule: () => void, onKey?: (key: string) => void): S {
24
24
  return new Proxy(obj, {
25
25
  set(target, key: string, value: unknown) {
26
26
  // Cast through StateRecord — TypeScript cannot write through a generic index
27
27
  ;(target as StateRecord)[key] = value
28
+ onKey?.(key)
28
29
  schedule()
29
30
  return true
30
31
  },
@@ -36,7 +36,8 @@ function applyText(el: Element, expr: string, state: StateRecord): void {
36
36
  * for the full security model.
37
37
  */
38
38
  function applyHtml(el: Element, expr: string, state: StateRecord): void {
39
- el.innerHTML = String(evalExpr(expr, state) ?? '')
39
+ const html = String(evalExpr(expr, state) ?? '')
40
+ if (el.innerHTML !== html) el.innerHTML = html
40
41
  }
41
42
 
42
43
  /**
@@ -72,7 +73,9 @@ function applyIf(binding: CachedIfBinding, state: StateRecord): void {
72
73
  * data-show — visibility toggle via `style.display`. Element stays in the DOM.
73
74
  */
74
75
  function applyShow(el: Element, expr: string, state: StateRecord): void {
75
- (el as HTMLElement).style.display = evalExpr(expr, state) ? '' : 'none'
76
+ const desired = evalExpr(expr, state) ? '' : 'none'
77
+ const htmlEl = el as HTMLElement
78
+ if (htmlEl.style.display !== desired) htmlEl.style.display = desired
76
79
  }
77
80
 
78
81
  function applyBind(
package/src/dom/each.ts CHANGED
@@ -30,16 +30,18 @@ import { scanComponent, scanFragment } from './scan'
30
30
  * Process all `<template data-each>` elements found by the scanner.
31
31
  * Scoped itemState makes `item`, `index`, `$index` available in row expressions.
32
32
  *
33
- * @param templates - Pre-scanned list of <template data-each> elements
34
- * @param state - Expression state (proxy merging rawState + instance)
35
- * @param rawState - Raw (non-proxy) state — used for model binding
36
- * @param instance - Component instance (for event binding)
33
+ * @param templates - Pre-scanned list of <template data-each> elements
34
+ * @param state - Expression state (proxy merging rawState + instance)
35
+ * @param rawState - Raw (non-proxy) state — used for model binding
36
+ * @param instance - Component instance (for event binding)
37
+ * @param triggerKey - Which state key triggered this render (null = initial, 'MULTIPLE' = batch)
37
38
  */
38
39
  export function renderList<S extends StateRecord>(
39
40
  templates: Element[],
40
41
  state: StateRecord,
41
42
  rawState: StateRecord,
42
43
  instance: InternalInstance<S>,
44
+ triggerKey: string | null | 'MULTIPLE',
43
45
  ): void {
44
46
  for (const tmplEl of templates) {
45
47
  if (tmplEl.tagName !== 'TEMPLATE') continue
@@ -60,10 +62,9 @@ export function renderList<S extends StateRecord>(
60
62
 
61
63
  const marker = tmpl.__micraMarker
62
64
  const keyMap = tmpl.__micraNodes
63
- const parent = marker.parentNode
64
65
  // The template (and its marker) is currently detached — likely a data-if
65
66
  // ancestor unmounted this subtree. Nothing to do until it returns.
66
- if (!parent) continue
67
+ if (!marker.parentNode) continue
67
68
 
68
69
  // Empty / non-array: clear all rendered rows
69
70
  if (!Array.isArray(items)) {
@@ -73,10 +74,16 @@ export function renderList<S extends StateRecord>(
73
74
  continue
74
75
  }
75
76
 
77
+ // canSkipUnchanged: true when only this list's state key changed — rows
78
+ // whose item reference and index are both unchanged can skip applyDirectives.
79
+ const canSkipUnchanged = triggerKey !== null &&
80
+ triggerKey !== 'MULTIPLE' &&
81
+ triggerKey === itemsExpr
82
+
76
83
  if (keyAttr) {
77
- renderKeyed(tmpl, items as StateRecord[], keyAttr, marker, keyMap, parent, state, rawState, instance)
84
+ renderKeyed(tmpl, items as StateRecord[], keyAttr, marker, keyMap, state, rawState, instance, canSkipUnchanged)
78
85
  } else {
79
- renderNoKey(tmpl, items as StateRecord[], marker, parent, state, rawState, instance)
86
+ renderNoKey(tmpl, items as StateRecord[], marker, state, rawState, instance)
80
87
  }
81
88
  }
82
89
  }
@@ -89,10 +96,10 @@ function renderKeyed<S extends StateRecord>(
89
96
  keyAttr: string,
90
97
  marker: Comment,
91
98
  keyMap: Map<unknown, MicraElement>,
92
- parent: Node,
93
99
  state: StateRecord,
94
100
  rawState: StateRecord,
95
101
  instance: InternalInstance<S>,
102
+ canSkipUnchanged: boolean,
96
103
  ): void {
97
104
  const nextKeys = new Set<unknown>()
98
105
  const nextNodes: MicraElement[] = []
@@ -132,12 +139,26 @@ function renderKeyed<S extends StateRecord>(
132
139
  bindDataOn(rowScan.on, instance)
133
140
  bindAtEvents(rowScan.atEvents, instance)
134
141
  bindModels(rowScan.model, instance)
142
+ // itemState is created once per node and reused across renders.
143
+ // item / index / $index are mutated in place each render — avoids
144
+ // Object.create + assign on every cycle and lets safeWrapCache hit.
145
+ node._itemState = Object.create(state) as StateRecord
146
+ } else if (canSkipUnchanged && node.__micraItem === item && node.__micraIndex === index) {
147
+ // Item reference and index are unchanged, and no other state key changed
148
+ // this cycle — the DOM already reflects the latest values. Skip re-render.
149
+ nextNodes.push(node)
150
+ continue
135
151
  }
136
152
 
137
- const itemState = Object.assign(
138
- Object.create(state) as StateRecord,
139
- { item, index, $index: index },
140
- )
153
+ node.__micraItem = item
154
+ node.__micraIndex = index
155
+
156
+ // Reuse the cached itemState, just update the per-row values.
157
+ const itemState = node._itemState!
158
+ itemState.item = item
159
+ itemState.index = index
160
+ itemState.$index = index
161
+
141
162
  // Use the cached scan if present (created above on first sight of this key);
142
163
  // older paths may pass a node we haven't scanned yet.
143
164
  const rowScan = node.__micraScan ?? (node.__micraScan = scanComponent(node))
@@ -150,23 +171,81 @@ function renderKeyed<S extends StateRecord>(
150
171
  if (!nextKeys.has(key)) { node.remove(); keyMap.delete(key) }
151
172
  }
152
173
 
153
- // Insert / reorder nodes after marker (insertBefore is no-op if already in place)
154
- let cursor: Node = marker
155
- for (const node of nextNodes) {
156
- if (cursor.nextSibling !== node) parent.insertBefore(node, cursor.nextSibling)
157
- cursor = node
174
+ const prevList = tmpl.__micraList
175
+ if (prevList.length === 0) {
176
+ // First render (or refill after a clear): every node is new and already in
177
+ // order batch into one fragment so the DOM takes a single insertion
178
+ // instead of N anchor.after() calls. Skips LIS entirely.
179
+ if (nextNodes.length) {
180
+ const frag = document.createDocumentFragment()
181
+ for (const node of nextNodes) frag.append(node)
182
+ marker.after(frag)
183
+ }
184
+ } else {
185
+ // Skip DOM reorder when list order is unchanged (pure JS array compare, no DOM reads).
186
+ let orderChanged = nextNodes.length !== prevList.length
187
+ if (!orderChanged) {
188
+ for (let i = 0; i < nextNodes.length; i++) {
189
+ if (nextNodes[i] !== prevList[i]) { orderChanged = true; break }
190
+ }
191
+ }
192
+ if (orderChanged) reorderKeyed(nextNodes, prevList, marker)
158
193
  }
159
194
 
160
195
  tmpl.__micraList = nextNodes
161
196
  }
162
197
 
198
+ // ── Keyed list reorder (LIS) ───────────────────────────────────────────────────
199
+
200
+ /**
201
+ * Move DOM nodes to match `nextNodes` order using the minimum number of moves.
202
+ *
203
+ * Computes the Longest Increasing Subsequence of each node's position in prevList —
204
+ * nodes in the LIS keep their place. Only the others are re-inserted via anchor.after().
205
+ *
206
+ * Complexity: O(n log n) for LIS, O(k) DOM operations where k = nodes that moved.
207
+ * For a 2-node swap this means 2 DOM ops instead of n.
208
+ */
209
+ function reorderKeyed(nextNodes: MicraElement[], prevList: MicraElement[], marker: Comment): void {
210
+ const prevPos = new Map<MicraElement, number>()
211
+ for (let i = 0; i < prevList.length; i++) prevPos.set(prevList[i]!, i)
212
+
213
+ const n = nextNodes.length
214
+ const tails: number[] = [] // patience sort: smallest tail at each LIS length
215
+ const tailIdx: number[] = [] // index into nextNodes for each tail
216
+ const prev: number[] = new Array(n).fill(-1)
217
+
218
+ for (let i = 0; i < n; i++) {
219
+ const p = prevPos.get(nextNodes[i]!)
220
+ if (p === undefined) continue // new node — always moved
221
+ let lo = 0, hi = tails.length
222
+ while (lo < hi) { const m = (lo + hi) >> 1; tails[m]! < p ? lo = m + 1 : hi = m }
223
+ if (lo > 0) prev[i] = tailIdx[lo - 1]!
224
+ tails[lo] = p
225
+ tailIdx[lo] = i
226
+ }
227
+
228
+ // Reconstruct stable (non-moving) set from LIS parent chain
229
+ const stable = new Set<number>()
230
+ let idx: number = tailIdx[tails.length - 1]!
231
+ while (idx >= 0) { stable.add(idx); idx = prev[idx]! }
232
+
233
+ // Move unstable nodes into position; stable (LIS) nodes serve as anchors
234
+ let anchor: ChildNode = marker
235
+ for (let i = 0; i < n; i++) {
236
+ const node = nextNodes[i]!
237
+ if (stable.has(i)) { anchor = node; continue }
238
+ anchor.after(node)
239
+ anchor = node
240
+ }
241
+ }
242
+
163
243
  // ── Non-keyed (full re-render) ─────────────────────────────────────────────────
164
244
 
165
245
  function renderNoKey<S extends StateRecord>(
166
246
  tmpl: MicraTemplate,
167
247
  items: StateRecord[],
168
248
  marker: Comment,
169
- parent: Node,
170
249
  state: StateRecord,
171
250
  rawState: StateRecord,
172
251
  instance: InternalInstance<S>,
@@ -192,5 +271,5 @@ function renderNoKey<S extends StateRecord>(
192
271
  nodes.forEach(n => { n.__micraEach = true; frag.append(n) })
193
272
  tmpl.__micraList.push(...nodes)
194
273
  }
195
- parent.insertBefore(frag, marker.nextSibling)
274
+ marker.after(frag)
196
275
  }
package/src/dom/refs.ts CHANGED
@@ -26,6 +26,7 @@ export function collectRefs<S extends StateRecord>(
26
26
  els: Element[],
27
27
  instance: InternalInstance<S>,
28
28
  ): void {
29
+ if (!els.length) return
29
30
  instance.refs = {}
30
31
  for (const el of els) {
31
32
  const name = (el as MicraElement).dataset['ref']
package/src/types.ts CHANGED
@@ -149,6 +149,9 @@ export interface MicraElement extends HTMLElement {
149
149
  __micraKey?: unknown // keyed-diff key
150
150
  __micraEach?: true // belongs to a no-key each list
151
151
  __micraScan?: ScanIndex // single-pass scan result (cached after 1st render)
152
+ __micraItem?: StateRecord // keyed row: last-rendered item ref (for skip check)
153
+ __micraIndex?: number // keyed row: last-rendered index (for skip check)
154
+ _itemState?: StateRecord // keyed row: reused itemState (avoids Object.create per render)
152
155
  }
153
156
 
154
157
  /**
@@ -166,7 +169,7 @@ export interface TrackedListener {
166
169
  export interface MicraTemplate extends HTMLTemplateElement {
167
170
  __micraMarker?: Comment
168
171
  __micraNodes: Map<unknown, MicraElement>
169
- __micraList: ChildNode[]
172
+ __micraList: MicraElement[]
170
173
  __micraNoKeyWarned?: true
171
174
  }
172
175
 
package/src/utils/expr.ts CHANGED
@@ -29,8 +29,15 @@ import type { StateRecord } from '../types'
29
29
 
30
30
  // LLM NOTE: exprCache is module-level (shared across all components).
31
31
  // This is intentional — most apps reuse the same expressions.
32
- type Compiled = (state: object, safe: object) => unknown
33
- const exprCache = new Map<string, Compiled>()
32
+
33
+ // Compiled fn for complex expressions; pre-split parts for simple dot-paths.
34
+ // Storing parts once avoids the SIMPLE_PATH regex test + split on every evalExpr call.
35
+ type CompiledFn = (state: object, safe: object) => unknown
36
+ type CachedEntry =
37
+ | { kind: 'fn'; fn: CompiledFn }
38
+ | { kind: 'path'; parts: string[] }
39
+
40
+ const exprCache = new Map<string, CachedEntry>()
34
41
  // Expressions whose runtime error we have already warned about. Prevents log spam
35
42
  // when the same `data-text="item.naame"` typo fires every render.
36
43
  const warnedRuntime = new Set<string>()
@@ -141,32 +148,38 @@ function safeStateHas(state: object, key: PropertyKey): boolean {
141
148
  * evalExpr('price * qty', { price: 9.99, qty: 3 }) // → 29.97
142
149
  */
143
150
  export function evalExpr(expr: string, state: StateRecord): unknown {
151
+ let cached = exprCache.get(expr)
152
+
153
+ if (!cached) {
154
+ // Determine once whether this is a simple dot-path and cache the result.
155
+ if (SIMPLE_PATH.test(expr)) {
156
+ cached = { kind: 'path', parts: expr.split('.') }
157
+ } else {
158
+ try {
159
+ cached = {
160
+ kind: 'fn',
161
+ fn: new Function('$s', '$safe', `with($safe){with($s){return (${expr})}}`) as CompiledFn,
162
+ }
163
+ } catch {
164
+ warn(`invalid expression "${expr}"`)
165
+ cached = { kind: 'fn', fn: () => undefined }
166
+ }
167
+ }
168
+ exprCache.set(expr, cached)
169
+ }
170
+
144
171
  // Fast-path: simple property access — no Function() needed.
145
172
  // Still guarded so bare access to Object.prototype names returns undefined.
146
- if (SIMPLE_PATH.test(expr)) {
147
- const parts = expr.split('.')
148
- if (!safeStateHas(state, parts[0]!)) return undefined
149
- return parts.reduce<unknown>((obj, key) =>
150
- obj != null ? (obj as StateRecord)[key] : undefined,
173
+ if (cached.kind === 'path') {
174
+ if (!safeStateHas(state, cached.parts[0]!)) return undefined
175
+ return cached.parts.reduce<unknown>(
176
+ (obj, key) => (obj != null ? (obj as StateRecord)[key] : undefined),
151
177
  state,
152
178
  )
153
179
  }
154
180
 
155
- if (!exprCache.has(expr)) {
156
- try {
157
- // Two with() statements: $s wins for state keys; $safe shadows globals.
158
- exprCache.set(
159
- expr,
160
- new Function('$s', '$safe', `with($safe){with($s){return (${expr})}}`) as Compiled,
161
- )
162
- } catch {
163
- warn(`invalid expression "${expr}"`)
164
- exprCache.set(expr, () => undefined)
165
- }
166
- }
167
-
168
181
  try {
169
- return exprCache.get(expr)!(safeStateWrap(state), SAFE_OUTER)
182
+ return cached.fn(safeStateWrap(state), SAFE_OUTER)
170
183
  } catch (e) {
171
184
  if (!warnedRuntime.has(expr)) {
172
185
  warnedRuntime.add(expr)