micra.js 1.0.0 → 1.1.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.
@@ -15,9 +15,11 @@
15
15
 
16
16
  import type {
17
17
  CachedBinding,
18
+ CachedPairBinding,
18
19
  DirectiveCache,
19
20
  InternalInstance,
20
21
  MicraElement,
22
+ MicraTemplate,
21
23
  StateRecord,
22
24
  } from '../types'
23
25
  import { evalExpr, warn } from '../utils/expr'
@@ -31,6 +33,13 @@ function applyText(el: Element, expr: string, state: StateRecord): void {
31
33
  if (el.textContent !== text) el.textContent = text
32
34
  }
33
35
 
36
+ /**
37
+ * data-html — writes the expression value as innerHTML.
38
+ *
39
+ * ⚠️ XSS WARNING: the value is rendered as raw HTML. Never bind untrusted
40
+ * input here — use `data-text` (textContent) instead. See docs/directives.md
41
+ * for the full security model.
42
+ */
34
43
  function applyHtml(el: Element, expr: string, state: StateRecord): void {
35
44
  el.innerHTML = String(evalExpr(expr, state) ?? '')
36
45
  }
@@ -39,13 +48,13 @@ function applyIf(el: Element, expr: string, state: StateRecord): void {
39
48
  (el as HTMLElement).style.display = evalExpr(expr, state) ? '' : 'none'
40
49
  }
41
50
 
