@ape-egg/vibe 4.0.0 → 4.1.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/runtime/state.js CHANGED
@@ -1,46 +1,23 @@
1
1
  import { recordRead, recordAbsentRead, isTracking } from './tracking.js';
2
2
 
3
- // Track which objects are already proxied to avoid double-wrapping
4
3
  const proxyCache = new WeakMap();
5
4
 
6
- // Reading this symbol off any of our reactive proxies returns its raw
7
- // (unproxied) target; on anything else it's undefined. `unwrap` uses it to
8
- // collapse a proxy back to its raw object. This keeps object identity stable
9
- // across mutations: array methods (splice/push) and assignments read an element
10
- // — which `get` hands back as a proxy — and write it back into the tree; if we
11
- // stored the proxy, the next read would wrap it AGAIN, minting a fresh proxy
12
- // identity for the same underlying object. That would break reference identity
13
- // (a loop item would no longer be `=== $.arr[i]` after a splice) and make change
14
- // detection fire forever (a stored proxy never `===` the raw it's compared to).
15
5
  const RAW = Symbol('vibeRaw');
16
6
 
17
7
  const unwrap = (value) =>
18
8
  value !== null && typeof value === 'object' && value[RAW] !== undefined ? value[RAW] : value;
19
9
 
20
- // Write a property onto the proxy's raw target without waking the reactive
21
- // pipeline — no changedProps, no flush. For writes that reactivity can prove
22
- // irrelevant: a freshly generated component id has no live bindings yet (its
23
- // subtree hydrates later in the same batch by reading state directly), so
24
- // flushing on registration re-walks the whole live tree for nothing. Reads
25
- // through the proxy see the value immediately; later writes through the
26
- // proxy flush normally.
27
10
  export const silentSet = (proxy, prop, value) => {
28
11
  const raw = proxy !== null && typeof proxy === 'object' ? (proxy[RAW] ?? proxy) : proxy;
29
12
  raw[prop] = unwrap(value);
30
13
  };
31
14
 
32
- // Mark a batch of already-written keys changed and schedule one flush — the
33
- // commit half of silentSet. A mount registers N component states silently as
34
- // its scripts settle (staggered by module fetches), then notifies ONCE, so
35
- // whole-`$` observers (`@[Object.keys($)...]`) still see every fresh key
36
- // with a single tree walk instead of N.
37
15
  export const notifyChanged = (props) => {
38
16
  if (!props || props.length === 0) return;
39
17
  for (const prop of props) changedProps.add(prop);
40
18
  scheduleFlush();
41
19
  };
42
20
 
43
- // Batching: collect mutations and flush once per microtask
44
21
  let pendingFlush = false;
45
22
  let flushCallback = null;
46
23
  const changedProps = new Set();
@@ -57,54 +34,36 @@ const scheduleFlush = () => {
57
34
  }
58
35
  };
59
36
 
