@ape-egg/vibe 2.3.0 → 3.0.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.
Files changed (57) hide show
  1. package/README.md +14 -4
  2. package/boot.js +4 -4
  3. package/component.js +27 -29
  4. package/hot-module-refresh.js +4 -4
  5. package/index.js +10 -15
  6. package/llms.txt +8 -6
  7. package/package.json +19 -14
  8. package/runtime/affected.js +159 -36
  9. package/runtime/cleanup.js +45 -1
  10. package/runtime/component.js +312 -99
  11. package/runtime/conditionals.js +111 -14
  12. package/runtime/debug.js +24 -0
  13. package/runtime/dispatch.js +172 -0
  14. package/runtime/hydrate.js +251 -111
  15. package/runtime/index.js +180 -71
  16. package/runtime/iterate.js +125 -50
  17. package/runtime/iteration-utils.js +59 -8
  18. package/runtime/manifest.js +77 -2
  19. package/runtime/parse.js +69 -5
  20. package/runtime/pre-compiled-iterations.js +19 -6
  21. package/runtime/pre-compiled-manifest.js +13 -4
  22. package/runtime/staging.js +153 -0
  23. package/runtime/state.js +31 -0
  24. package/runtime/tracking.js +173 -0
  25. package/runtime/utils.js +155 -78
  26. package/spa.js +77 -14
  27. package/vibe.css +8 -4
  28. package/CHANGELOG.md +0 -1196
  29. package/ROADMAP.md +0 -397
  30. package/compiler/bin/vibe-compile.js +0 -121
  31. package/compiler/native/.gitkeep +0 -0
  32. package/compiler/native/vibe-compiler-darwin-arm64 +0 -0
  33. package/compiler/native/vibe-compiler-linux-x64 +0 -0
  34. package/compiler/src/Cargo.lock +0 -2023
  35. package/compiler/src/Cargo.toml +0 -38
  36. package/compiler/src/compiler/PRE-RENDERING-IMPLEMENTATION.md +0 -241
  37. package/compiler/src/compiler/binding_case.rs +0 -88
  38. package/compiler/src/compiler/compile.rs +0 -2880
  39. package/compiler/src/compiler/component_tagger.rs +0 -469
  40. package/compiler/src/compiler/iteration_optimizer.rs +0 -455
  41. package/compiler/src/compiler/js_analyzer.rs +0 -715
  42. package/compiler/src/compiler/manifest_builder.rs +0 -693
  43. package/compiler/src/compiler/mod.rs +0 -16
  44. package/compiler/src/compiler/name_binding_protect.rs +0 -207
  45. package/compiler/src/compiler/reassignment_analyzer.rs +0 -456
  46. package/compiler/src/compiler/spa.rs +0 -477
  47. package/compiler/src/compiler/state_extractor.rs +0 -263
  48. package/compiler/src/compiler/value_stamper.rs +0 -921
  49. package/compiler/src/compiler/watcher.rs +0 -1278
  50. package/compiler/src/config.rs +0 -279
  51. package/compiler/src/main.rs +0 -358
  52. package/compiler/src/parser/element.rs +0 -96
  53. package/compiler/src/parser/html.rs +0 -1004
  54. package/compiler/src/parser/mod.rs +0 -8
  55. package/runtime/pre-compiled-manifest.test.mjs +0 -58
  56. package/runtime/scope.js +0 -50
  57. package/test-results/.last-run.json +0 -4
