@ape-egg/vibe 2.3.0 → 3.0.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.
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
@@ -1,7 +1,27 @@
1
- import { resolvePath, deepEqual } from "./iteration-utils.js";
2
- import { extractDependencies } from "./conditionals.js";
1
+ import { resolvePath, deepEqual, iterPropWrappersOf } from "./iteration-utils.js";
3
2
  import { BINDING_REGEX } from "./constants.js";
4
- import { evalInScope, resolveThisPath, ownKeysOf, scopedOverlayOf } from "./utils.js";
3
+ import { liveNode, isOutgoing } from "./staging.js";
4
+ import { beginSpeculative, endSpeculative } from "./debug.js";
5
+ import {
6
+ evalInScope,
7
+ resolveThisPath,
8
+ ownKeysOf,
9
+ scopedOverlayOf,
10
+ rememberOverlay,
11
+ overlayKeysOf,
12
+ } from "./utils.js";
13
+ import { beginTracking, endTracking, unsubscribe } from "./tracking.js";
14
+
15
+ // Drop the subscribers of a stale binding-cache list. A cache rebuild means
16
+ // the parsed source or element changed — the old binding objects are dead,
17
+ // but their anchors may still be connected (a reparse of a live element), so
18
+ // disconnect-pruning alone would leave them serving stale patches.
19
+ const unsubscribeBindings = (list) => {
20
+ if (!list) return;
21
+ for (let i = 0; i < list.length; i++) {
22
+ if (list[i]._sub) unsubscribe(list[i]._sub);
23
+ }
24
+ };
5
25
 
6
26
  // The @[...] bindings in a tree node are static: the parsed template string and
7
27
  // the element's component scope don't change between renders. Re-running the
@@ -11,6 +31,7 @@ import { evalInScope, resolveThisPath, ownKeysOf, scopedOverlayOf } from "./util
11
31
  // and element so a re-parse or element swap recomputes.
12
32
  const textBindingsOf = (tree) => {
13
33
  if (tree._tbSrc === tree.parsed && tree._tbEl === tree.element) return tree._tb;
34
+ unsubscribeBindings(tree._tb);
14
35
  const out = [];
15
36
  BINDING_REGEX.lastIndex = 0;
16
37
  let m;
@@ -24,6 +45,9 @@ const textBindingsOf = (tree) => {
24
45
  tokens: tokensOf(resolvedInner),
25
46
  });
26
47
  }
48
+ // Back-ref so dispatch can expand one dirty binding to its whole text
49
+ // span (a text node hydrates all-or-nothing).
50
+ for (let i = 0; i < out.length; i++) out[i]._group = out;
27
51
  tree._tb = out;
28
52
  tree._tbSrc = tree.parsed;
29
53
  tree._tbEl = tree.element;