60
- // Deep proxy: recursively wrap nested objects and arrays
61
37
  const createDeepProxy = (target, rerender, rootState = null, rootProp = null) => {
62
- // Never wrap one of our own proxies — collapse to its raw target so the cache
63
- // returns the single canonical proxy. Covers a proxy that slipped into the
64
- // tree nested inside an assigned object literal (set only unwraps the
65
- // top-level value), which would otherwise double-wrap on read.
66
38
  target = unwrap(target);
67
39
 
68
- // For root level, rootState is the target itself
69
40
  if (rootState === null) {
70
41
  rootState = target;
71
42
  flushCallback = typeof rerender === 'function' ? (props) => rerender(props) : null;
72
43
  }
73
44
 
74
- // Check cache first
75
45
  if (proxyCache.has(target)) {
76
46
  return proxyCache.get(target);
77
47
  }
78
48
 
79
49
  const proxy = new Proxy(target, {
80
50
  set(obj, prop, value) {
81
- // Never store one of our proxies in the raw tree — store its raw target,
82
- // so element identity stays stable across mutations (see RAW comment).
83
51
  value = unwrap(value);
84
52
  const oldValue = obj[prop];
85
53
 
86
- // Only trigger rerender if value actually changed
87
54
  if (oldValue !== value) {
88
55
  const ref = Reflect.set(obj, prop, value);
89
56
 
90
- // Track which root-level property changed (for selective extraction)
91
57
  changedProps.add(rootProp || prop);
92
58
  scheduleFlush();
93
59
 
94
60
  return ref;
95
61
  }
96
62
 
97
- // No change, just set
98
63
  return Reflect.set(obj, prop, value);
99
64
  },
100
65
 
101
66
  deleteProperty(obj, prop) {
102
- // Without notifying changedProps + scheduleFlush, `delete $.foo` is
103
- // invisible to the reactive pipeline — bindings depending on `foo` (or
104
- // on `Object.keys($)`) keep showing the deleted value. Critical for
105
- // component state cleanup: releaseOrphanedComponentState calls
106
- // `delete window.$[id]` and downstream consumers (e.g. a state
107
- // inspector iterating root keys) need to re-render.
108
67
  if (!(prop in obj)) return Reflect.deleteProperty(obj, prop);
109
68
 
110
69
  const ref = Reflect.deleteProperty(obj, prop);
@@ -116,21 +75,8 @@ const createDeepProxy = (target, rerender, rootState = null, rootProp = null) =>
116
75
  },
117
76
 
118
77
  get(target, prop) {
119
- // Expose the raw target so `unwrap` (and external identity checks) can
120
- // recover the unproxied object from any of our proxies.
121
78
  if (prop === RAW) return target;
122
79
 
123
- // Subscription tracking: a read inside an open tracking window records
124
- // the ROOT key it descended from. This is what makes helper bodies
125
- // truthful dependencies — `isPremium()` reading `$.premiumUntil` via
126
- // closure lands here mid-evaluation. One null check when inactive.
127
- // A root-level read of a key the state does NOT have is an ABSENT read
128
- // — same discipline as dollarFor's snapshot wrapper. Counting it live
129
- // denies the subscriber the always-bucket and indexes it under a key
130
- // that may never change: resolvePath fed a whole call expression
131
- // (`literalRangeCells(2, 6)` — the eval-failed fallback treats it as a
132
- // path) records one garbage "live" dep and the iteration is never
133
- // re-selected (the game's "# Teams" BoxRange stranding empty).
134
80
  if (rootProp === null && isTracking() && typeof prop === 'string' && !(prop in target)) {
135
81
  recordAbsentRead(prop);
136
82
  } else {
@@ -139,17 +85,10 @@ const createDeepProxy = (target, rerender, rootState = null, rootProp = null) =>
139
85
 
140
86
  const value = Reflect.get(target, prop);
141
87
 
142
- // Don't proxy non-objects, functions, null, or Promises.
143
- // Promises aren't reactive state; wrapping them would also violate
144
- // the Proxy invariant when exposed as non-writable/non-configurable
145
- // properties (e.g. $.ready).
146
88
  if (value === null || typeof value !== 'object' || typeof value === 'function' || value instanceof Promise) {
147
89
  return value;
148
90
  }
149
91
 
150
- // Recursively wrap nested objects/arrays
151
- // If rootProp is null (we're at root level), set it to current prop
152
- // Otherwise keep passing down the same rootProp
153
92
  const topProp = rootProp === null ? prop : rootProp;
154
93
  return createDeepProxy(value, rerender, rootState, topProp);
155
94
  },
@@ -1,20 +1,3 @@
1
- // Event-handler `this.` resolution.
2
- //
3
- // parse.js rewrites `this.` in on* handlers inside a component scope to
4
- // `$this(this).` — this module is that `$this`. The returned proxy decides at
5
- // FIRE time, per access: a key declared in the component's state routes to
6
- // the state bucket; anything else routes to the native element (functions
7
- // bound so DOM methods keep their receiver, state methods left raw so `this`
8
- // inside them stays the proxy and nested writes stay reactive).
9
- //
10
- // The decision must happen at fire time, not parse time: compiled pages
11
- // register component state AFTER the initial parse (boot runs
12
- // executeCompiledComponentScripts post-parse, pre-hydrate), so a parse-time
13
- // key check would see empty buckets and mis-route every handler.
14
- //
15
- // Same delivery pattern as loop-scope's `$scope`: a global resolver keeps the
16
- // handler a readable native `on*` attribute.
17
-
18
1
  import { findComponentIdForElement } from './utils.js';
19
2
 
20
3
  const bucketFor = (element) => {
@@ -1,42 +1,10 @@
1
- // Auto-tracked reactive subscriptions — the knowledge half of the update
2
- // engine. Each reactive obligation (a text-binding span, an attribute or name
3
- // binding, a conditional, an iteration) is a SUBSCRIBER; during its
4
- // evaluation a tracking window is open and every genuine state read that
5
- // happens inside it — proxy get traps, evalInScope's state-first parameter
6
- // resolution, `$.key` reads on snapshots, scoped-state global fallthrough —
7
- // records the ROOT state key into the window. Closing the window commits the
8
- // read set to a reverse index (rootKey → Set<subscriber>), replacing the
9
- // subscriber's previous deps wholesale so conditional branches self-heal
10
- // (`a ? $.x : $.y` re-subscribes as it flips). A write then answers "who
11
- // cares?" from the index instead of walking the world.
12
- //
13
- // Granularity is deliberately root-key only — the same granularity as
14
- // state.js's changedProps. Recording is active ONLY inside a window: app
15
- // code (handlers, boot scripts, intervals) never tracks, and the inactive
16
- // path is a single null check.
17
-
18
- // Stack of open windows — nesting is tolerated (a subscriber's eval that
19
- // transitively evaluates another registers each read against the window that
20
- // was open when it happened), though engine call sites keep windows tight
21
- // around single evaluations.
22
1
  let windows = [];
23
2
  let active = null;
24
3
 
25
- // rootKey → Set<subscriber>
26
4
  const keyIndex = new Map();
27
- // Conservative bucket: subscribers whose evaluation read NO tracked key at
28
- // all (module-level caches, Date.now(), Object.keys($) enumeration). Can't
29
- // prove what they depend on, so they re-evaluate on every flush — the same
30
- // contract the walk's "no known state key ⇒ affected" check gives today.
31
5
  const alwaysSubs = new Set();
32
- // subscriber → its committed dep Set (empty Set = scope-only, owned by its
33
- // iteration's diff; absent = never tracked/unsubscribed)
34
6
  const subDeps = new Map();
35
7
 
36
- // Open a window for `sub`. `skipKeys` names the scope-alias keys of the
37
- // evaluation state (loop `item`/`index` overlays) — reads of those are the
38
- // row's own scope, owned by the iteration's diff lifecycle, so they mark the
39
- // window scope-touched instead of subscribing.
40
8
  export const beginTracking = (sub, skipKeys = null) => {
41
9
  active = { sub, skipKeys, reads: new Set(), absent: new Set(), sawScope: false };
42
10
  windows.push(active);
@@ -58,23 +26,13 @@ const dropFromBuckets = (sub, deps) => {
58
26
  }
59
27
  };
60
28
 
61
-
62
29
  const commit = ({ sub, reads, absent, sawScope }) => {
63
30
  const prev = subDeps.get(sub);
64
31
  if (prev) dropFromBuckets(sub, prev);
65
32
  alwaysSubs.delete(sub);
66
33
 
67
- // Only LIVE reads count toward "this subscriber has provable deps" — an
68
- // eval that read no present state key keeps the walk's re-evaluate-on-any-
69
- // flush contract even when absent identifiers were seen (they may be plain
70
- // globals forever). Scope-only evals stay out of the always-bucket: the
71
- // row's iteration is subscribed and owns the row.
72
34
  if (reads.size === 0 && !sawScope) alwaysSubs.add(sub);
73
35
 
74
- // Absent identifiers still index: if the key is ever CREATED, that flush
75
- // must select this subscriber — the walk got this by token-matching against
76
- // the NEW state's key set every cycle. Same for delete-then-recreate: the
77
- // post-delete re-eval keeps the key as an absent dep.
78
36
  const deps = absent.size === 0 ? reads : new Set([...reads, ...absent]);
79
37
  for (const key of deps) {
80
38
  let bucket = keyIndex.get(key);
@@ -84,14 +42,8 @@ const commit = ({ sub, reads, absent, sawScope }) => {
84
42
  subDeps.set(sub, deps);
85
43
  };
86
44
 
87
- // Whether a tracking window is open — state.js's get trap consults this so
88
- // its own-key presence test (recordRead vs recordAbsentRead) only runs while
89
- // an evaluation is actually being tracked.
90
45
  export const isTracking = () => active !== null;
91
46
 
92
- // The single recording entry point — called from state.js's get trap,
93
- // evalInScope's parameter resolution, dollarFor's snapshot wrapper, and
94
- // createScopedState's global fallthrough. One null check when inactive.
95
47
  export const recordRead = (key) => {
96
48
  if (active === null || typeof key !== 'string') return;
97
49
  if (active.skipKeys !== null && active.skipKeys.has(key)) {
@@ -101,10 +53,6 @@ export const recordRead = (key) => {
101
53
  active.reads.add(key);
102
54
  };
103
55
 
104
- // A read that MISSED the state (identifier absent at evaluation time —
105
- // evalInScope's global fallback, dollarFor's non-key miss). Indexed so the
106
- // key's later creation dispatches this subscriber, but not counted as a live
107
- // dep — see commit.
108
56
  export const recordAbsentRead = (key) => {
109
57
  if (active === null || typeof key !== 'string') return;
110
58
  if (active.skipKeys !== null && active.skipKeys.has(key)) {
@@ -114,8 +62,6 @@ export const recordAbsentRead = (key) => {
114
62
  active.absent.add(key);
115
63
  };
116
64
 
117
- // Union of subscribers for a set of changed root keys, plus the conservative
118
- // always-bucket. Callers filter liveness (anchor.isConnected) at use time.
119
65
  export const subscribersOf = (keys) => {
120
66
  const out = new Set(alwaysSubs);
121
67
  for (const key of keys) {
@@ -127,10 +73,6 @@ export const subscribersOf = (keys) => {
127
73
 
128
74
  export const depsOf = (sub) => subDeps.get(sub);
129
75
 
130
- // Force a subscriber into the conservative always-bucket. Batch/compiled
131
- // iterations use this: their row HTML is produced by a compiled function
132
- // closing over every state key, so "re-evaluate on any change" is exactly
133
- // the walk's contract for them (the identical-HTML guard absorbs no-ops).
134
76
  export const markAlways = (sub) => {
135
77
  const prev = subDeps.get(sub);
136
78
  if (prev) dropFromBuckets(sub, prev);
@@ -145,19 +87,12 @@ export const unsubscribe = (sub) => {
145
87
  subDeps.delete(sub);
146
88
  };
147
89
 
148
- // Drop every subscriber whose anchor node has left the document. Called on
149
- // teardown batches (mutation removals, iteration row release, branch
150
- // unmount) — O(subscribers), same cost class as the manifest sweep.
151
90
  export const pruneDisconnected = () => {
152
91
  for (const sub of subDeps.keys()) {
153
92
  if (sub.anchor && !sub.anchor.isConnected) unsubscribe(sub);
154
93
  }
155
94
  };
156
95
 
157
- // Called at the top of a state flush with the changed root keys. Returns the
158
- // dirty set — every live subscriber of those keys plus the conservative
159
- // always-bucket. Dead subscribers found here unsubscribe on the spot (the
160
- // lazy self-prune).
161
96
  export const beginFlush = (changedKeys) => {
162
97
  const dirty = new Set();
163
98
  for (const sub of subscribersOf(changedKeys)) {
package/runtime/utils.js CHANGED
@@ -2,22 +2,10 @@ import { THIS_PROP_REGEX } from './constants.js';
2
2
  import { recordRead, recordAbsentRead } from './tracking.js';
3
3
  import { reportEvalError } from './debug.js';
4
4
 
5
- // Fast incrementing counter instead of expensive random hash
6
5
  let hashCounter = 0;
7
6
  export const hash = () => `_${hashCounter++}`;
8
7
 
9
- // Scoped-state proxy -> its precomputed key list. The affected-walk needs the
10
- // key set per tree node on every frame; calling Object.keys() on a scoped-state
11
- // proxy fires its getOwnPropertyDescriptor trap for every key — a measurable
12
- // combat hot spot. createScopedState records the keys here via
13
- // rememberScopedKeys; ownKeysOf reads them, falling back to Object.keys for
14
- // plain (non-scoped) states.
15
8
  const scopedKeys = new WeakMap();
16
- // Scoped-state proxy -> its local overlay (loop aliases item/index + any parent
17
- // aliases). affected's iteration descent rebuilds a plain merged snapshot per
18
- // instance; spreading the proxy to recover the aliases fires its traps over
19
- // every global key. Recording the small overlay lets the descent do a cheap
20
- // plain merge (`{...currentGlobals, ...overlay}`) instead.
21
9
  const scopedOverlay = new WeakMap();
22
10
  export const rememberScopedKeys = (state, keys, overlay) => {
23
11
  scopedKeys.set(state, keys);
@@ -27,16 +15,11 @@ export const rememberScopedKeys = (state, keys, overlay) => {
27
15
  export const ownKeysOf = (state) => scopedKeys.get(state) || Object.keys(state);
28
16
  export const scopedOverlayOf = (state) => scopedOverlay.get(state);
29
17
 
30
- // Register just the overlay for a merged PLAIN row snapshot (affected's
31
- // iteration descent builds those without going through createScopedState).
32
- // Tracking uses it to tell alias reads from global reads.
33
18
  export const rememberOverlay = (state, overlay) => {
34
19
  if (overlay) scopedOverlay.set(state, overlay);
35
20
  return state;
36
21
  };
37
22
 
38
- // The overlay's key set, cached per state object — the skipKeys a tracking
39
- // window needs so loop-alias reads don't subscribe.
40
23
  const overlayKeySets = new WeakMap();
41
24
  export const overlayKeysOf = (state) => {
42
25
  if (state === null || typeof state !== 'object') return null;
@@ -50,33 +33,11 @@ export const overlayKeysOf = (state) => {
50
33
  return set;
51
34
  };
52
35
 
53
- // The live root reactive proxy, wired once at boot by index.js. Used ONLY to
54
- // reach the root's non-enumerable helper methods (`$.unsafe`, `$.on`, …) — NOT
55
- // for state reads.
56
36
  let rootProxy = null;
57
37
  export const setRootState = (proxy) => { rootProxy = proxy; };
58
38
 
59
- // Resolve the `$` identifier for an expression. `$` must read from the SAME
60
- // state object the diff cycle is evaluating — affected.js / iterate.js compare
61
- // an expression's value against an old snapshot and a new snapshot, so binding
62
- // `$` to the live root instead would make both reads identical and defeat
63
- // change detection (the whole reactivity engine). So state reads stay on the
64
- // passed `state`.
65
- //
66
- // The catch: the root's reserved methods (`unsafe`, `on`, `reconcile`, …) are
67
- // non-enumerable and don't survive the plain `{...state}` snapshots, so a
68
- // plain snapshot can't reach `$.unsafe`. Wrap such a state in a thin proxy that
69
- // serves its own keys (snapshot reads, fully diffable) and falls back to the
70
- // live root only for keys it lacks (the helper methods). Scoped states already
71
- // delegate to the live root (their target is `$`), so they need no wrapper.
72
39
  const dollarCache = new WeakMap();
73
- const RESERVED_PROBE = 'unsafe'; // reserved method present on root-backed states, absent on plain snapshots
74
- // The fallback serves ONLY the root's non-enumerable helper methods. A state
75
- // key that is merely missing from this snapshot must read as undefined — the
76
- // old/new snapshot diff depends on it. Letting it leak through to the live
77
- // root would make both sides of the diff read the same current value (e.g. a
78
- // component state bucket registered mid-cycle), silently defeating change
79
- // detection.
40
+ const RESERVED_PROBE = 'unsafe';
80
41
  const rootHelper = (k) => {
81
42
  const desc = Object.getOwnPropertyDescriptor(rootProxy, k);
82
43
  return desc && !desc.enumerable;
@@ -86,10 +47,6 @@ const dollarFor = (state) => {
86
47
  let wrapped = dollarCache.get(state);
87
48
  if (!wrapped) {
88
49
  wrapped = new Proxy(state, {
89
- // A `$.key` read on a plain snapshot never touches the live proxy, so
90
- // the tracking trap can't see it — record it here. The helper fallback
91
- // isn't a state read; a genuine miss records absent-tier so the key's
92
- // later creation dispatches this subscriber.
93
50
  get: (t, k) =>
94
51
  k in t ? (recordRead(k), t[k]) : rootHelper(k) ? rootProxy[k] : (recordAbsentRead(k), undefined),
95
52
  has: (t, k) => k in t || rootHelper(k),
@@ -99,25 +56,10 @@ const dollarFor = (state) => {
99
56
  return wrapped;
100
57
  };
101
58
 
102
- // Function compilation cache: avoids creating new Function() for repeated
103
- // expressions. Keyed by the CANONICALIZED expression alone — params derive
104
- // from the expression's own free identifiers (state-first, live global
105
- // fallback at call time), and per-mount component ids (`_cN`) canonicalize
106
- // into parameters — so the cache is bounded by the app's distinct template
107
- // expressions, not by state shape or mount count. The previous key included
108
- // the full state-key signature: every SPA navigation mints fresh `_cN` keys,
109
- // so each navigation recompiled every expression and left the dead entries
110
- // cached forever (~1,300 per navigation cycle in the game).
111
59
  const fnCache = new Map();
112
60
  export const evalCacheSize = () => fnCache.size;
113
61
 
114
- // Canonicalize + compile one normalized expression. One regex pass: skips
115
- // string literals and object-literal keys, folds `$['_cN']` and bare `_cN`
116
- // references into `$[__vibeCidK]` parameters, and collects every remaining
117
- // free identifier (minus locals declared by arrow params, reserved words,
118
- // and known globals) as a parameter of the compiled function.
119
62
  const compileExpression = (normalized) => {
120
- // Arrow-function parameters are locally bound — never parameters of ours.
121
63
  const locals = new Set();
122
64
  normalized.replace(/\(([^()]*)\)\s*=>/g, (_, paramList) => {
123
65
  for (const p of paramList.split(',')) {
@@ -169,46 +111,24 @@ const compileExpression = (normalized) => {
169
111
  };
170
112
  fnCache.set(canonical, entry);
171
113
  }
172
- // Params/cids are derived deterministically from the expression text, so a
173
- // cache hit from a DIFFERENT original spelling (another mount's `_cN`)
174
- // reuses the compiled fn with THIS call's ids.
175
114
  return { fn: entry.fn, params: entry.params, cids };
176
115
  };
177
116
 
178
- // Built-ins and reserved words that must NEVER be pre-declared as `var`
179
- // inside a compiled expression — they're either real globals we want to
180
- // reach, or JS keywords that would be a SyntaxError to shadow.
181
117
  const EVAL_IDENT_EXCLUDE = new Set([
182
- // Primitive literals / special identifiers
183
118
  'true', 'false', 'null', 'undefined', 'NaN', 'Infinity', 'this',
184
- // JS reserved words (shadowing any of these is a SyntaxError) — plus
185
- // `arguments`/`eval`, which are legal identifiers but illegal PARAMETER
186
- // names in strict mode.
187
119
  'break', 'case', 'catch', 'class', 'const', 'continue', 'debugger',
188
120
  'default', 'delete', 'do', 'else', 'export', 'extends', 'finally',
189
121
  'for', 'function', 'if', 'import', 'in', 'instanceof', 'let', 'new',
190
122
  'of', 'return', 'super', 'switch', 'throw', 'try', 'typeof', 'var',
191
123
  'void', 'while', 'with', 'yield', 'async', 'await', 'arguments', 'eval',
192
- // Common runtime globals authors reach for
193
124
  'Math', 'Array', 'Object', 'String', 'Number', 'Boolean', 'Date',
194
125
  'JSON', 'RegExp', 'Error', 'Map', 'Set', 'WeakMap', 'WeakSet',
195
126
  'Promise', 'Symbol', 'parseInt', 'parseFloat', 'isNaN', 'isFinite',
196
127
  'window', 'document', 'console', 'globalThis',
197
128
  ]);
198
129
 
199
- // Match free identifiers (not property accesses like `obj.foo` or keys
200
- // inside strings). The `(?<![.\w$'"])` lookbehind skips tokens preceded
201
- // by a dot (property access), an identifier char (mid-identifier), or
202
- // a quote (inside a string literal).
203
130
  const FREE_IDENT_REGEX = /(?<![.\w$'"])[a-zA-Z_$][\w$]*/g;
204
131
 
205
- // Resolve a global identifier through the SCOPE CHAIN, not the globalThis
206
- // object — top-level let/const/class helpers in classic scripts live in the
207
- // global declarative record, which `globalThis[p]` can't see. A cached
208
- // per-identifier probe compiled at global scope reaches both records with
209
- // the typeof-guard the compiled expressions themselves used pre-3.0.0.
210
- // `p` is always a regex-validated non-reserved identifier (compileExpression
211
- // filters keywords via EVAL_IDENT_EXCLUDE), so the probe source is inert.
212
132
  const globalProbeCache = new Map();
213
133
  const readGlobal = (p) => {
214
134
  let probe = globalProbeCache.get(p);
@@ -219,46 +139,25 @@ const readGlobal = (p) => {
219
139
  return probe();
220
140
  };
221
141
 
222
- // Evaluate expression in the context of state
223
- // Handles @[this.property] for component state and @[property] for global state
224
142
  export const evalInScope = (expr, state, element = null) => {
225
143
  try {
226
- // Normalize whitespace - collapse newlines/spaces to single space (resilient to IDE formatting)
227
144
  let normalized = expr.replace(/\s+/g, ' ').trim();
228
145
 
229
- // If expression contains 'this.', replace with component state path
230
146
  if (normalized.includes('this.') && element) {
231
147
  const componentId = findComponentIdForElement(element);
232
148
  if (componentId) {
233
- // Replace this.property with $['componentId'].property
234
149
  normalized = normalized.replace(THIS_PROP_REGEX, `$['${componentId}'].$1`);
235
150
  }
236
151
  }
237
152
 
238
153
  const { fn, params, cids } = compileExpression(normalized);
239
154
 
240
- // Build values matching the compiled parameter order: the expression's
241
- // free identifiers, the canonicalized component-id strings, and `$` via
242
- // dollarFor — the same state for reads (so old/new diffing works), with
243
- // helper-method fallback to the live root. An identifier resolves state-
244
- // first by ENUMERABLE OWN KEY presence — ownKeysOf, the exact reach the
245
- // old key-signature params had: snapshots and scoped states expose their
246
- // keys there (a key holding undefined still shadows a global), while the
247
- // root's non-enumerable helpers ($.on, $.unsafe) stay invisible — a state
248
- // key named `on` must never resolve the hook method. `in` would see
249
- // those helpers and the prototype chain; ownKeysOf is computed per call,
250
- // so a live root proxy mutated between evals stays accurate. Everything
251
- // else resolves through the scope-chain probe (readGlobal) and records
252
- // an absent-tier read, so the key's later creation re-renders this
253
- // binding instead of being silently missed.
254
155
  const stateObj = state == null ? {} : state;
255
156
  const values = new Array(params.length + cids.length + 1);
256
157
  const keys = params.length ? ownKeysOf(stateObj) : null;
257
158
  let v = 0;
258
159
  for (let i = 0; i < params.length; i++) {
259
160
  const p = params[i];
260
- // State-first resolution IS a root-key read — record it for the open
261
- // tracking window (snapshot evals never reach the proxy trap).
262
161
  values[v++] = keys.includes(p) ? (recordRead(p), stateObj[p]) : (recordAbsentRead(p), readGlobal(p));
263
162
  }
264
163
  for (let i = 0; i < cids.length; i++) values[v++] = cids[i];
@@ -266,15 +165,12 @@ export const evalInScope = (expr, state, element = null) => {
266
165
 
267
166
  const result = fn(...values);
268
167
 
269
- // If result is undefined and we're accessing a component property, try case-insensitive match
270
- // This handles HTML lowercasing attribute names like @[this.iconName] -> @[this.iconname]
271
168
  if (result === undefined) {
272
169
  const componentMatch = normalized.match(/\$\['([^']+)'\]\.(\w+)/);
273
170
  if (componentMatch) {
274
171
  const [, componentId, propName] = componentMatch;
275
172
  const componentState = state[componentId];
276
173
  if (componentState) {
277
- // Try case-insensitive property lookup
278
174
  const actualKey = Object.keys(componentState).find(k => k.toLowerCase() === propName.toLowerCase());
279
175
  if (actualKey) {
280
176
  return componentState[actualKey];
@@ -290,32 +186,14 @@ export const evalInScope = (expr, state, element = null) => {
290
186
  }
291
187
  };
292
188
 
293
- // Walk a dotted path against a state-like object, falling back to
294
- // case-insensitive key matching at each segment. Used by name-binding
295
- // hydration (clone + batch) to recover camelCase property names that the
296
- // HTML parser lowercased — `<icon @[fx.convertsIcon]>` arrives at the
297
- // runtime as `@[fx.convertsicon]`, which doesn't match `convertsIcon` on
298
- // `fx`. Bails on bracket/call expressions because those need a real
299
- // evaluator (and `evalInScope` already handled them).
300
189
  export const resolveCaseInsensitivePath = (state, path) => {
301
190
  if (path.includes('[')) return undefined;
302
- // Component prop substitution wraps the source in grouping parens, e.g.
303
- // `props.element` inside a reused component becomes `(equipmentDetailProps).element`.
304
- // The HTML parser lowercases name-binding attribute names, so strip the grouping
305
- // parens to recover a plain dotted path; bail if anything but identifiers + dots
306
- // survives (a real function call like `selectedEquipProps(uuid)` can't be recovered).
307
191
  if (path.includes('(')) {
308
192
  path = path.replace(/[()]/g, '');
309
193
  if (!/^[A-Za-z_$][\w$]*(\.[A-Za-z_$][\w$]*)*$/.test(path)) return undefined;
310
194
  }
311
195
  let segments = path.split('.');
312
196
 
313
- // Resolve the root against the SAME scope evalInScope reaches, in its order:
314
- // the diff state (including scoped loop aliases — see ownKeysOf below), then
315
- // globals. A name-binding inside an iteration reads its props from a global
316
- // stash — `window.__vibe.iterProps._p0.statusKey` — so a state-only walk can
317
- // never reach it, and the camelCase leaf the parser lowercased stays lost
318
- // (the status-chip gray-icon parity bug: resolved in compiled, not runtime).
319
197
  const root = segments[0];
320
198
  const rootInState =
321
199
  state != null &&
@@ -335,15 +213,11 @@ export const resolveCaseInsensitivePath = (state, path) => {
335
213
 
336
214
  for (const seg of segments) {
337
215
  if (current == null) return undefined;
338
- // Direct first — handles proxies (scoped iteration state) and plain objects.
339
216
  if (Reflect.has(Object(current), seg)) {
340
217
  current = current[seg];
341
218
  continue;
342
219
  }
343
220
  if (typeof current !== 'object' && typeof current !== 'function') return undefined;
344
- // Read keys from the SAME source evalInScope does (ownKeysOf), so a scoped
345
- // loop alias — present in the proxy's precomputed key list but hidden from
346
- // Object.keys/Reflect.has — resolves here too.
347
221
  const ci = ownKeysOf(current).find((k) => k.toLowerCase() === seg.toLowerCase());
348
222
  if (!ci) return undefined;
349
223
  current = current[ci];
@@ -351,26 +225,17 @@ export const resolveCaseInsensitivePath = (state, path) => {
351
225
  return current;
352
226
  };
353
227
 
354
- // Helper to find component ID for an element
355
- // Walks up DOM tree to find nearest component wrapper
356
228
  export const findComponentIdForElement = (element) => {
357
229
  if (!element || !element.closest) return null;
358
230
 
359
231
  const wrapper = element.closest('[data-vibe-component-id]');
360
232
  if (wrapper) return wrapper.getAttribute('data-vibe-component-id');
361
- // Detached fallback: cloned-but-not-yet-attached subtrees (iteration row
362
- // construction in `initializeBlock` parses + hydrates inside a fresh
363
- // parseContainer before insertion). Walk back to the root and consult
364
- // _vibeComponentId, which iteration code can stash on the parseContainer
365
- // when it knows the row's owning component up front.
366
233
  let root = element;
367
234
  while (root.parentNode) root = root.parentNode;
368
235
  if (root._vibeComponentId) return root._vibeComponentId;
369
236
  return null;
370
237
  };
371
238
 
372
- // Helper to resolve this.property paths to componentId.property
373
- // Used for component-scoped iterations, conditionals, etc.
374
239
  export const resolveThisPath = (path, element) => {
375
240
  if (!path.startsWith('this.')) {
376
241
  return path;
@@ -385,28 +250,21 @@ export const resolveThisPath = (path, element) => {
385
250
  return path;
386
251
  };
387
252
 
388
- // Instead of using lodash-es as a dependency, we run our own deepMerge (mergeWith in lodash)
389
253
  export const deepMerge = (target, source) => {
390
- // Handle null/undefined
391
254
  if (source == null) return target;
392
255
  if (target == null) return source;
393
256
 
394
- // If source is not an object, return it
395
257
  if (typeof source !== 'object') return source;
396
258
 
397
- // If source is an array, replace target array (don't merge arrays)
398
259
  if (Array.isArray(source)) return source;
399
260
 
400
- // Clone target to avoid mutation
401
261
  const result = { ...target };
402
262
 
403
- // Merge each property from source
404
263
  for (const key in source) {
405
264
  if (source.hasOwnProperty(key)) {
406
265
  const targetValue = result[key];
407
266
  const sourceValue = source[key];
408
267
 
409
- // If both are objects (but not arrays), merge recursively
410
268
  if (
411
269
  targetValue != null &&
412
270
  sourceValue != null &&
@@ -417,7 +275,6 @@ export const deepMerge = (target, source) => {
417
275
  ) {
418
276
  result[key] = deepMerge(targetValue, sourceValue);
419
277
  } else {
420
- // Otherwise, replace with source value (handles arrays and primitives)
421
278
  result[key] = sourceValue;
422
279
  }
423
280
  }