micra.js 2.2.0 → 2.3.0

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/src/dom/each.ts CHANGED
@@ -4,7 +4,8 @@
4
4
  * Responsibilities:
5
5
  * - Process `<template data-each="items" data-key="id">` elements
6
6
  * - Keyed diff: reuse/reorder DOM nodes by key — O(n) with a Map
7
- * - Non-keyed fallback: full replace (no key → warn in dev, full re-render)
7
+ * - Non-keyed fallback: length-based positional reuse min(old, new) rows
8
+ * are kept as-is, the tail is removed or new rows are appended
8
9
  * - Apply directives to each row with a scoped itemState
9
10
  *
10
11
  * LLM NOTE: renderList() is called on every render cycle AFTER applyDirectives().
@@ -12,7 +13,9 @@
12
13
  * Each row node gets its own ScanIndex cached on `node.__micraScan` so
13
14
  * re-renders of that row don't re-walk the DOM.
14
15
  * Keyed mode (data-key present) mutates the DOM in-place — nodes are
15
- * created once and reused. Non-keyed mode removes all nodes and re-clones.
16
+ * created once and reused. Non-keyed mode also reuses existing nodes
17
+ * positionally: only the length delta is touched, the rest gets a fresh
18
+ * itemState and re-applies directives.
16
19
  */
17
20
 