@@ -32,6 +56,7 @@ const textBindingsOf = (tree) => {
32
56
 
33
57
  const attrBindingsOf = (tree) => {
34
58
  if (tree._abSrc === tree.attributes && tree._abEl === tree.element) return tree._ab;
59
+ if (tree._ab) for (const entry of tree._ab) unsubscribeBindings(entry.matches);
35
60
  const out = [];
36
61
  for (const attrName in tree.attributes) {
37
62
  const attrValue = tree.attributes[attrName];
@@ -55,6 +80,7 @@ const attrBindingsOf = (tree) => {
55
80
  // text/attr binding caches above.
56
81
  const nameBindingsOf = (tree) => {
57
82
  if (tree._nbSrc === tree.nameBindings && tree._nbEl === tree.element) return tree._nb;
83
+ unsubscribeBindings(tree._nb);
58
84
  const out = [];
59
85
  for (const nameBinding of tree.nameBindings) {
60
86
  BINDING_REGEX.lastIndex = 0;
@@ -74,6 +100,12 @@ const nameBindingsOf = (tree) => {
74
100
  const evaluateCondition = (expression, state, element = null) =>
75
101
  !!evalInScope(expression, state, element);
76
102
 
103
+ // One subscriber per conditional/iteration tree node, hosted on the node —
104
+ // stable across flushes, dead with the node. The start comment anchors
105
+ // liveness (disconnect-pruning).
106
+ export const nodeSubscriberOf = (tree, kind) =>
107
+ (tree._sub ??= { kind, node: tree, anchor: tree.meta.startComment });
108
+
77
109
  // Helper function to check if a match references a specific key.
78
110
  // Fast path: exact match or property access (`user.name` matches `user`).
79
111
  // Slow path: word-boundary search for complex expressions like `Math.floor(coins / 100)`
@@ -124,14 +156,17 @@ const keySetOf = (snapshot) => {
124
156
  // A binding is affected on update when it reads a state key whose value changed,
125
157
  // OR when it reads no known state key at all (can't prove it's unaffected, so
126
158
  // re-evaluate — hydrate self-guards the DOM write). False can only be returned
127
- // when every key the binding reads is present AND unchanged.
159
+ // when every key the binding reads is present AND unchanged. A key absent from
160
+ // the OLD snapshot is a change even when its new value is undefined — mount
161
+ // walks diff against {}, and skipping there means the binding never reaches
162
+ // tracked() and never registers a subscriber.
128
163
  const bindingAffected = (tokens, keySet, state, newState) => {
129
164
  let matched = false;
130
165
  for (let i = 0; i < tokens.length; i++) {
131
166
  const t = tokens[i];
132
167
  if (keySet.has(t)) {
133
168
  matched = true;
134
- if (state[t] !== newState[t]) return true;
169
+ if (!(t in state) || state[t] !== newState[t]) return true;
135
170
  }
136
171
  }
137
172
  return !matched;
@@ -162,7 +197,7 @@ const nameBindingAffected = (tokens, keyMap, state, newState) => {
162
197
  const realKey = keyMap.get(tokens[i]);
163
198
  if (realKey !== undefined) {
164
199
  matched = true;
165
- if (state[realKey] !== newState[realKey]) return true;
200
+ if (!(realKey in state) || state[realKey] !== newState[realKey]) return true;
166
201
  }
167
202
  }
168
203
  return !matched;
@@ -178,16 +213,6 @@ const nameBindingAffected = (tokens, keyMap, state, newState) => {
178
213
  // churn when the value didn't actually flip.
179
214
  const CALL_EXPR_REGEX = /\b[A-Za-z_$][\w$]*\s*\(/;
180
215
 
181
- // Resolve a clone-list entry to its live counterpart. processComponent swaps
182
- // the original `<component src>` for a post-process `<component>` wrapper and
183
- // records the new node on the original via `_vibeReplacedBy`. Mirrors
184
- // `liveCloneNode` in iterate.js — kept inline to avoid a circular import.
185
- const liveCloneNode = (node) => {
186
- let cur = node;
187
- while (cur && cur._vibeReplacedBy) cur = cur._vibeReplacedBy;
188
- return cur;
189
- };
190
-
191
216
  // Descend into any inlined `<component>` parsed trees attached to an
192
217
  // iteration row. `_vibeIterTree` is stamped on the post-process wrapper by
193
218
  // index.js (mutation-observer path) and on nested inlined components reached
@@ -202,15 +227,13 @@ const walkInlinedComponentTrees = (
202
227
  depth,
203
228
  ) => {
204
229
  for (let n = 0; n < clonedNodes.length; n++) {
205
- const node = liveCloneNode(clonedNodes[n]);
230
+ const node = liveNode(clonedNodes[n]);
206
231
  if (!node || node.nodeType !== 1) continue;
207
232
  const wrappers = [];
208
233
  if (node._vibeIterTree) wrappers.push(node);
209
- const found = node.querySelectorAll?.('[data-vibe-iter-prop]');
210
- if (found) {
211
- for (let i = 0; i < found.length; i++) {
212
- if (found[i]._vibeIterTree) wrappers.push(found[i]);
213
- }
234
+ const found = iterPropWrappersOf(node);
235
+ for (let i = 0; i < found.length; i++) {
236
+ if (found[i]._vibeIterTree) wrappers.push(found[i]);
214
237
  }
215
238
  for (let w = 0; w < wrappers.length; w++) {
216
239
  recursive(
@@ -225,6 +248,18 @@ const walkInlinedComponentTrees = (
225
248
  }
226
249
  };
227
250
 
251
+ // One affected() pass can reach the same parsed tree through several linkage
252
+ // channels — a conditional walks both its runtime-mounted branch and its
253
+ // processMutations-linked children, an iteration row's inlined component tree
254
+ // hangs off the wrapper's `_vibeIterTree` AND off an ancestor's children. Each
255
+ // duplicate walk multiplies at every nesting level (measured ~1000 visits per
256
+ // node per flush on the game's armory page). A tree object is one DOM position
257
+ // — the first visit already collected its entries, so the rest are skipped.
258
+ // Scoped states stay correct: a row-scoped tree is reached through its owning
259
+ // iteration first (document order), and inlined component trees carry
260
+ // registry-rewritten expressions that evaluate identically from any channel.
261
+ let walkVisited = null;
262
+
228
263
  const recursive = (
229
264
  tree,
230
265
  state,
@@ -233,6 +268,10 @@ const recursive = (
233
268
  scopedStateForHydration = null,
234
269
  depth = 0,
235
270
  ) => {
271
+ if (walkVisited) {
272
+ if (walkVisited.has(tree)) return affected;
273
+ walkVisited.add(tree);
274
+ }
236
275
  // Handle iteration nodes specially
237
276
  if (tree.type === "iteration") {
238
277
  const resolvedExpr = resolveThisPath(
@@ -243,22 +282,48 @@ const recursive = (
243
282
  const oldArray =
244
283
  evalInScope(resolvedExpr, state, tree.meta.startComment?.parentElement) ??
245
284
  resolvePath(state, resolvedExpr);
285
+ // The new-side eval is the iteration's subscription: whatever the
286
+ // arrayPath expression reads NOW is what should notify it. Old-side
287
+ // evals never track — they read the previous snapshot.
288
+ const iterSub = nodeSubscriberOf(tree, "iteration");
289
+ iterSub.lastScope = newState;
290
+ beginTracking(iterSub, overlayKeysOf(newState));
246
291
  const newArray =
247
292
  evalInScope(
248
293
  resolvedExpr,
249
294
  newState,
250
295
  tree.meta.startComment?.parentElement,
251
296
  ) ?? resolvePath(newState, resolvedExpr);
297
+ endTracking();
252
298
 
253
299
  // Fast path: reference comparison (arrays are typically replaced, not mutated)
254
- // This avoids expensive O(n) deepEqual for large arrays
300
+ // This avoids expensive O(n) deepEqual for large arrays.
301
+ //
302
+ // Call-expression array paths (`<!-- each filterItems(...) -->`, `.filter()`
303
+ // chains) build a FRESH array on every eval, so the two evals above never
304
+ // share a reference even when nothing relevant changed — the ref compare
305
+ // alone would flag every such iteration as a full array change on every
306
+ // flush and rewalk all its rows (the armory 900ms-per-tick hang). For call
307
+ // expressions only, same length + same item refs ⇒ the rendered rows are
308
+ // already right: fall through to the per-instance walk below, which still
309
+ // catches outer-state bindings inside rows. Plain paths keep pure ref
310
+ // semantics — `$.items = [...$.items]` after an in-place item mutation must
311
+ // still refresh every row (the documented reassign-to-rerender pattern).
255
312
  if (oldArray !== newArray) {
256
- affected.push({
257
- type: "iteration",
258
- node: tree,
259
- changeType: "array",
260
- });
261
- return affected;
313
+ const freshRefArtifact =
314
+ CALL_EXPR_REGEX.test(resolvedExpr) &&
315
+ Array.isArray(oldArray) &&
316
+ Array.isArray(newArray) &&
317
+ oldArray.length === newArray.length &&
318
+ oldArray.every((item, i) => item === newArray[i]);
319
+ if (!freshRefArtifact) {
320
+ affected.push({
321
+ type: "iteration",
322
+ node: tree,
323
+ changeType: "array",
324
+ });
325
+ return affected;
326
+ }
262
327
  }
263
328
 
264
329
  // Array didn't change, but check for affected elements inside iteration instances
@@ -312,6 +377,15 @@ const recursive = (
312
377
  const mergedNewState = overlay
313
378
  ? { ...newState, ...overlay }
314
379
  : { ...instance.scopedState, ...newState };
380
+ // Carry the alias set onto the merged plain snapshots so tracking
381
+ // windows opened against them can tell alias reads from global
382
+ // reads (skipKeys). Without the recorded overlay (legacy scoped
383
+ // states) alias names subscribe as phantom keys — harmless, they
384
+ // never appear in changedProps.
385
+ if (overlay) {
386
+ rememberOverlay(mergedOldState, overlay);
387
+ rememberOverlay(mergedNewState, overlay);
388
+ }
315
389
  // scopedStateForHydration must reflect the new state so hydrate's
316
390
  // bindings inside the row see post-update values. instance.scopedState
317
391
  // is frozen against whatever target renderIteration was called with
@@ -360,11 +434,18 @@ const recursive = (
360
434
  state,
361
435
  tree.meta.startComment?.parentElement,
362
436
  );
437
+ // New-side eval doubles as the conditional's subscription (see the
438
+ // iteration note above). Helper bodies reading live `$` record through
439
+ // the proxy trap even though the direct reads here hit the snapshot.
440
+ const condSub = nodeSubscriberOf(tree, "conditional");
441
+ condSub.lastScope = newState;
442
+ beginTracking(condSub, overlayKeysOf(newState));
363
443
  const newValue = evaluateCondition(
364
444
  tree.meta.expression,
365
445
  newState,
366
446
  tree.meta.startComment?.parentElement,
367
447
  );
448
+ endTracking();
368
449
 
369
450
  // Check if condition result changed
370
451
  if (oldValue !== newValue) {
@@ -381,7 +462,10 @@ const recursive = (
381
462
  changeType: "expression",
382
463
  scopedState: newState,
383
464
  oldScopedState: state,
465
+ wasConnected: !!tree.meta.startComment?.isConnected,
384
466
  });
467
+ // The branch swap re-renders everything beneath — no point collecting
468
+ // entries for content that's about to be replaced.
385
469
  return affected;
386
470
  }
387
471
 
@@ -393,6 +477,8 @@ const recursive = (
393
477
  // this cycle so updateConditional re-runs against live state; if the
394
478
  // result hasn't really flipped, mountBranch's branchChanged guard absorbs
395
479
  // the call cheaply. Skip during initial hydration (state === newState).
480
+ // Does NOT stop the walk: the branch is (almost always) unchanged, so the
481
+ // content beneath still needs its own entries collected below.
396
482
  if (state !== newState && CALL_EXPR_REGEX.test(tree.meta.expression)) {
397
483
  const stateKeys = Object.keys(state);
398
484
  const anyChanged = stateKeys.some((k) => state[k] !== newState[k]);
@@ -403,14 +489,24 @@ const recursive = (
403
489
  changeType: "expression-fn-call",
404
490
  scopedState: newState,
405
491
  oldScopedState: state,
492
+ wasConnected: !!tree.meta.startComment?.isConnected,
406
493
  });
407
- return affected;
408
494
  }
409
495
  }
410
496
 
411
- // Condition didn't change, check for affected elements inside active branch
497
+ // Condition didn't change: walk the live content. Runtime-mounted
498
+ // branches live in runtime.activeInstance.parsedTree. COMPILED
499
+ // pre-rendered conditionals never went through mountBranch — their live
500
+ // DOM is what parse put in `children` — and processMutations links
501
+ // later-mounted subtrees (SPA fragments, slot-projected content) into
502
+ // `children` too. Both channels must be walked or content under a
503
+ // compiled conditional is frozen at its mount-time render (the game's
504
+ // blank fragment pages). Children whose root isn't connected are the
505
+ // detached both-branch template nodes of a runtime conditional — skip
506
+ // those; their restoration templates are strings on meta.branches, and
507
+ // hydrating detached template DOM is pure waste.
412
508
  if (tree.runtime.activeInstance && tree.runtime.activeInstance.parsedTree) {
413
- return recursive(
509
+ recursive(
414
510
  tree.runtime.activeInstance.parsedTree,
415
511
  state,
416
512
  newState,
@@ -419,6 +515,15 @@ const recursive = (
419
515
  depth + 1,
420
516
  );
421
517
  }
518
+ if (tree.children) {
519
+ for (const key in tree.children) {
520
+ const child = tree.children[key];
521
+ if (!child || typeof child !== "object") continue;
522
+ const root = child.element ?? child.textNode;
523
+ if (!root || !root.isConnected) continue;
524
+ recursive(child, state, newState, affected, scopedStateForHydration, depth + 1);
525
+ }
526
+ }
422
527
 
423
528
  return affected;
424
529
  }
@@ -450,6 +555,8 @@ const recursive = (
450
555
  element: tree.element,
451
556
  textNode: tree.textNode, // Reference to specific text node (prevents wiping children)
452
557
  scopedState: scopedStateForHydration, // Pass scoped state from iteration context
558
+ binding: m, // Stable per-binding host — hydrate registers its subscriber here
559
+ wasConnected: !!(tree.textNode ?? tree.element)?.isConnected,
453
560
  });
454
561
  }
455
562
  }
@@ -475,6 +582,8 @@ const recursive = (
475
582
  matchInner: m.inner, // Keep original, evalInScope will resolve this.
476
583
  element: tree.element,
477
584
  scopedState: scopedStateForHydration, // Pass scoped state from iteration context
585
+ binding: m,
586
+ wasConnected: !!tree.element?.isConnected,
478
587
  });
479
588
  }
480
589
  }
@@ -498,14 +607,18 @@ const recursive = (
498
607
  matchInner: m.inner, // Keep original, evalInScope will resolve this.
499
608
  element: tree.element,
500
609
  scopedState: scopedStateForHydration,
610
+ binding: m,
611
+ wasConnected: !!tree.element?.isConnected,
501
612
  });
502
613
  }
503
614
  }
504
615
  }
505
616
 
506
- // Process children
617
+ // Process children — except under an outgoing wrapper, whose subtree is
618
+ // frozen until the swap (the node's own bindings above stay live: the next
619
+ // navigation must still re-trigger the remount through them).
507
620
  const children = tree.children;
508
- if (children) {
621
+ if (children && !isOutgoing(tree.element)) {
509
622
  for (const key in children) {
510
623
  const child = children[key];
511
624
  if (child && typeof child === "object") {
@@ -524,7 +637,17 @@ const recursive = (
524
637
  return affected;
525
638
  };
526
639
 
640
+ // The whole collect is a speculative context: evaluations here probe "did
641
+ // this change?" against snapshots where scoped aliases may legitimately be
642
+ // absent — expected throws, not author errors (debug.js reportEvalError
643
+ // stays quiet inside).
527
644
  export default (tree, state, newState) => {
528
- const affected = recursive(tree, state, newState, [], null, 0);
529
- return affected;
645
+ walkVisited = new WeakSet();
646
+ beginSpeculative();
647
+ try {
648
+ return recursive(tree, state, newState, [], null, 0);
649
+ } finally {
650
+ endSpeculative();
651
+ walkVisited = null;
652
+ }
530
653
  };
@@ -45,7 +45,51 @@ export const shouldCleanup = (rootElement) => {
45
45
  }
46
46
  }
47
47
 
48
- // 3. All processing appears complete
48
+ // 3. Check for unhydrated ATTRIBUTE bindings — style="width: @[…]%" is
49
+ // invalid CSS until hydrated (width computes as auto → a full-width bar),
50
+ // so revealing on text-settled alone paints attribute-only content wrong
51
+ // (the game's "Making potion" loader flashed full on every navigation).
52
+ // data-vibe-* attributes are runtime transport that legitimately carries
53
+ // raw bindings across remounts, and src/key on a component wrapper are the
54
+ // wrapper's own remount contract — both excluded.
55
+ const elementWalker = document.createTreeWalker(rootElement, NodeFilter.SHOW_ELEMENT, {
56
+ acceptNode(el) {
57
+ let parent = el;
58
+ while (parent && parent !== rootElement) {
59
+ if (NON_REACTIVE_ELEMENTS.includes(parent.nodeName)) {
60
+ return NodeFilter.FILTER_REJECT;
61
+ }
62
+ if (parent.hasAttribute(DEHYDRATE_CLASS_OR_ATTR) || parent.classList?.contains(DEHYDRATE_CLASS_OR_ATTR)) {
63
+ return NodeFilter.FILTER_REJECT;
64
+ }
65
+ parent = parent.parentElement;
66
+ }
67
+ return NodeFilter.FILTER_ACCEPT;
68
+ },
69
+ });
70
+
71
+ let el;
72
+ while ((el = elementWalker.nextNode())) {
73
+ // A component wrapper's attributes are all its own machinery: src/key
74
+ // are the remount contract, data-vibe-* is transport, and every other
75
+ // attribute is a PROP — owned and consumed by processComponent at mount,
76
+ // parked raw by design on a declaration-form outlet whose src resolved
77
+ // to nothing. None of them are unhydrated page bindings.
78
+ if (
79
+ el.nodeName === 'COMPONENT' ||
80
+ (el.nodeName === 'DIV' && el.classList?.contains('component'))
81
+ ) {
82
+ continue;
83
+ }
84
+ for (const attr of el.attributes) {
85
+ if (attr.name.startsWith('data-vibe-')) continue;
86
+ if (/@\[.+?\]/.test(attr.value)) {
87
+ return false;
88
+ }
89
+ }
90
+ }
91
+
92
+ // 4. All processing appears complete
49
93
  return true;
50
94
  };
51
95