42
- function applyBind(el: Element, expr: string, state: StateRecord): void {
43
- for (const pair of expr.split(',')) {
44
- const colonIdx = pair.indexOf(':')
45
- if (colonIdx === -1) continue
46
- const attr = pair.slice(0, colonIdx).trim()
47
- const valExpr = pair.slice(colonIdx + 1).trim()
48
- const val = evalExpr(valExpr, state)
51
+ function applyBind(
52
+ el: Element,
53
+ pairs: ReadonlyArray<readonly [string, string]>,
54
+ state: StateRecord,
55
+ ): void {
56
+ for (const [attr, valExpr] of pairs) {
57
+ const val = evalExpr(valExpr, state)
49
58
 
50
59
  if (attr === 'class') {
51
60
  (el as HTMLElement).className = String(val ?? '')
@@ -76,26 +85,42 @@ function applyBind(el: Element, expr: string, state: StateRecord): void {
76
85
  * @example
77
86
  * <div data-class="active:tab === 'home', hidden:!loaded">
78
87
  */
79
- function applyClass(el: Element, expr: string, state: StateRecord): void {
80
- for (const pair of expr.split(',')) {
81
- const colonIdx = pair.indexOf(':')
82
- if (colonIdx === -1) continue
83
- const cls = pair.slice(0, colonIdx).trim()
84
- const valExpr = pair.slice(colonIdx + 1).trim()
85
- if (!cls) continue
88
+ function applyClass(
89
+ el: Element,
90
+ pairs: ReadonlyArray<readonly [string, string]>,
91
+ state: StateRecord,
92
+ ): void {
93
+ for (const [cls, valExpr] of pairs) {
86
94
  el.classList.toggle(cls, Boolean(evalExpr(valExpr, state)))
87
95
  }
88
96
  }
89
97
 
98
+ /** @internal Parse a comma+colon spec like `href:url, disabled:loading` once. */
99
+ function parsePairs(expr: string): Array<readonly [string, string]> {
100
+ const out: Array<readonly [string, string]> = []
101
+ for (const part of expr.split(',')) {
102
+ const colonIdx = part.indexOf(':')
103
+ if (colonIdx === -1) continue
104
+ const left = part.slice(0, colonIdx).trim()
105
+ const right = part.slice(colonIdx + 1).trim()
106
+ if (!left) continue
107
+ out.push([left, right])
108
+ }
109
+ return out
110
+ }
111
+
90
112
  function applyModel(
91
113
  el: Element,
92
114
  key: string,
93
115
  rawState: StateRecord,
94
116
  ): void {
95
117
  const html = el as HTMLInputElement
96
- if (document.activeElement !== el) {
97
- html.value = rawState[key] == null ? '' : String(rawState[key])
98
- }
118
+ const stateVal = rawState[key]
119
+ const desired = stateVal == null ? '' : String(stateVal)
120
+ // Only write when out of sync. This is a no-op during live typing (the input
121
+ // event already drove state to match el.value) but still propagates
122
+ // programmatic resets such as `this.state.q = ''` on focused inputs.
123
+ if (html.value !== desired) html.value = desired
99
124
  // listener is attached separately in events.ts — this only syncs the value
100
125
  }
101
126
 
@@ -111,14 +136,16 @@ function buildCache(root: Element): DirectiveCache {
111
136
  .filter(el => !el.closest('template'))
112
137
  .map(el => ({ el, expr: el.getAttribute(attr)! }))
113
138
  }
139
+ const pickPairs = (attr: string): CachedPairBinding[] =>
140
+ pick(attr).map(b => ({ ...b, pairs: parsePairs(b.expr) }))
114
141
  return {
115
142
  text: pick('data-text'),
116
143
  html: pick('data-html'),
117
144
  if: pick('data-if'),
118
145
  show: pick('data-show'),
119
- bind: pick('data-bind'),
146
+ bind: pickPairs('data-bind'),
120
147
  model: pick('data-model'),
121
- class: pick('data-class'),
148
+ class: pickPairs('data-class'),
122
149
  }
123
150
  }
124
151
 
@@ -165,9 +192,9 @@ function applyFromList(
165
192
  cache.html.forEach(b => applyHtml(b.el, b.expr, state))
166
193
  cache.if.forEach(b => applyIf(b.el, b.expr, state))
167
194
  cache.show.forEach(b => applyIf(b.el, b.expr, state))
168
- cache.bind.forEach(b => applyBind(b.el, b.expr, state))
195
+ cache.bind.forEach(b => applyBind(b.el, b.pairs, state))
169
196
  cache.model.forEach(b => applyModel(b.el, b.expr.trim(), rawState))
170
- cache.class.forEach(b => applyClass(b.el, b.expr, state))
197
+ cache.class.forEach(b => applyClass(b.el, b.pairs, state))
171
198
  }
172
199
 
173
200
  /** @internal Scan a DocumentFragment (no-key each clone) — returns a DirectiveCache. */
@@ -176,14 +203,16 @@ function buildFragmentList(frag: DocumentFragment): DirectiveCache {
176
203
  queryAll(frag, `[${attr}]`)
177
204
  .filter(el => !el.closest('template'))
178
205
  .map(el => ({ el, expr: el.getAttribute(attr)! }))
206
+ const pickPairs = (attr: string): CachedPairBinding[] =>
207
+ pick(attr).map(b => ({ ...b, pairs: parsePairs(b.expr) }))
179
208
  return {
180
209
  text: pick('data-text'),
181
210
  html: pick('data-html'),
182
211
  if: pick('data-if'),
183
212
  show: pick('data-show'),
184
- bind: pick('data-bind'),
213
+ bind: pickPairs('data-bind'),
185
214
  model: pick('data-model'),
186
- class: pick('data-class'),
215
+ class: pickPairs('data-class'),
187
216
  }
188
217
  }
189
218
 
@@ -197,10 +226,24 @@ function buildFragmentList(frag: DocumentFragment): DirectiveCache {
197
226
  */
198
227
  export function validateDirectives(root: Element): void {
199
228
  queryOwn(root, 'data-each').forEach(el => {
200
- if (!el.hasAttribute('data-key')) {
229
+ const tmpl = el as MicraTemplate
230
+ if (!el.hasAttribute('data-key') && !tmpl.__micraNoKeyWarned) {
231
+ tmpl.__micraNoKeyWarned = true
201
232
  warn(`data-each="${el.getAttribute('data-each')}" has no data-key — keyed diff disabled. Add data-key="id" for better performance.`)
202
233
  }
203
234
  })
235
+
236
+ // data-bind="class:..." replaces className wholesale, which fights with
237
+ // data-class on the same element. Warn so the developer picks one.
238
+ const bindEls = queryOwn(root, 'data-bind')
239
+ if ((root as HTMLElement).hasAttribute?.('data-bind') && !bindEls.includes(root)) bindEls.unshift(root)
240
+ for (const el of bindEls) {
241
+ const spec = el.getAttribute('data-bind') ?? ''
242
+ const hasClassBind = spec.split(',').some(p => p.trim().split(':')[0]?.trim() === 'class')
243
+ if (hasClassBind && el.hasAttribute('data-class')) {
244
+ warn(`element has both data-bind="class:..." and data-class — they fight on every render. Use one.`)
245
+ }
246
+ }
204
247
  }
205
248
 
206
249
  // Re-export warn for use in other modules
package/src/dom/each.ts CHANGED
@@ -86,10 +86,19 @@ function renderKeyed<S extends StateRecord>(
86
86
  ): void {
87
87
  const nextKeys = new Set<unknown>()
88
88
  const nextNodes: MicraElement[] = []
89
+ let warnedNullKey = false
90
+ let warnedDupKey = false
89
91
 
90
92
  for (const [index, item] of items.entries()) {
91
93
  const key = item[keyAttr]
92
- if (key == null) warn(`data-key="${keyAttr}" is null/undefined on item at index ${index}`)
94
+ if (key == null && !warnedNullKey) {
95
+ warn(`data-key="${keyAttr}" is null/undefined on item at index ${index}`)
96
+ warnedNullKey = true
97
+ }
98
+ if (nextKeys.has(key) && !warnedDupKey) {
99
+ warn(`data-key="${keyAttr}" has duplicate value ${JSON.stringify(key)} — rows will collide`)
100
+ warnedDupKey = true
101
+ }
93
102
  nextKeys.add(key)
94
103
 
95
104
  let node = keyMap.get(key) as MicraElement | undefined
package/src/dom/events.ts CHANGED
@@ -3,16 +3,29 @@
3
3
  *
4
4
  * Responsibilities:
5
5
  * - Bind `data-on="event:method"` listeners (once per element)
6
- * - Bind `@event="method"` shorthand (scanned once per component root)
6
+ * - Bind `@event="method"` shorthand (once per element)
7
+ * - Bind `data-model` two-way input listeners (once per element)
7
8
  *
8
- * LLM NOTE: Listeners are attached exactly once. The `__micraEvents` and
9
- * `__micraAtScanned` flags prevent duplicate bindings on re-renders.
9
+ * LLM NOTE: Every listener attached here is also recorded in
10
+ * instance.__micraListeners so destroy() can remove it cleanly.
11
+ * Re-render skips already-bound elements via per-element __micra* flags.
10
12
  */
11
13
 
12
14
  import type { InternalInstance, MicraElement, StateRecord } from '../types'
13
- import { evalExpr, warn } from '../utils/expr'
15
+ import { warn } from '../utils/expr'
14
16
  import { queryOwn, queryAll } from './query'
15
17
 
18
+ /** @internal Attach a DOM listener and track it on the instance for destroy(). */
19
+ function track<S extends StateRecord>(
20
+ instance: InternalInstance<S>,
21
+ el: Element,
22
+ type: string,
23
+ fn: EventListener,
24
+ ): void {
25
+ el.addEventListener(type, fn)
26
+ ;(instance.__micraListeners ??= []).push({ el, type, fn })
27
+ }
28
+
16
29
  // ── data-on ───────────────────────────────────────────────────────────────────
17
30
 
18
31
  /**
@@ -50,7 +63,7 @@ export function bindDataOn<S extends StateRecord>(
50
63
 
51
64
  const [evName, ...mods] = evSpec.split('.')
52
65
 
53
- el.addEventListener(evName!, (e: Event) => {
66
+ track(instance, el, evName!, (e: Event) => {
54
67
  if (mods.includes('prevent')) e.preventDefault()
55
68
  if (mods.includes('stop')) e.stopPropagation()
56
69
  if (mods.includes('self') && e.target !== el) return
@@ -67,7 +80,7 @@ export function bindDataOn<S extends StateRecord>(
67
80
 
68
81
  /**
69
82
  * Bind `@event="method"` shorthand attributes (Stimulus-style).
70
- * Scanned once per component root (guarded by `__micraAtScanned`).
83
+ * Bound once per element via `__micraAtBound` re-renders are no-ops.
71
84
  * Supports the same modifiers as data-on: `@click.prevent="submit"`.
72
85
  *
73
86
  * @example
@@ -78,18 +91,25 @@ export function bindAtEvents<S extends StateRecord>(
78
91
  root: Element,
79
92
  instance: InternalInstance<S>,
80
93
  ): void {
81
- const mRoot = root as MicraElement
82
- if (mRoot.__micraAtScanned) return
83
- mRoot.__micraAtScanned = true
94
+ const isFragment = root.nodeType === 11
95
+ const all = isFragment
96
+ ? queryAll(root as unknown as ParentNode, '*')
97
+ : queryAll(root, '*')
98
+
99
+ // Include root itself for the regular-element case
100
+ if (!isFragment && !all.includes(root)) all.unshift(root)
84
101
 
85
- const all = queryAll(root, '*')
86
102
  for (const el of all) {
103
+ const mEl = el as MicraElement
104
+ if (mEl.__micraAtBound) continue
105
+
106
+ let bound = false
87
107
  for (const attr of Array.from(el.attributes)) {
88
108
  if (!attr.name.startsWith('@')) continue
89
109
  const [evSpec, ...rest] = attr.name.slice(1).split('.')
90
110
  const method = attr.value.trim()
91
111
 
92
- el.addEventListener(evSpec!, (e: Event) => {
112
+ track(instance, el, evSpec!, (e: Event) => {
93
113
  if (rest.includes('prevent')) e.preventDefault()
94
114
  if (rest.includes('stop')) e.stopPropagation()
95
115
  if (rest.includes('self') && e.target !== el) return
@@ -98,7 +118,9 @@ export function bindAtEvents<S extends StateRecord>(
98
118
  if (typeof fn === 'function') (fn as (e: Event) => void).call(instance, e)
99
119
  else warn(`method "${method}" not found`)
100
120
  })
121
+ bound = true
101
122
  }
123
+ if (bound) mEl.__micraAtBound = true
102
124
  }
103
125
  }
104
126
 
@@ -108,6 +130,9 @@ export function bindAtEvents<S extends StateRecord>(
108
130
  * Two-way binding: `data-model="key"` wires <input>/<select>/<textarea>
109
131
  * to `state[key]`. Binding is attached once per element.
110
132
  *
133
+ * Numeric inputs (`type="number"` / `type="range"`) write numbers, not strings.
134
+ * Checkbox inputs write booleans. Everything else writes strings.
135
+ *
111
136
  * @example
112
137
  * <input data-model="search"> // updates state.search on every keystroke
113
138
  * <select data-model="sortBy"> // updates state.sortBy on change
@@ -128,18 +153,23 @@ export function bindModels<S extends StateRecord>(
128
153
 
129
154
  const key = (el as HTMLInputElement).dataset['model'] ?? ''
130
155
  const tag = el.tagName
156
+ const inputEl = el as HTMLInputElement
157
+ const inputType = inputEl.type
131
158
 
132
159
  const update = () => {
133
- const val = tag === 'INPUT' && (el as HTMLInputElement).type === 'checkbox'
134
- ? (el as HTMLInputElement).checked
135
- : (el as HTMLInputElement).value
160
+ let val: unknown
161
+ if (tag === 'INPUT' && inputType === 'checkbox') {
162
+ val = inputEl.checked
163
+ } else if (tag === 'INPUT' && (inputType === 'number' || inputType === 'range')) {
164
+ // Empty string → NaN; preserve raw empty as null so state stays "unfilled"
165
+ val = inputEl.value === '' ? null : inputEl.valueAsNumber
166
+ } else {
167
+ val = inputEl.value
168
+ }
136
169
  ;(instance.state as StateRecord)[key] = val
137
170
  }
138
171
 
139
- el.addEventListener(tag === 'SELECT' || (el as HTMLInputElement).type === 'radio'
140
- ? 'change'
141
- : 'input',
142
- update,
143
- )
172
+ const evType = tag === 'SELECT' || inputType === 'radio' ? 'change' : 'input'
173
+ track(instance, el, evType, update)
144
174
  }
145
175
  }
package/src/types.ts CHANGED
@@ -105,12 +105,21 @@ export type ComponentDefinition<S extends StateRecord = StateRecord> = {
105
105
  export interface MicraElement extends HTMLElement {
106
106
  __micraModel?: true // data-model listener bound
107
107
  __micraEvents?: true // data-on listeners bound
108
- __micraAtScanned?: true // @event shorthand scanned (set on component root)
108
+ __micraAtBound?: true // @event shorthand bound (per-element)
109
109
  __micraKey?: unknown // keyed-diff key
110
110
  __micraEach?: true // belongs to a no-key each list
111
111
  __micraCache?: DirectiveCache // cached directive scan result
112
112
  }
113
113
 
114
+ /**
115
+ * @internal A DOM listener tracked for cleanup on destroy().
116
+ */
117
+ export interface TrackedListener {
118
+ el: Element
119
+ type: string
120
+ fn: EventListener
121
+ }
122
+
114
123
  /**
115
124
  * @internal Extended HTMLTemplateElement with keyed-diff state.
116
125
  */
@@ -118,6 +127,7 @@ export interface MicraTemplate extends HTMLTemplateElement {
118
127
  __micraMarker?: Comment
119
128
  __micraNodes: Map<unknown, MicraElement>
120
129
  __micraList: ChildNode[]
130
+ __micraNoKeyWarned?: true
121
131
  }
122
132
 
123
133
  /**
@@ -128,6 +138,17 @@ export interface CachedBinding {
128
138
  expr: string
129
139
  }
130
140
 
141
+ /**
142
+ * @internal Per-element directive binding with pre-parsed pairs.
143
+ * Used by `data-bind` and `data-class` — both share the
144
+ * `name:expression[, name:expression…]` syntax.
145
+ */
146
+ export interface CachedPairBinding {
147
+ el: Element
148
+ expr: string
149
+ pairs: ReadonlyArray<readonly [string, string]>
150
+ }
151
+
131
152
  /**
132
153
  * @internal Directive scan result — built once per Element, reused every render.
133
154
  * This is the core of the performance optimization.
@@ -140,9 +161,9 @@ export interface DirectiveCache {
140
161
  html: CachedBinding[]
141
162
  if: CachedBinding[]
142
163
  show: CachedBinding[]
143
- bind: CachedBinding[]
164
+ bind: CachedPairBinding[]
144
165
  model: CachedBinding[]
145
- class: CachedBinding[]
166
+ class: CachedPairBinding[]
146
167
  }
147
168
 
148
169
  /**
@@ -153,5 +174,7 @@ export interface DirectiveCache {
153
174
  export interface InternalInstance<S extends StateRecord = StateRecord>
154
175
  extends ComponentInstance<S> {
155
176
  __micraSubs?: UnsubFn[]
177
+ __micraListeners?: TrackedListener[]
178
+ __micraDestroyed?: true
156
179
  [key: string]: unknown
157
180
  }
package/src/utils/expr.ts CHANGED
@@ -5,9 +5,20 @@
5
5
  * - Compile expression strings into cached functions
6
6
  * - Evaluate them against a state object
7
7
  * - Fast-path for simple property lookups
8
+ * - Shadow non-state identifiers so directive expressions cannot reach
9
+ * globals like `window`, `fetch`, `constructor`, etc. A small whitelist
10
+ * of utility globals (Math, JSON, Date, ...) remains accessible.
8
11
  *
9
12
  * LLM NOTE: This module is PURE. It does not touch the DOM or mutate state.
10
- * All side effects are isolated to console.warn on invalid expressions.
13
+ *
14
+ * Security model:
15
+ * Directive expressions are JavaScript — they are compiled via `new Function`
16
+ * and run with full JS capability except that bare identifiers must resolve
17
+ * to either a state key, a component instance method, or one of
18
+ * ALLOWED_GLOBALS. This blocks the `constructor.constructor("...")()` chain
19
+ * and accidental access to `window` / `document` / `fetch`. It does NOT
20
+ * sandbox method calls — if a component method itself touches `window`,
21
+ * that still works. Treat directive templates as trusted code regardless.
11
22
  */
12
23
 
13
24
  import type { StateRecord } from '../types'
@@ -18,12 +29,105 @@ import type { StateRecord } from '../types'
18
29
 
19
30
  // LLM NOTE: exprCache is module-level (shared across all components).
20
31
  // This is intentional — most apps reuse the same expressions.
21
- const exprCache = new Map<string, (state: StateRecord) => unknown>()
32
+ type Compiled = (state: object, safe: object) => unknown
33
+ const exprCache = new Map<string, Compiled>()
34
+ // Expressions whose runtime error we have already warned about. Prevents log spam
35
+ // when the same `data-text="item.naame"` typo fires every render.
36
+ const warnedRuntime = new Set<string>()
22
37
 
23
38
  // Simple identifier or dot-path: "count", "user.name", "item.email"
24
39
  // Matches: letter/$/_ followed by word chars, optionally with .property chains
25
40
  const SIMPLE_PATH = /^[a-zA-Z_$][a-zA-Z0-9_$]*(\.[a-zA-Z_$][a-zA-Z0-9_$]*)*$/
26
41
 
42
+ // ── Safe scope ────────────────────────────────────────────────────────────────
43
+
44
+ /**
45
+ * Globals reachable from directive expressions. Anything else (window, fetch,
46
+ * constructor, eval, ...) is shadowed by SAFE_OUTER and resolves to undefined.
47
+ */
48
+ const ALLOWED_GLOBALS = new Set<string>([
49
+ 'Math', 'JSON', 'Date', 'String', 'Number', 'Boolean', 'Array', 'Object',
50
+ 'parseInt', 'parseFloat', 'isNaN', 'isFinite', 'NaN', 'Infinity', 'undefined',
51
+ ])
52
+
53
+ /**
54
+ * Outer `with()` scope. Its `has` trap claims every non-whitelisted identifier
55
+ * is "in scope" so the JS engine resolves the read on this Proxy (which returns
56
+ * undefined) instead of walking up to the global object. Whitelisted names fall
57
+ * through to globalThis.
58
+ */
59
+ // Sentinel parameter names used by the compiled function. SAFE_OUTER must NOT
60
+ // shadow them, or `with($s)` would resolve to `undefined` via SAFE_OUTER.
61
+ const PARAM_S = '$s'
62
+ const PARAM_SAFE = '$safe'
63
+
64
+ const SAFE_OUTER: object = new Proxy(Object.create(null) as object, {
65
+ has(_target, key): boolean {
66
+ if (typeof key !== 'string') return false
67
+ if (key === PARAM_S || key === PARAM_SAFE) return false
68
+ return !ALLOWED_GLOBALS.has(key)
69
+ },
70
+ get(): undefined {
71
+ return undefined
72
+ },
73
+ })
74
+
75
+ /**
76
+ * @internal Per-state safe wrappers — one per source state object. WeakMap so
77
+ * short-lived itemStates get GC'd with their wrappers.
78
+ */
79
+ const safeWrapCache = new WeakMap<object, object>()
80
+
81
+ /**
82
+ * @internal Pre-computed names that live on `Object.prototype`
83
+ * (constructor, toString, hasOwnProperty, ...). Used by safeStateHas to detect
84
+ * built-in keys without re-walking the chain on every call.
85
+ */
86
+ const OBJ_PROTO_KEYS = new Set<string>(Object.getOwnPropertyNames(Object.prototype))
87
+
88
+ /**
89
+ * Wrap a state object so its `has` trap reports only "real" keys — own
90
+ * properties or keys reachable up to (but not including) `Object.prototype`.
91
+ * This blocks `'constructor' in state` from leaking the prototype.
92
+ */
93
+ function safeStateWrap(state: object): object {
94
+ const cached = safeWrapCache.get(state)
95
+ if (cached) return cached
96
+ const wrapped = new Proxy(state, {
97
+ has(target, key) {
98
+ return safeStateHas(target, key)
99
+ },
100
+ get(target, key) {
101
+ return Reflect.get(target, key)
102
+ },
103
+ })
104
+ safeWrapCache.set(state, wrapped)
105
+ return wrapped
106
+ }
107
+
108
+ /**
109
+ * Return true iff `key` is reachable on `state` without walking into
110
+ * `Object.prototype`. Works for plain objects, prototype-chained objects, and
111
+ * Proxies with their own `has` trap.
112
+ */
113
+ function safeStateHas(state: object, key: PropertyKey): boolean {
114
+ if (typeof key !== 'string') return false
115
+ if (!Reflect.has(state, key)) return false
116
+ // Identifiers that are NOT on Object.prototype are always safe — accept them
117
+ // immediately without walking the chain.
118
+ if (!OBJ_PROTO_KEYS.has(key)) return true
119
+ // Built-in Object.prototype names (constructor, toString, hasOwnProperty, ...)
120
+ // are only accepted when they have been explicitly placed on the state chain.
121
+ let obj: object | null = state
122
+ while (obj && obj !== Object.prototype) {
123
+ if (Object.prototype.hasOwnProperty.call(obj, key)) return true
124
+ obj = Object.getPrototypeOf(obj) as object | null
125
+ }
126
+ return false
127
+ }
128
+
129
+ // ── evalExpr ──────────────────────────────────────────────────────────────────
130
+
27
131
  /**
28
132
  * Evaluate a JS expression string against a state object.
29
133
  *
@@ -37,9 +141,12 @@ const SIMPLE_PATH = /^[a-zA-Z_$][a-zA-Z0-9_$]*(\.[a-zA-Z_$][a-zA-Z0-9_$]*)*$/
37
141
  * evalExpr('price * qty', { price: 9.99, qty: 3 }) // → 29.97
38
142
  */
39
143
  export function evalExpr(expr: string, state: StateRecord): unknown {
40
- // Fast-path: simple property access — no Function() needed
144
+ // Fast-path: simple property access — no Function() needed.
145
+ // Still guarded so bare access to Object.prototype names returns undefined.
41
146
  if (SIMPLE_PATH.test(expr)) {
42
- return expr.split('.').reduce<unknown>((obj, key) =>
147
+ const parts = expr.split('.')
148
+ if (!safeStateHas(state, parts[0]!)) return undefined
149
+ return parts.reduce<unknown>((obj, key) =>
43
150
  obj != null ? (obj as StateRecord)[key] : undefined,
44
151
  state,
45
152
  )
@@ -47,9 +154,10 @@ export function evalExpr(expr: string, state: StateRecord): unknown {
47
154
 
48
155
  if (!exprCache.has(expr)) {
49
156
  try {
157
+ // Two with() statements: $s wins for state keys; $safe shadows globals.
50
158
  exprCache.set(
51
159
  expr,
52
- new Function('$s', `with($s){return (${expr})}`) as (s: StateRecord) => unknown,
160
+ new Function('$s', '$safe', `with($safe){with($s){return (${expr})}}`) as Compiled,
53
161
  )
54
162
  } catch {
55
163
  warn(`invalid expression "${expr}"`)
@@ -58,8 +166,12 @@ export function evalExpr(expr: string, state: StateRecord): unknown {
58
166
  }
59
167
 
60
168
  try {
61
- return exprCache.get(expr)!(state)
62
- } catch {
169
+ return exprCache.get(expr)!(safeStateWrap(state), SAFE_OUTER)
170
+ } catch (e) {
171
+ if (!warnedRuntime.has(expr)) {
172
+ warnedRuntime.add(expr)
173
+ warn(`runtime error in "${expr}": ${(e as Error).message}`)
174
+ }
63
175
  return undefined
64
176
  }
65
177
  }