18
21
  import type {
@@ -24,22 +27,24 @@ import type {
24
27
  import { evalExpr, warn } from '../utils/expr'
25
28
  import { applyDirectives } from './directives'
26
29
  import { bindDataOn, bindAtEvents, bindModels } from './events'
27
- import { scanComponent, scanFragment } from './scan'
30
+ import { scanComponent } from './scan'
28
31
 
29
32
  /**
30
33
  * Process all `<template data-each>` elements found by the scanner.
31
34
  * Scoped itemState makes `item`, `index`, `$index` available in row expressions.
32
35
  *
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)
36
+ * @param templates - Pre-scanned list of <template data-each> elements
37
+ * @param state - Expression state (proxy merging rawState + instance)
38
+ * @param rawState - Raw (non-proxy) state — used for model binding
39
+ * @param instance - Component instance (for event binding)
40
+ * @param triggerKey - Which state key triggered this render (null = initial, 'MULTIPLE' = batch)
37
41
  */
38
42
  export function renderList<S extends StateRecord>(
39
43
  templates: Element[],
40
44
  state: StateRecord,
41
45
  rawState: StateRecord,
42
46
  instance: InternalInstance<S>,
47
+ triggerKey: string | null | 'MULTIPLE',
43
48
  ): void {
44
49
  for (const tmplEl of templates) {
45
50
  if (tmplEl.tagName !== 'TEMPLATE') continue
@@ -60,10 +65,9 @@ export function renderList<S extends StateRecord>(
60
65
 
61
66
  const marker = tmpl.__micraMarker
62
67
  const keyMap = tmpl.__micraNodes
63
- const parent = marker.parentNode
64
68
  // The template (and its marker) is currently detached — likely a data-if
65
69
  // ancestor unmounted this subtree. Nothing to do until it returns.
66
- if (!parent) continue
70
+ if (!marker.parentNode) continue
67
71
 
68
72
  // Empty / non-array: clear all rendered rows
69
73
  if (!Array.isArray(items)) {
@@ -73,14 +77,51 @@ export function renderList<S extends StateRecord>(
73
77
  continue
74
78
  }
75
79
 
80
+ // canSkipUnchanged: true when only this list's state key changed — rows
81
+ // whose item reference and index are both unchanged can skip applyDirectives.
82
+ const canSkipUnchanged = triggerKey !== null &&
83
+ triggerKey !== 'MULTIPLE' &&
84
+ triggerKey === itemsExpr
85
+
76
86
  if (keyAttr) {
77
- renderKeyed(tmpl, items as StateRecord[], keyAttr, marker, keyMap, parent, state, rawState, instance)
87
+ renderKeyed(tmpl, items as StateRecord[], keyAttr, marker, keyMap, state, rawState, instance, canSkipUnchanged)
78
88
  } else {
79
- renderNoKey(tmpl, items as StateRecord[], marker, parent, state, rawState, instance)
89
+ renderNoKey(tmpl, items as StateRecord[], marker, state, rawState, instance, canSkipUnchanged)
80
90
  }
81
91
  }
82
92
  }
83
93
 
94
+ // ── Row node creation (shared by both paths) ──────────────────────────────────
95
+
96
+ /**
97
+ * Clone the template into a fresh row node, wrapping multi-root content in
98
+ * `<micra-each-item style="display:contents">` so the row always corresponds
99
+ * to a single, stable DOM element. Scans, binds listeners once, and caches
100
+ * an empty itemState prototyped from `state` (filled in by the caller).
101
+ */
102
+ function createRowNode<S extends StateRecord>(
103
+ tmpl: MicraTemplate,
104
+ state: StateRecord,
105
+ instance: InternalInstance<S>,
106
+ ): MicraElement {
107
+ const frag = tmpl.content.cloneNode(true) as DocumentFragment
108
+ let node: MicraElement
109
+ if (frag.childNodes.length === 1) {
110
+ node = frag.firstElementChild as MicraElement
111
+ } else {
112
+ node = document.createElement('micra-each-item') as MicraElement
113
+ node.style.display = 'contents'
114
+ node.append(frag)
115
+ }
116
+ const rowScan = scanComponent(node)
117
+ node.__micraScan = rowScan
118
+ node._itemState = Object.create(state) as StateRecord
119
+ bindDataOn(rowScan.on, instance)
120
+ bindAtEvents(rowScan.atEvents, instance)
121
+ bindModels(rowScan.model, instance)
122
+ return node
123
+ }
124
+
84
125
  // ── Keyed diff ────────────────────────────────────────────────────────────────
85
126
 
86
127
  function renderKeyed<S extends StateRecord>(
@@ -89,10 +130,10 @@ function renderKeyed<S extends StateRecord>(
89
130
  keyAttr: string,
90
131
  marker: Comment,
91
132
  keyMap: Map<unknown, MicraElement>,
92
- parent: Node,
93
133
  state: StateRecord,
94
134
  rawState: StateRecord,
95
135
  instance: InternalInstance<S>,
136
+ canSkipUnchanged: boolean,
96
137
  ): void {
97
138
  const nextKeys = new Set<unknown>()
98
139
  const nextNodes: MicraElement[] = []
@@ -114,30 +155,25 @@ function renderKeyed<S extends StateRecord>(
114
155
  let node = keyMap.get(key) as MicraElement | undefined
115
156
 
116
157
  if (!node) {
117
- // Clone template and wrap multi-root fragments in a display:contents element
118
- const frag = tmpl.content.cloneNode(true) as DocumentFragment
119
- if (frag.childNodes.length === 1) {
120
- node = frag.firstElementChild as MicraElement
121
- } else {
122
- node = document.createElement('micra-each-item') as MicraElement
123
- node.style.display = 'contents'
124
- node.append(frag)
125
- }
158
+ node = createRowNode(tmpl, state, instance)
126
159
  node.__micraKey = key
127
160
  keyMap.set(key, node)
128
- // Bind data-on / @event / data-model listeners once per row node.
129
- // Scan the row, cache the scan on the node for future re-renders.
130
- const rowScan = scanComponent(node)
131
- node.__micraScan = rowScan
132
- bindDataOn(rowScan.on, instance)
133
- bindAtEvents(rowScan.atEvents, instance)
134
- bindModels(rowScan.model, instance)
161
+ } else if (canSkipUnchanged && node.__micraItem === item && node.__micraIndex === index) {
162
+ // Item reference and index are unchanged, and no other state key changed
163
+ // this cycle — the DOM already reflects the latest values. Skip re-render.
164
+ nextNodes.push(node)
165
+ continue
135
166
  }
136
167
 
137
- const itemState = Object.assign(
138
- Object.create(state) as StateRecord,
139
- { item, index, $index: index },
140
- )
168
+ node.__micraItem = item
169
+ node.__micraIndex = index
170
+
171
+ // Reuse the cached itemState, just update the per-row values.
172
+ const itemState = node._itemState!
173
+ itemState.item = item
174
+ itemState.index = index
175
+ itemState.$index = index
176
+
141
177
  // Use the cached scan if present (created above on first sight of this key);
142
178
  // older paths may pass a node we haven't scanned yet.
143
179
  const rowScan = node.__micraScan ?? (node.__micraScan = scanComponent(node))
@@ -150,47 +186,142 @@ function renderKeyed<S extends StateRecord>(
150
186
  if (!nextKeys.has(key)) { node.remove(); keyMap.delete(key) }
151
187
  }
152
188
 
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
189
+ const prevList = tmpl.__micraList
190
+ if (prevList.length === 0) {
191
+ // First render (or refill after a clear): every node is new and already in
192
+ // order batch into one fragment so the DOM takes a single insertion
193
+ // instead of N anchor.after() calls. Skips LIS entirely.
194
+ if (nextNodes.length) {
195
+ const frag = document.createDocumentFragment()
196
+ for (const node of nextNodes) frag.append(node)
197
+ marker.after(frag)
198
+ }
199
+ } else {
200
+ // Skip DOM reorder when list order is unchanged (pure JS array compare, no DOM reads).
201
+ let orderChanged = nextNodes.length !== prevList.length
202
+ if (!orderChanged) {
203
+ for (let i = 0; i < nextNodes.length; i++) {
204
+ if (nextNodes[i] !== prevList[i]) { orderChanged = true; break }
205
+ }
206
+ }
207
+ if (orderChanged) reorderKeyed(nextNodes, prevList, marker)
158
208
  }
159
209
 
160
210
  tmpl.__micraList = nextNodes
161
211
  }
162
212
 
163
- // ── Non-keyed (full re-render) ─────────────────────────────────────────────────
213
+ // ── Keyed list reorder (LIS) ───────────────────────────────────────────────────
164
214
 
215
+ /**
216
+ * Move DOM nodes to match `nextNodes` order using the minimum number of moves.
217
+ *
218
+ * Computes the Longest Increasing Subsequence of each node's position in prevList —
219
+ * nodes in the LIS keep their place. Only the others are re-inserted via anchor.after().
220
+ *
221
+ * Complexity: O(n log n) for LIS, O(k) DOM operations where k = nodes that moved.
222
+ * For a 2-node swap this means 2 DOM ops instead of n.
223
+ */
224
+ function reorderKeyed(nextNodes: MicraElement[], prevList: MicraElement[], marker: Comment): void {
225
+ const prevPos = new Map<MicraElement, number>()
226
+ for (let i = 0; i < prevList.length; i++) prevPos.set(prevList[i]!, i)
227
+
228
+ const n = nextNodes.length
229
+ const tails: number[] = [] // patience sort: smallest tail at each LIS length
230
+ const tailIdx: number[] = [] // index into nextNodes for each tail
231
+ const prev: number[] = new Array(n).fill(-1)
232
+
233
+ for (let i = 0; i < n; i++) {
234
+ const p = prevPos.get(nextNodes[i]!)
235
+ if (p === undefined) continue // new node — always moved
236
+ let lo = 0, hi = tails.length
237
+ while (lo < hi) { const m = (lo + hi) >> 1; tails[m]! < p ? lo = m + 1 : hi = m }
238
+ if (lo > 0) prev[i] = tailIdx[lo - 1]!
239
+ tails[lo] = p
240
+ tailIdx[lo] = i
241
+ }
242
+
243
+ // Reconstruct stable (non-moving) set from LIS parent chain
244
+ const stable = new Set<number>()
245
+ let idx: number = tailIdx[tails.length - 1]!
246
+ while (idx >= 0) { stable.add(idx); idx = prev[idx]! }
247
+
248
+ // Move unstable nodes into position; stable (LIS) nodes serve as anchors
249
+ let anchor: ChildNode = marker
250
+ for (let i = 0; i < n; i++) {
251
+ const node = nextNodes[i]!
252
+ if (stable.has(i)) { anchor = node; continue }
253
+ anchor.after(node)
254
+ anchor = node
255
+ }
256
+ }
257
+
258
+ // ── Non-keyed (positional reuse) ──────────────────────────────────────────────
259
+
260
+ /**
261
+ * Diff a non-keyed list by length: reuse the first min(prev, next) DOM nodes,
262
+ * remove the tail when the list shrinks, clone fresh rows for the growth delta.
263
+ * Multi-root template rows are wrapped in `<micra-each-item style="display:contents">`
264
+ * — same as keyed mode — so the reused list is one DOM node per row.
265
+ */
165
266
  function renderNoKey<S extends StateRecord>(
166
267
  tmpl: MicraTemplate,
167
268
  items: StateRecord[],
168
269
  marker: Comment,
169
- parent: Node,
170
270
  state: StateRecord,
171
271
  rawState: StateRecord,
172
272
  instance: InternalInstance<S>,
273
+ canSkipUnchanged: boolean,
173
274
  ): void {
174
- tmpl.__micraList.forEach(n => n.remove())
175
- tmpl.__micraList = []
275
+ const prevList = tmpl.__micraList
276
+ const prevLen = prevList.length
277
+ const nextLen = items.length
278
+ const reuseLen = nextLen < prevLen ? nextLen : prevLen
279
+ const nextList: MicraElement[] = new Array(nextLen)
176
280
 
177
- const frag = document.createDocumentFragment()
178
- for (const [index, item] of items.entries()) {
179
- const clone = tmpl.content.cloneNode(true) as DocumentFragment
180
- const itemState = Object.assign(
181
- Object.create(state) as StateRecord,
182
- { item, index, $index: index },
183
- )
184
- // Fresh clone each render → fresh scan each render (uncached).
185
- const fragScan = scanFragment(clone)
186
- applyDirectives(fragScan, itemState, rawState, instance)
187
- bindDataOn(fragScan.on, instance)
188
- bindAtEvents(fragScan.atEvents, instance)
189
- bindModels(fragScan.model, instance)
190
-
191
- const nodes = Array.from(clone.childNodes) as MicraElement[]
192
- nodes.forEach(n => { n.__micraEach = true; frag.append(n) })
193
- tmpl.__micraList.push(...nodes)
281
+ // 1. Reuse [0, reuseLen): refresh itemState, re-apply directives in place.
282
+ for (let i = 0; i < reuseLen; i++) {
283
+ const node = prevList[i]!
284
+ const item = items[i]!
285
+ if (canSkipUnchanged && node.__micraItem === item && node.__micraIndex === i) {
286
+ nextList[i] = node
287
+ continue
288
+ }
289
+ node.__micraItem = item
290
+ node.__micraIndex = i
291
+ const itemState = node._itemState!
292
+ itemState.item = item
293
+ itemState.index = i
294
+ itemState.$index = i
295
+ applyDirectives(node.__micraScan!, itemState, rawState, instance)
296
+ nextList[i] = node
297
+ }
298
+
299
+ // 2. Shrink: remove tail nodes [nextLen, prevLen).
300
+ for (let i = nextLen; i < prevLen; i++) {
301
+ prevList[i]!.remove()
194
302
  }
195
- parent.insertBefore(frag, marker.nextSibling)
303
+
304
+ // 3. Grow: clone and attach fresh rows for [prevLen, nextLen).
305
+ if (nextLen > prevLen) {
306
+ const frag = document.createDocumentFragment()
307
+ for (let i = prevLen; i < nextLen; i++) {
308
+ const node = createRowNode(tmpl, state, instance)
309
+ const item = items[i]!
310
+ const itemState = node._itemState!
311
+ itemState.item = item
312
+ itemState.index = i
313
+ itemState.$index = i
314
+ node.__micraEach = true
315
+ node.__micraItem = item
316
+ node.__micraIndex = i
317
+ applyDirectives(node.__micraScan!, itemState, rawState, instance)
318
+ nextList[i] = node
319
+ frag.append(node)
320
+ }
321
+ // Insert after the last reused node, or the marker if the list was empty.
322
+ const anchor: ChildNode = prevLen > 0 ? nextList[prevLen - 1]! : marker
323
+ anchor.after(frag)
324
+ }
325
+
326
+ tmpl.__micraList = nextList
196
327
  }
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/dom/scan.ts CHANGED
@@ -10,7 +10,8 @@
10
10
  * even *visit* those nodes.
11
11
  * - <template> contents are not visited (browser TreeWalker default).
12
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.
13
+ * its children are processed by each.ts on every render fresh rows
14
+ * are wrapped in a per-row element and scanned via scanComponent.
14
15
  *
15
16
  * Hot-path notes:
16
17
  * - We read `el.attributes` once and switch by suffix. No allocations per
@@ -166,24 +167,3 @@ export function scanComponent(root: Element): ScanIndex {
166
167
  return scan;
167
168
  }
168
169
 
169
- /**
170
- * Scan a DocumentFragment (no-key each clone). Not cached — these fragments
171
- * are temporary and re-cloned every render.
172
- */
173
- export function scanFragment(frag: DocumentFragment): ScanIndex {
174
- const scan = emptyScan();
175
-
176
- const walker = document.createTreeWalker(
177
- frag,
178
- NodeFilter.SHOW_ELEMENT,
179
- NESTED_COMPONENT_FILTER,
180
- );
181
-
182
- let node: Element | null = walker.nextNode() as Element | null;
183
- while (node) {
184
- classify(node, scan);
185
- node = walker.nextNode() as Element | null;
186
- }
187
-
188
- return scan;
189
- }
package/src/index.ts CHANGED
@@ -27,6 +27,9 @@ export type {
27
27
  StateRecord,
28
28
  UnsubFn,
29
29
  EventHandler,
30
+ EventPayload,
31
+ EmitArgs,
32
+ MicraEvents,
30
33
  FetchOptions,
31
34
  ComponentMethods,
32
35
  ComponentBuiltins,
package/src/types.ts CHANGED
@@ -22,6 +22,53 @@ export type UnsubFn = () => void
22
22
  /** Event bus handler. Generic `T` types the payload. */
23
23
  export type EventHandler<T = unknown> = (payload: T) => void
24
24
 
25
+ /**
26
+ * Type-safe event bus registry. Empty by default — augment it via
27
+ * declaration merging to type your application's events.
28
+ *
29
+ * @example
30
+ * declare module 'micra.js' {
31
+ * interface MicraEvents {
32
+ * 'cart:updated': { count: number }
33
+ * 'user:login': { id: number; name: string }
34
+ * 'modal:close': void
35
+ * }
36
+ * }
37
+ *
38
+ * Micra.emit('cart:updated', { count: 3 }) // ✓ typed
39
+ * Micra.emit('cart:updated', { count: '3' }) // ✗ type error
40
+ * Micra.on('user:login', user => user.id) // user: { id, name }
41
+ *
42
+ * Events not present in the interface fall back to `unknown` payload —
43
+ * fully backward-compatible with untyped usage.
44
+ */
45
+ // eslint-disable-next-line @typescript-eslint/no-empty-object-type
46
+ export interface MicraEvents {}
47
+
48
+ /**
49
+ * Resolves the payload type for an event key. For keys registered in
50
+ * `MicraEvents` returns the declared payload; for any other string key
51
+ * returns `unknown` (preserving backward compatibility).
52
+ */
53
+ export type EventPayload<K extends string> = K extends keyof MicraEvents
54
+ ? MicraEvents[K]
55
+ : unknown
56
+
57
+ /**
58
+ * Tuple of arguments passed to `emit` after the event name. When the
59
+ * payload type for a known event includes `undefined` (or the payload is
60
+ * declared as `void`), the argument is optional. For known events with a
61
+ * required payload, the argument is required. Unknown events accept any
62
+ * optional payload (backward compat).
63
+ */
64
+ export type EmitArgs<K extends string> = K extends keyof MicraEvents
65
+ ? [MicraEvents[K]] extends [void]
66
+ ? [payload?: undefined]
67
+ : undefined extends MicraEvents[K]
68
+ ? [payload?: MicraEvents[K]]
69
+ : [payload: MicraEvents[K]]
70
+ : [payload?: unknown]
71
+
25
72
  /** Options for `this.fetch()`. For GET/HEAD extra keys become query params. */
26
73
  export interface FetchOptions {
27
74
  method?: string
@@ -71,10 +118,16 @@ export interface ComponentBuiltins<S extends StateRecord = StateRecord> {
71
118
  prop<T>(name: string, defaultVal: T): T
72
119
  /** Fetch helper: CSRF header, JSON body, query params, typed errors. */
73
120
  fetch(url: string, options?: FetchOptions): Promise<unknown>
74
- /** Publish an event on the global bus. */
75
- emit(event: string, payload?: unknown): void
76
- /** Subscribe to the global bus. Subscription is auto-removed on destroy(). */
77
- on<T = unknown>(event: string, handler: EventHandler<T>): UnsubFn
121
+ /**
122
+ * Publish an event on the global bus.
123
+ * Payload is typed via the `MicraEvents` interface (augmentable).
124
+ */
125
+ emit<K extends string>(event: K, ...args: EmitArgs<K>): void
126
+ /**
127
+ * Subscribe to the global bus. Subscription is auto-removed on destroy().
128
+ * Handler payload is typed via the `MicraEvents` interface (augmentable).
129
+ */
130
+ on<K extends string>(event: K, handler: (payload: EventPayload<K>) => void): UnsubFn
78
131
  }
79
132
 
80
133
  /**
@@ -149,6 +202,9 @@ export interface MicraElement extends HTMLElement {
149
202
  __micraKey?: unknown // keyed-diff key
150
203
  __micraEach?: true // belongs to a no-key each list
151
204
  __micraScan?: ScanIndex // single-pass scan result (cached after 1st render)
205
+ __micraItem?: StateRecord // keyed row: last-rendered item ref (for skip check)
206
+ __micraIndex?: number // keyed row: last-rendered index (for skip check)
207
+ _itemState?: StateRecord // keyed row: reused itemState (avoids Object.create per render)
152
208
  }
153
209
 
154
210
  /**
@@ -166,7 +222,7 @@ export interface TrackedListener {
166
222
  export interface MicraTemplate extends HTMLTemplateElement {
167
223
  __micraMarker?: Comment
168
224
  __micraNodes: Map<unknown, MicraElement>
169
- __micraList: ChildNode[]
225
+ __micraList: MicraElement[]
170
226
  __micraNoKeyWarned?: true
171
227
  }
172
228
 
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)