package/runtime/parse.js CHANGED
@@ -1,8 +1,14 @@
1
- import { findEndComment, findConditionalEnd, parseIterationHeader } from './iteration-utils.js';
1
+ import {
2
+ findEndComment,
3
+ findEndCommentIndex,
4
+ findConditionalEnd,
5
+ parseIterationHeader,
6
+ } from './iteration-utils.js';
2
7
  import {
3
8
  NON_REACTIVE_ELEMENTS,
4
9
  BINDING_REGEX,
5
10
  CONDITIONAL_REGEX,
11
+ ITERATION_START_REGEX,
6
12
  DOM_ELEMENT_PROPERTIES,
7
13
  DEHYDRATE_CLASS_OR_ATTR,
8
14
  THIS_PROP_REGEX,
@@ -18,6 +24,18 @@ const findComponentIdForElement = (element) => {
18
24
  return wrapper ? wrapper.getAttribute('data-vibe-component-id') : null;
19
25
  };
20
26
 
27
+ // `key` is reserved on fetched-component wrappers and remounts on CHANGE — a
28
+ // literal key can never change, so it can never fire. Warn once per value
29
+ // instead of dying silently (reparses would otherwise spam).
30
+ const warnedStaticKeys = new Set();
31
+ const warnStaticKey = (key) => {
32
+ if (warnedStaticKeys.has(key)) return;
33
+ warnedStaticKeys.add(key);
34
+ console.warn(
35
+ `[vibe] <component key="${key}"> is static — key remounts on CHANGE, so a literal never fires. Bind it (key="@[expr]") or remove it.`,
36
+ );
37
+ };
38
+
21
39
  // Single source of truth for reading attribute/name bindings off an element.
22
40
  // Called from both the root handler and recursive() so they can't drift. Any
23
41
  // element classified as a fetched component (`<component src>` or
@@ -44,6 +62,7 @@ const captureAttributeBindings = (element, aliasSet) => {
44
62
  if (BINDING_REGEX.test(src)) attributes.src = src;
45
63
  BINDING_REGEX.lastIndex = 0;
46
64
  if (key && BINDING_REGEX.test(key)) attributes.key = key;
65
+ else if (key) warnStaticKey(key);
47
66
  return {
48
67
  attributes: Object.keys(attributes).length ? attributes : null,
49
68
  nameBindings: null,
@@ -169,7 +188,39 @@ const recursive = (children, rootKey = undefined, skipIndices = new Set(), stats
169
188
 
170
189
  // Handle iteration comments
171
190
  if (nodeName === '#comment') {
172
- const iterationMatch = parseIterationHeader(textContent.trim());
191
+ const trimmedComment = textContent.trim();
192
+ const iterationMatch = parseIterationHeader(trimmedComment);
193
+
194
+ // An each-shaped comment that fails the grammar, WITH a matching /each,
195
+ // is an authored directive with a syntax error — code, not prose. It
196
+ // used to render its body once, silently: the framework's worst
197
+ // footgun. Directives scream like a SyntaxError in every mode. (A
198
+ // prose comment merely starting with "each", no /each below, stays an
199
+ // inert comment.)
200
+ if (
201
+ !iterationMatch &&
202
+ ITERATION_START_REGEX.test(trimmedComment) &&
203
+ findEndCommentIndex(children, i + 1) !== -1
204
+ ) {
205
+ console.error(
206
+ `[vibe] Malformed each directive: <!-- ${trimmedComment} -->\n` +
207
+ 'Expected <!-- each items as item -->, with optional ", index" and "(key)" in either order — e.g. <!-- each items as item (item.id), i -->',
208
+ );
209
+ }
210
+
211
+ // A bare <!-- if --> / <!-- else if --> (no expression) with a
212
+ // matching /if ahead is the same class of authored error.
213
+ if (/^if$/.test(trimmedComment) || /^else\s+if$/.test(trimmedComment)) {
214
+ try {
215
+ findConditionalEnd(children, i + 1);
216
+ console.error(
217
+ `[vibe] Malformed ${trimmedComment.startsWith('else') ? 'else if' : 'if'} directive: <!-- ${trimmedComment} --> has no expression.\n` +
218
+ 'Expected <!-- if condition --> ... <!-- else if condition --> ... <!-- /if -->',
219
+ );
220
+ } catch {
221
+ // No /if below — an inert comment.
222
+ }
223
+ }
173
224
 
174
225
  if (iterationMatch) {
175
226
  const { arrayPath, itemAlias, keyExpr, indexAlias } = iterationMatch;
@@ -269,8 +320,8 @@ const recursive = (children, rootKey = undefined, skipIndices = new Set(), stats
269
320
  const [_, expression] = conditionalMatch;
270
321
 
271
322
  try {
272
- // Find matching end comment and optional else
273
- const { elseIndex, endIndex } = findConditionalEnd(children, i + 1);
323
+ // Find matching end comment and optional else / else-if boundary
324
+ const { elseIndex, endIndex, elseText } = findConditionalEnd(children, i + 1);
274
325
 
275
326
  // Extract true branch nodes (between if and else/endif)
276
327
  const trueBranchEnd = elseIndex !== null ? elseIndex : endIndex;
@@ -286,15 +337,28 @@ const recursive = (children, rootKey = undefined, skipIndices = new Set(), stats
286
337
  // inside a conditional nested within an iteration).
287
338
  const trueBranchParsed = recursive([...trueBranchContainer.childNodes], undefined, new Set(), stats, aliasSet);
288
339
 
289
- // Extract false branch nodes if else exists
340
+ // Extract false branch nodes if else exists. An `else if` boundary
341
+ // DESUGARS: the false branch becomes a synthesized nested
342
+ // conditional (`<!-- if rest -->…<!-- /if -->` wrapping the chain
343
+ // tail), so every mount/patch/manifest path only ever sees binary
344
+ // conditionals — else-if is grammar, not machinery. The chain tail
345
+ // may itself contain further else-if boundaries; the recursion
346
+ // desugars them the same way.
290
347
  let falseBranchParsed = null;
291
348
  let falseBranchContainer = null;
292
349
  if (elseIndex !== null) {
293
350
  const falseBranchNodes = Array.from(children).slice(elseIndex + 1, endIndex);
294
351
  falseBranchContainer = document.createElement('div');
352
+ const chained = elseText !== 'else';
353
+ if (chained) {
354
+ falseBranchContainer.appendChild(
355
+ document.createComment(` ${elseText.replace(/^else\s+/, '')} `),
356
+ );
357
+ }
295
358
  falseBranchNodes.forEach((node) => {
296
359
  falseBranchContainer.appendChild(node.cloneNode(true));
297
360
  });
361
+ if (chained) falseBranchContainer.appendChild(document.createComment(' /if '));
298
362
  falseBranchParsed = recursive([...falseBranchContainer.childNodes], undefined, new Set(), stats, aliasSet);
299
363
  }
300
364
 
@@ -125,6 +125,7 @@ export const renderCompiled = (iterationNode, array, state, compiledMeta, parent
125
125
 
126
126
  parent.insertBefore(frag, endComment);
127
127
  iterationNode.runtime.instances = instances;
128
+ iterationNode.runtime.lastCompiledHtml = html;
128
129
  stampInstanceScopes(iterationNode);
129
130
  return true;
130
131
  }
@@ -137,9 +138,22 @@ export const renderCompiled = (iterationNode, array, state, compiledMeta, parent
137
138
  */
138
139
  export const updateCompiled = (iterationNode, newArray, state, compiledMeta, startComment, endComment) => {
139
140
  const parent = startComment.parentNode;
141
+ const rt = iterationNode.runtime;
142
+
143
+ const html = newArray.length === 0 ? '' : callCompiled(iterationNode, newArray, state, compiledMeta);
144
+ if (html === null) return false;
145
+
146
+ // Keep-DOM guard, same contract as the runtime batch renderer: compiled
147
+ // iterations sit in the always-dirty bucket, so every flush of ANY key
148
+ // lands here — identical output must keep the existing rows (imperative
149
+ // listeners, focus, markers die with deleteContents), not rebuild them.
150
+ if (html === rt.lastCompiledHtml && rt.instances.length === newArray.length) {
151
+ for (let i = 0; i < newArray.length; i++) rt.instances[i].item = newArray[i];
152
+ return true;
153
+ }
140
154
 
141
155
  // Clear existing instances
142
- if (iterationNode.runtime.instances.length > 0) {
156
+ if (rt.instances.length > 0) {
143
157
  const range = document.createRange();
144
158
  range.setStartAfter(startComment);
145
159
  range.setEndBefore(endComment);
@@ -147,13 +161,11 @@ export const updateCompiled = (iterationNode, newArray, state, compiledMeta, sta
147
161
  }
148
162
 
149
163
  if (newArray.length === 0) {
150
- iterationNode.runtime.instances = [];
164
+ rt.instances = [];
165
+ rt.lastCompiledHtml = '';
151
166
  return true;
152
167
  }
153
168
 
154
- const html = callCompiled(iterationNode, newArray, state, compiledMeta);
155
- if (html === null) return false;
156
-
157
169
  // Parse and insert
158
170
  if (parseTemplate) {
159
171
  parseTemplate.innerHTML = html;
@@ -168,7 +180,8 @@ export const updateCompiled = (iterationNode, newArray, state, compiledMeta, sta
168
180
  }
169
181
 
170
182
  parent.insertBefore(frag, endComment);
171
- iterationNode.runtime.instances = instances;
183
+ rt.instances = instances;
184
+ rt.lastCompiledHtml = html;
172
185
  stampInstanceScopes(iterationNode);
173
186
  return true;
174
187
  }
@@ -252,13 +252,22 @@ const detectHyperspeed = async () => {
252
252
  const skipNetwork = !!document.querySelector("[vibe-fouc], .vibe-fouc");
253
253
 
254
254
  try {
255
+ // window.__MANIFEST__ is an explicit manifest URL stamped into the page by
256
+ // the build/dev server. The compiled SPA shell needs it: the shell is
257
+ // served for EVERY route, so pathname-derived candidates point at manifests
258
+ // that don't exist (deep links probed 404s and never found the shell's own
259
+ // manifest). When present it is the single, authoritative candidate.
260
+ //
255
261
  // window.__ROUTE__ is the page's route template (e.g. "/brawlers/:index"),
256
262
  // injected by the compiler/dev server for dynamic pages. It lets us resolve
257
263
  // the tokenized `$` manifest directly instead of probing literal 404s.
258
- const possiblePaths = buildManifestCandidatePaths(
259
- window.location.pathname,
260
- typeof window !== "undefined" ? window.__ROUTE__ : null,
261
- );
264
+ const hinted = typeof window !== "undefined" ? window.__MANIFEST__ : null;
265
+ const possiblePaths = hinted
266
+ ? [hinted]
267
+ : buildManifestCandidatePaths(
268
+ window.location.pathname,
269
+ typeof window !== "undefined" ? window.__ROUTE__ : null,
270
+ );
262
271
 
263
272
  if (possiblePaths.length === 0) return null;
264
273
 
@@ -0,0 +1,153 @@
1
+ // Navigation staging — the one-paint commit machinery behind reactive-src /
2
+ // keyed remounts, owned in one place. A remount marks the mounted wrapper
3
+ // OUTGOING: it keeps the screen — visible, styled, frozen — while the
4
+ // incoming wrapper hydrates as a display:none sibling ([vibe-staged]). The
5
+ // commit swaps the two and replays parked ancestor bindings in one
6
+ // synchronous step: navigation paints exactly once, never an unstyled or
7
+ // blank frame between.
8
+ //
9
+ // Every consumer answers its question through THIS module:
10
+ // isOutgoing(el) tree walks — skip collecting under a frozen wrapper
11
+ // outgoingRootOf(n) dispatch — is this subscriber inside a frozen page?
12
+ // parkRootFor(el) hydrate — is a frozen page inside this element? then
13
+ // park the binding (it is the styling context AROUND
14
+ // the outlet — flipping it mid-stage un-styles the
15
+ // visible page) and let the commit replay it
16
+ // stage/commit/abandon component.js's mount finalize drives transitions
17
+
18
+ // The mount pipeline replaces wrappers (finalize's replaceWith), leaving a
19
+ // `_vibeReplacedBy` link behind. Trees and clone lists keep ORIGINAL nodes —
20
+ // follow the chain to the live one, compressing so intermediate detached
21
+ // wrappers stay collectable.
22
+ export const liveNode = (node) => {
23
+ let live = node;
24
+ while (live && live._vibeReplacedBy) live = live._vibeReplacedBy;
25
+ if (live !== node && node) node._vibeReplacedBy = live;
26
+ return live;
27
+ };
28
+
29
+ // Wrappers whose mounted content is outgoing — a remount is in flight.
30
+ export const activeOutgoingRoots = new Set();
31
+
32
+ export const isOutgoing = (element) => !!(element && liveNode(element)._vibeOutgoing);
33
+
34
+ export const markOutgoing = (el) => {
35
+ el._vibeOutgoing = true;
36
+ activeOutgoingRoots.add(el);
37
+ };
38
+
39
+ export const releaseOutgoing = (el) => {
40
+ el._vibeOutgoing = false;
41
+ activeOutgoingRoots.delete(el);
42
+ };
43
+
44
+ // The outgoing root STRICTLY containing `node`, or null. The root's own
45
+ // src/key/prop bindings stay live (a rapid next navigation must re-trigger);
46
+ // everything beneath it is replaced wholesale at the swap, so re-rendering
47
+ // it against post-navigation state is pure waste — and was the visible
48
+ // mid-navigation collapse.
49
+ export const outgoingRootOf = (node) => {
50
+ if (!activeOutgoingRoots.size || !node) return null;
51
+ for (const root of activeOutgoingRoots) {
52
+ if (root !== node && root.contains?.(node)) return root;
53
+ }
54
+ return null;
55
+ };
56
+
57
+ // The outgoing root strictly INSIDE `element`, or null — the park direction:
58
+ // `element` is the styling context around the outlet (the game's
59
+ // `<page @[page.name]>`). Zero cost when nothing is staging.
60
+ export const parkRootFor = (element) => {
61
+ if (!activeOutgoingRoots.size || !element) return null;
62
+ for (const root of activeOutgoingRoots) {
63
+ if (root !== element && element.contains?.(root)) return root;
64
+ }
65
+ return null;
66
+ };
67
+
68
+ // Parked updates live on the outgoing wrapper (Map<element, Map<key, apply>>)
69
+ // keyed by element + binding so rapid flushes during staging keep only the
70
+ // latest value; the commit replays them in its synchronous swap step.
71
+ export const parkBinding = (root, element, key, apply) => {
72
+ const parked = (root._vibeParkedBindings ??= new Map());
73
+ let perElement = parked.get(element);
74
+ if (!perElement) parked.set(element, (perElement = new Map()));
75
+ perElement.set(key, apply);
76
+ };
77
+
78
+ const replayParked = (old) => {
79
+ const parked = old._vibeParkedBindings;
80
+ if (!parked) return;
81
+ for (const perElement of parked.values()) {
82
+ for (const apply of perElement.values()) apply();
83
+ }
84
+ old._vibeParkedBindings = null;
85
+ };
86
+
87
+ // A fetched-component host: `<component>` or `<div class="component">`.
88
+ export const isComponentWrapper = (el) =>
89
+ el.nodeName === 'COMPONENT' ||
90
+ (el.nodeName === 'DIV' && el.classList?.contains('component'));
91
+
92
+ // A flush entry that (re)mounts: the wrapper's own src/key binding. These
93
+ // process FLUSH-WIDE FIRST — they register the outgoing roots every other
94
+ // entry's freeze-skip and parking decisions depend on. (Tree-walk order gave
95
+ // the walk this for free within one pass; dispatch batches must hoist.)
96
+ export const isRemountTrigger = (entry) =>
97
+ entry.type === 'attribute' &&
98
+ (entry.attrName === 'src' || entry.attrName === 'key') &&
99
+ !!entry.element &&
100
+ isComponentWrapper(entry.element);
101
+
102
+ // ————— staged swap transitions (driven by component.js finalize) —————
103
+
104
+ // Mount landed for an outgoing wrapper: stage the incoming wrapper, don't
105
+ // swap. A superseded staged wrapper (rapid re-navigation) was never visible:
106
+ // drop it immediately and inherit the ORIGINAL visible page as this mount's
107
+ // commit target.
108
+ export const stageIncoming = (el, newWrapper) => {
109
+ if (el.hasAttribute('vibe-staged')) {
110
+ newWrapper._vibeCommitOld = el._vibeCommitOld;
111
+ activeOutgoingRoots.delete(el);
112
+ newWrapper.setAttribute('vibe-staged', '');
113
+ el.replaceWith(newWrapper);
114
+ } else {
115
+ newWrapper._vibeCommitOld = el;
116
+ newWrapper.setAttribute('vibe-staged', '');
117
+ // The outgoing wrapper is no longer a mount request — with the src
118
+ // attribute still on it, every processComponent rescan would treat it as
119
+ // unresolved and mount a second copy while it holds the screen.
120
+ el.removeAttribute('src');
121
+ el.after(newWrapper);
122
+ }
123
+ };
124
+
125
+ // Atomic visual commit: old page out, parked styling-context bindings
126
+ // applied, new page revealed — one synchronous block, one paint.
127
+ export const commitStaged = (newWrapper) => {
128
+ const old = newWrapper._vibeCommitOld;
129
+ if (old) {
130
+ old.remove();
131
+ activeOutgoingRoots.delete(old);
132
+ replayParked(old);
133
+ newWrapper._vibeCommitOld = null;
134
+ }
135
+ newWrapper.removeAttribute('vibe-staged');
136
+ };
137
+
138
+ // Incoming wrapper died before its commit (unmounted mid-hydration, or
139
+ // removed by something outside the staging machinery — $.reconcile on an
140
+ // ancestor, app code pruning the container): no swap is coming. Release the
141
+ // frozen old page — connected, it must resume updating; detached, the Set
142
+ // entry would pin the subtree forever — and reverse the replaced-by link so
143
+ // chains compressed onto the dead staged wrapper resolve back to the page
144
+ // that stayed (its src binding must act on IT again, or no later navigation
145
+ // can ever remount).
146
+ export const abandonStaged = (newWrapper) => {
147
+ const old = newWrapper._vibeCommitOld;
148
+ if (!old) return;
149
+ newWrapper._vibeCommitOld = null;
150
+ releaseOutgoing(old);
151
+ old._vibeReplacedBy = null;
152
+ newWrapper._vibeReplacedBy = old;
153
+ };
package/runtime/state.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { recordRead } from './tracking.js';
2
+
1
3
  // Track which objects are already proxied to avoid double-wrapping
2
4
  const proxyCache = new WeakMap();
3
5
 
@@ -15,6 +17,29 @@ const RAW = Symbol('vibeRaw');
15
17
  const unwrap = (value) =>
16
18
  value !== null && typeof value === 'object' && value[RAW] !== undefined ? value[RAW] : value;
17
19
 
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
+ export const silentSet = (proxy, prop, value) => {
28
+ const raw = proxy !== null && typeof proxy === 'object' ? (proxy[RAW] ?? proxy) : proxy;
29
+ raw[prop] = unwrap(value);
30
+ };
31
+
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
+ export const notifyChanged = (props) => {
38
+ if (!props || props.length === 0) return;
39
+ for (const prop of props) changedProps.add(prop);
40
+ scheduleFlush();
41
+ };
42
+
18
43
  // Batching: collect mutations and flush once per microtask
19
44
  let pendingFlush = false;
20
45
  let flushCallback = null;
@@ -95,6 +120,12 @@ const createDeepProxy = (target, rerender, rootState = null, rootProp = null) =>
95
120
  // recover the unproxied object from any of our proxies.
96
121
  if (prop === RAW) return target;
97
122
 
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
+ recordRead(rootProp === null ? prop : rootProp);
128
+
98
129
  const value = Reflect.get(target, prop);
99
130
 
100
131
  // Don't proxy non-objects, functions, null, or Promises.
@@ -0,0 +1,173 @@
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
+ let windows = [];
23
+ let active = null;
24
+
25
+ // rootKey → Set<subscriber>
26
+ 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
+ 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
+ const subDeps = new Map();
35
+
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
+ export const beginTracking = (sub, skipKeys = null) => {
41
+ active = { sub, skipKeys, reads: new Set(), absent: new Set(), sawScope: false };
42
+ windows.push(active);
43
+ };
44
+
45
+ export const endTracking = () => {
46
+ const w = windows.pop();
47
+ active = windows.length ? windows[windows.length - 1] : null;
48
+ if (w) commit(w);
49
+ };
50
+
51
+ const dropFromBuckets = (sub, deps) => {
52
+ for (const key of deps) {
53
+ const bucket = keyIndex.get(key);
54
+ if (bucket) {
55
+ bucket.delete(sub);
56
+ if (bucket.size === 0) keyIndex.delete(key);
57
+ }
58
+ }
59
+ };
60
+
61
+ const commit = ({ sub, reads, absent, sawScope }) => {
62
+ const prev = subDeps.get(sub);
63
+ if (prev) dropFromBuckets(sub, prev);
64
+ alwaysSubs.delete(sub);
65
+
66
+ // Only LIVE reads count toward "this subscriber has provable deps" — an
67
+ // eval that read no present state key keeps the walk's re-evaluate-on-any-
68
+ // flush contract even when absent identifiers were seen (they may be plain
69
+ // globals forever). Scope-only evals stay out of the always-bucket: the
70
+ // row's iteration is subscribed and owns the row.
71
+ if (reads.size === 0 && !sawScope) alwaysSubs.add(sub);
72
+
73
+ // Absent identifiers still index: if the key is ever CREATED, that flush
74
+ // must select this subscriber — the walk got this by token-matching against
75
+ // the NEW state's key set every cycle. Same for delete-then-recreate: the
76
+ // post-delete re-eval keeps the key as an absent dep.
77
+ const deps = absent.size === 0 ? reads : new Set([...reads, ...absent]);
78
+ for (const key of deps) {
79
+ let bucket = keyIndex.get(key);
80
+ if (!bucket) keyIndex.set(key, (bucket = new Set()));
81
+ bucket.add(sub);
82
+ }
83
+ subDeps.set(sub, deps);
84
+ };
85
+
86
+ // The single recording entry point — called from state.js's get trap,
87
+ // evalInScope's parameter resolution, dollarFor's snapshot wrapper, and
88
+ // createScopedState's global fallthrough. One null check when inactive.
89
+ export const recordRead = (key) => {
90
+ if (active === null || typeof key !== 'string') return;
91
+ if (active.skipKeys !== null && active.skipKeys.has(key)) {
92
+ active.sawScope = true;
93
+ return;
94
+ }
95
+ active.reads.add(key);
96
+ };
97
+
98
+ // A read that MISSED the state (identifier absent at evaluation time —
99
+ // evalInScope's global fallback, dollarFor's non-key miss). Indexed so the
100
+ // key's later creation dispatches this subscriber, but not counted as a live
101
+ // dep — see commit.
102
+ export const recordAbsentRead = (key) => {
103
+ if (active === null || typeof key !== 'string') return;
104
+ if (active.skipKeys !== null && active.skipKeys.has(key)) {
105
+ active.sawScope = true;
106
+ return;
107
+ }
108
+ active.absent.add(key);
109
+ };
110
+
111
+ // Union of subscribers for a set of changed root keys, plus the conservative
112
+ // always-bucket. Callers filter liveness (anchor.isConnected) at use time.
113
+ export const subscribersOf = (keys) => {
114
+ const out = new Set(alwaysSubs);
115
+ for (const key of keys) {
116
+ const bucket = keyIndex.get(key);
117
+ if (bucket) for (const sub of bucket) out.add(sub);
118
+ }
119
+ return out;
120
+ };
121
+
122
+ export const depsOf = (sub) => subDeps.get(sub);
123
+
124
+ // Force a subscriber into the conservative always-bucket. Batch/compiled
125
+ // iterations use this: their row HTML is produced by a compiled function
126
+ // closing over every state key, so "re-evaluate on any change" is exactly
127
+ // the walk's contract for them (the identical-HTML guard absorbs no-ops).
128
+ export const markAlways = (sub) => {
129
+ const prev = subDeps.get(sub);
130
+ if (prev) dropFromBuckets(sub, prev);
131
+ alwaysSubs.add(sub);
132
+ subDeps.set(sub, new Set());
133
+ };
134
+
135
+ export const unsubscribe = (sub) => {
136
+ const prev = subDeps.get(sub);
137
+ if (prev) dropFromBuckets(sub, prev);
138
+ alwaysSubs.delete(sub);
139
+ subDeps.delete(sub);
140
+ };
141
+
142
+ // Drop every subscriber whose anchor node has left the document. Called on
143
+ // teardown batches (mutation removals, iteration row release, branch
144
+ // unmount) — O(subscribers), same cost class as the manifest sweep.
145
+ export const pruneDisconnected = () => {
146
+ for (const sub of subDeps.keys()) {
147
+ if (sub.anchor && !sub.anchor.isConnected) unsubscribe(sub);
148
+ }
149
+ };
150
+
151
+ // Called at the top of a state flush with the changed root keys. Returns the
152
+ // dirty set — every live subscriber of those keys plus the conservative
153
+ // always-bucket. Dead subscribers found here unsubscribe on the spot (the
154
+ // lazy self-prune).
155
+ export const beginFlush = (changedKeys) => {
156
+ const dirty = new Set();
157
+ for (const sub of subscribersOf(changedKeys)) {
158
+ if (sub.anchor && sub.anchor.isConnected === false) {
159
+ unsubscribe(sub);
160
+ continue;
161
+ }
162
+ dirty.add(sub);
163
+ }
164
+ return dirty;
165
+ };
166
+
167
+ export const resetTracking = () => {
168
+ windows = [];
169
+ active = null;
170
+ keyIndex.clear();
171
+ alwaysSubs.clear();
172
+ subDeps.clear();
173
+ };