@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/README.md +3 -1
- package/boot.js +10 -9
- package/component.js +0 -29
- package/hot-module-refresh.js +0 -0
- package/index.js +0 -28
- package/package.json +3 -2
- package/runtime/_vibe-compiled-iteration-batch.js +0 -56
- package/runtime/affected.js +6 -189
- package/runtime/cleanup.js +4 -35
- package/runtime/component-cache.js +0 -53
- package/runtime/component.js +7 -401
- package/runtime/conditionals.js +1 -143
- package/runtime/constants.js +19 -82
- package/runtime/debug.js +22 -47
- package/runtime/dispatch.js +0 -46
- package/runtime/hydrate.js +10 -129
- package/runtime/index.js +7 -365
- package/runtime/iterate.js +21 -579
- package/runtime/iteration-utils.js +18 -71
- package/runtime/loop-scope.js +0 -58
- package/runtime/manifest.js +0 -27
- package/runtime/parse.js +2 -116
- package/runtime/pre-compiled-iterations.js +3 -51
- package/runtime/pre-compiled-manifest.js +6 -169
- package/runtime/raw-html.js +0 -5
- package/runtime/reconcile.js +4 -159
- package/runtime/staging.js +0 -57
- package/runtime/state.js +0 -61
- package/runtime/this-scope.js +0 -17
- package/runtime/tracking.js +0 -65
- package/runtime/utils.js +1 -144
- package/runtime/vibe-css.js +54 -0
- package/spa.js +0 -76
- package/vibe.css +22 -44
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
|
},
|
package/runtime/this-scope.js
CHANGED
|
@@ -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) => {
|
package/runtime/tracking.js
CHANGED
|
@@ -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';
|
|
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
|
}
|