@ape-egg/vibe 4.0.1 → 4.1.3

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.
@@ -12,10 +12,6 @@ import {
12
12
  } from "./utils.js";
13
13
  import { beginTracking, endTracking, unsubscribe } from "./tracking.js";
14
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
15
  const unsubscribeBindings = (list) => {
20
16
  if (!list) return;
21
17
  for (let i = 0; i < list.length; i++) {
@@ -23,12 +19,6 @@ const unsubscribeBindings = (list) => {
23
19
  }
24
20
  };
25
21
 
26
- // The @[...] bindings in a tree node are static: the parsed template string and
27
- // the element's component scope don't change between renders. Re-running the
28
- // regex + resolveThisPath on every affected-walk was a large per-frame cost in
29
- // hot iterations (combat at 60fps re-extracts every binding of every row). Cache
30
- // the extracted + scope-resolved bindings on the node, keyed by the parsed source
31
- // and element so a re-parse or element swap recomputes.
32
22
  const textBindingsOf = (tree) => {
33
23
  if (tree._tbSrc === tree.parsed && tree._tbEl === tree.element) return tree._tb;
34
24
  unsubscribeBindings(tree._tb);
@@ -45,8 +35,6 @@ const textBindingsOf = (tree) => {
45
35
  tokens: tokensOf(resolvedInner),
46
36
  });
47
37
  }
48
- // Back-ref so dispatch can expand one dirty binding to its whole text
49
- // span (a text node hydrates all-or-nothing).
50
38
  for (let i = 0; i < out.length; i++) out[i]._group = out;
51
39
  tree._tb = out;
52
40
  tree._tbSrc = tree.parsed;
@@ -75,9 +63,6 @@ const attrBindingsOf = (tree) => {
75
63
  return out;
76
64
  };
77
65
 
78
- // Name bindings (`<icon @[fx.icon]>`) live as raw strings on tree.nameBindings.
79
- // Extract + this-resolve + tokenize them once, same cache discipline as the
80
- // text/attr binding caches above.
81
66
  const nameBindingsOf = (tree) => {
82
67
  if (tree._nbSrc === tree.nameBindings && tree._nbEl === tree.element) return tree._nb;
83
68
  unsubscribeBindings(tree._nb);
@@ -96,20 +81,12 @@ const nameBindingsOf = (tree) => {
96
81
  return out;
97
82
  };
98
83
 
99
- // Evaluate conditional expression
100
84
  const evaluateCondition = (expression, state, element = null) =>
101
85
  !!evalInScope(expression, state, element);
102
86
 
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
87
  export const nodeSubscriberOf = (tree, kind) =>
107
88
  (tree._sub ??= { kind, node: tree, anchor: tree.meta.startComment });
108
89
 
109
- // Helper function to check if a match references a specific key.
110
- // Fast path: exact match or property access (`user.name` matches `user`).
111
- // Slow path: word-boundary search for complex expressions like `Math.floor(coins / 100)`
112
- // where the key appears as an identifier anywhere in the expression.
113
90
  const isIdentChar = (c) =>
114
91
  (c >= "a" && c <= "z") ||
115
92
  (c >= "A" && c <= "Z") ||
@@ -117,12 +94,6 @@ const isIdentChar = (c) =>
117
94
  c === "_" ||
118
95
  c === "$";
119
96
 
120
- // Split an expression into its identifier tokens — every maximal run of
121
- // identifier characters. This is exactly the set of standalone identifiers the
122
- // old per-key `matchesKey` scan could match, so intersecting these tokens with
123
- // the state-key set is equivalent to "which state keys does this binding read"
124
- // for identifier-named keys, but costs O(expr length) once (cached) instead of
125
- // O(stateKeys) on every frame. Deduped so the per-frame change check stays tight.
126
97
  const tokensOf = (expr) => {
127
98
  const seen = new Set();
128
99
  let i = 0;
@@ -138,11 +109,6 @@ const tokensOf = (expr) => {
138
109
  return [...seen];
139
110
  };
140
111
 
141
- // Memoize the own-key set of a state snapshot so binding checks do O(1)
142
- // membership tests instead of scanning the key array. Keyed by the snapshot
143
- // object itself — scoped-state proxies and merged plain objects are reused
144
- // across every node of a single affected() descent, so the Set is built once
145
- // per snapshot, not once per binding.
146
112
  const keySetCache = new WeakMap();
147
113
  const keySetOf = (snapshot) => {
148
114
  let set = keySetCache.get(snapshot);
@@ -153,13 +119,6 @@ const keySetOf = (snapshot) => {
153
119
  return set;
154
120
  };
155
121
 
156
- // A binding is affected on update when it reads a state key whose value changed,
157
- // OR when it reads no known state key at all (can't prove it's unaffected, so
158
- // re-evaluate — hydrate self-guards the DOM write). False can only be returned
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.
163
122
  const bindingAffected = (tokens, keySet, state, newState) => {
164
123
  let matched = false;
165
124
  for (let i = 0; i < tokens.length; i++) {
@@ -172,10 +131,6 @@ const bindingAffected = (tokens, keySet, state, newState) => {
172
131
  return !matched;
173
132
  };
174
133
 
175
- // Name bindings (`<icon @[fx.convertIcon]>`) arrive lowercased from HTML while
176
- // state keys stay camelCase, so they match keys case-insensitively. This map
177
- // resolves a (possibly lowercased) token back to its real key. Memoized per
178
- // snapshot like keySetOf.
179
134
  const nameKeyMapCache = new WeakMap();
180
135
  const nameKeyMapOf = (snapshot) => {
181
136
  let map = nameKeyMapCache.get(snapshot);
@@ -203,22 +158,8 @@ const nameBindingAffected = (tokens, keyMap, state, newState) => {
203
158
  return !matched;
204
159
  };
205
160
 
206
- // Identifier-followed-by-`(` — i.e. an attempted function or method call.
207
- // Used to flag conditional expressions whose result can't be trusted to the
208
- // snapshot-equality short-circuit because the called function may read live
209
- // `$` (or other state-holding globals) via closure rather than the state
210
- // snapshot affected.js passes in. False positives are acceptable: when the
211
- // expression contains a call, we fall back to "if any state key changed,
212
- // re-evaluate"; mountBranch's branchChanged guard suppresses the no-op DOM
213
- // churn when the value didn't actually flip.
214
161
  const CALL_EXPR_REGEX = /\b[A-Za-z_$][\w$]*\s*\(/;
215
162
 
216
- // Descend into any inlined `<component>` parsed trees attached to an
217
- // iteration row. `_vibeIterTree` is stamped on the post-process wrapper by
218
- // index.js (mutation-observer path) and on nested inlined components reached
219
- // via `[data-vibe-iter-prop]`. The trees retain the original `@[…]` binding
220
- // text so this affected walk can flag changes the same way it would on the
221
- // row's own tree.
222
163
  const walkInlinedComponentTrees = (
223
164
  clonedNodes,
224
165
  state,
@@ -248,16 +189,6 @@ const walkInlinedComponentTrees = (
248
189
  }
249
190
  };
250
191
 
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
192
  let walkVisited = null;
262
193
 
263
194
  const recursive = (
@@ -272,7 +203,6 @@ const recursive = (
272
203
  if (walkVisited.has(tree)) return affected;
273
204
  walkVisited.add(tree);
274
205
  }
275
- // Handle iteration nodes specially
276
206
  if (tree.type === "iteration") {
277
207
  const resolvedExpr = resolveThisPath(
278
208
  tree.meta.arrayPath,
@@ -282,9 +212,6 @@ const recursive = (
282
212
  const oldArray =
283
213
  evalInScope(resolvedExpr, state, tree.meta.startComment?.parentElement) ??
284
214
  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
215
  const iterSub = nodeSubscriberOf(tree, "iteration");
289
216
  iterSub.lastScope = newState;
290
217
  beginTracking(iterSub, overlayKeysOf(newState));
@@ -296,27 +223,6 @@ const recursive = (
296
223
  ) ?? resolvePath(newState, resolvedExpr);
297
224
  endTracking();
298
225
 
299
- // Fast path: reference comparison (arrays are typically replaced, not mutated)
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).
312
- //
313
- // "Already right" additionally requires the RENDERED rows to correspond to
314
- // the array: an iteration whose initial render evaluated before its
315
- // component's script settled (a fetched compiled fragment defining the
316
- // helper) bailed with zero instances — its two evals now agree ("2, 6"
317
- // literals produce equal fresh arrays every flush) but nothing is on
318
- // screen, and skipping here would strand it empty forever (the game's
319
- // "# Teams" BoxRange). Instance count vs array length is that invariant.
320
226
  if (oldArray !== newArray) {
321
227
  const freshRefArtifact =
322
228
  CALL_EXPR_REGEX.test(resolvedExpr) &&
@@ -337,17 +243,7 @@ const recursive = (
337
243
  }
338
244
  }
339
245
 
340
- // Array didn't change, but check for affected elements inside iteration instances
341
- // (e.g., when tutorialProgress changes, need to update checkmarks in menu items)
342
246
  if (tree.runtime.instances) {
343
- // Compiled iterations have instances without tree/scopedState — the batch function
344
- // may reference global state keys (e.g., `selectedCategory` in a button binding).
345
- // If any state key changed, trigger a rebuild so the batch function re-evaluates.
346
- // Instances rendered without a per-row parsed tree — compiled (batch
347
- // fn from the manifest) or runtime batch (fast path for simple
348
- // templates) — can't be walked by the recursive descent below to
349
- // detect nested outer-state bindings. Mark the whole iteration as
350
- // affected so updateIteration runs and rebuilds the rows.
351
247
  const hasTreelessInstances =
352
248
  tree.runtime.instances.length > 0 &&
353
249
  !tree.runtime.instances[0].tree;
@@ -372,17 +268,6 @@ const recursive = (
372
268
 
373
269
  for (const instance of tree.runtime.instances) {
374
270
  if (instance.tree && instance.scopedState) {
375
- // Build plain-object snapshots for comparison. Spreading
376
- // instance.scopedState yields its local vars (item, index) plus
377
- // whatever globals were captured at scope-creation time; the
378
- // overlaying spread of state/newState then writes the *current*
379
- // values from this update cycle. Both merged states are plain
380
- // objects with up-to-date values.
381
- // The merged snapshot only needs this row's aliases overlaid on the
382
- // current globals (`state`/`newState` already carry every global key).
383
- // Recover the small overlay recorded at scope creation and plain-merge
384
- // it — spreading instance.scopedState (a Proxy) instead fires its traps
385
- // over every global key, per instance, per frame (a combat hot spot).
386
271
  const overlay = scopedOverlayOf(instance.scopedState);
387
272
  const mergedOldState = overlay
388
273
  ? { ...state, ...overlay }
@@ -390,21 +275,10 @@ const recursive = (
390
275
  const mergedNewState = overlay
391
276
  ? { ...newState, ...overlay }
392
277
  : { ...instance.scopedState, ...newState };
393
- // Carry the alias set onto the merged plain snapshots so tracking
394
- // windows opened against them can tell alias reads from global
395
- // reads (skipKeys). Without the recorded overlay (legacy scoped
396
- // states) alias names subscribe as phantom keys — harmless, they
397
- // never appear in changedProps.
398
278
  if (overlay) {
399
279
  rememberOverlay(mergedOldState, overlay);
400
280
  rememberOverlay(mergedNewState, overlay);
401
281
  }
402
- // scopedStateForHydration must reflect the new state so hydrate's
403
- // bindings inside the row see post-update values. instance.scopedState
404
- // is frozen against whatever target renderIteration was called with
405
- // (often the plain initialState snapshot from index.js), so reading
406
- // outer-state keys through it returns stale values after later
407
- // mutations. mergedNewState carries the live globals + localVars.
408
282
  recursive(
409
283
  instance.tree,
410
284
  mergedOldState,
@@ -414,16 +288,6 @@ const recursive = (
414
288
  depth + 1,
415
289
  );
416
290
 
417
- // Components mounted as `<component src>` *inside* an iteration row
418
- // are not children of `instance.tree` — their parsed tree lives on
419
- // the post-process wrapper as `_vibeIterTree`. Without descending
420
- // into it, conditionals / bindings inside the inlined component are
421
- // invisible to this walk, so a global state change that should tear
422
- // down a row-internal `<!-- if -->` (or flip a binding) inside the
423
- // component is missed. `updateInstance` re-hydrates these trees via
424
- // `hydrateInlinedIterationComponents`, but only when the iteration
425
- // itself is flagged affected (array change). For pure global-state
426
- // updates the array is unchanged, so we descend here instead.
427
291
  if (instance.clonedNodes) {
428
292
  walkInlinedComponentTrees(
429
293
  instance.clonedNodes,
@@ -440,16 +304,12 @@ const recursive = (
440
304
  return affected;
441
305
  }
442
306
 
443
- // Handle conditional nodes specially
444
307
  if (tree.type === "conditional") {
445
308
  const oldValue = evaluateCondition(
446
309
  tree.meta.expression,
447
310
  state,
448
311
  tree.meta.startComment?.parentElement,
449
312
  );
450
- // New-side eval doubles as the conditional's subscription (see the
451
- // iteration note above). Helper bodies reading live `$` record through
452
- // the proxy trap even though the direct reads here hit the snapshot.
453
313
  const condSub = nodeSubscriberOf(tree, "conditional");
454
314
  condSub.lastScope = newState;
455
315
  beginTracking(condSub, overlayKeysOf(newState));
@@ -460,15 +320,7 @@ const recursive = (
460
320
  );
461
321
  endTracking();
462
322
 
463
- // Check if condition result changed
464
323
  if (oldValue !== newValue) {
465
- // Carry the state pair the condition was evaluated against. Inside an
466
- // iteration row this is the merged state (loop alias + current globals),
467
- // outside it is the cycle's top-level state. hydrate hands these to
468
- // updateConditional so the branch swap re-evaluates the expression in
469
- // the same scope — without them, a row-internal `if` is re-evaluated
470
- // against a state with no `item`, which always reads falsy and leaves
471
- // rows that were initially on the else branch stuck there.
472
324
  affected.push({
473
325
  type: "conditional",
474
326
  node: tree,
@@ -477,21 +329,9 @@ const recursive = (
477
329
  oldScopedState: state,
478
330
  wasConnected: !!tree.meta.startComment?.isConnected,
479
331
  });
480
- // The branch swap re-renders everything beneath — no point collecting
481
- // entries for content that's about to be replaced.
482
332
  return affected;
483
333
  }
484
334
 
485
- // Function-call short-circuit: when the expression contains a `(`-style
486
- // call, the snapshot eval can be blind — the called helper may read live
487
- // `$` via closure rather than the passed state, so `oldValue` and
488
- // `newValue` both run against the *current* values and the equality check
489
- // never fires. Flag as affected whenever any state key actually changed
490
- // this cycle so updateConditional re-runs against live state; if the
491
- // result hasn't really flipped, mountBranch's branchChanged guard absorbs
492
- // the call cheaply. Skip during initial hydration (state === newState).
493
- // Does NOT stop the walk: the branch is (almost always) unchanged, so the
494
- // content beneath still needs its own entries collected below.
495
335
  if (state !== newState && CALL_EXPR_REGEX.test(tree.meta.expression)) {
496
336
  const stateKeys = Object.keys(state);
497
337
  const anyChanged = stateKeys.some((k) => state[k] !== newState[k]);
@@ -507,17 +347,6 @@ const recursive = (
507
347
  }
508
348
  }
509
349
 
510
- // Condition didn't change: walk the live content. Runtime-mounted
511
- // branches live in runtime.activeInstance.parsedTree. COMPILED
512
- // pre-rendered conditionals never went through mountBranch — their live
513
- // DOM is what parse put in `children` — and processMutations links
514
- // later-mounted subtrees (SPA fragments, slot-projected content) into
515
- // `children` too. Both channels must be walked or content under a
516
- // compiled conditional is frozen at its mount-time render (the game's
517
- // blank fragment pages). Children whose root isn't connected are the
518
- // detached both-branch template nodes of a runtime conditional — skip
519
- // those; their restoration templates are strings on meta.branches, and
520
- // hydrating detached template DOM is pure waste.
521
350
  if (tree.runtime.activeInstance && tree.runtime.activeInstance.parsedTree) {
522
351
  recursive(
523
352
  tree.runtime.activeInstance.parsedTree,
@@ -544,9 +373,6 @@ const recursive = (
544
373
  const matches = textBindingsOf(tree);
545
374
 
546
375
  if (matches.length) {
547
- // A text node is hydrated as a whole (every @[…] re-interpolated together),
548
- // so the node is all-or-nothing: if any of its bindings is affected, push
549
- // them all. Initial hydration affects everything.
550
376
  const isInitialHydration = state === newState;
551
377
  let hasAffected = isInitialHydration;
552
378
  if (!isInitialHydration) {
@@ -566,16 +392,15 @@ const recursive = (
566
392
  matchInner: m.inner,
567
393
  input: m.input,
568
394
  element: tree.element,
569
- textNode: tree.textNode, // Reference to specific text node (prevents wiping children)
570
- scopedState: scopedStateForHydration, // Pass scoped state from iteration context
571
- binding: m, // Stable per-binding host — hydrate registers its subscriber here
395
+ textNode: tree.textNode,
396
+ scopedState: scopedStateForHydration,
397
+ binding: m,
572
398
  wasConnected: !!(tree.textNode ?? tree.element)?.isConnected,
573
399
  });
574
400
  }
575
401
  }
576
402
  }
577
403
 
578
- // Check attribute bindings
579
404
  if (tree.attributes) {
580
405
  const isInitialHydration = state === newState;
581
406
  const keySet = isInitialHydration ? null : keySetOf(newState);
@@ -592,9 +417,9 @@ const recursive = (
592
417
  attrName,
593
418
  attrValue,
594
419
  matchOuter: m.outer,
595
- matchInner: m.inner, // Keep original, evalInScope will resolve this.
420
+ matchInner: m.inner,
596
421
  element: tree.element,
597
- scopedState: scopedStateForHydration, // Pass scoped state from iteration context
422
+ scopedState: scopedStateForHydration,
598
423
  binding: m,
599
424
  wasConnected: !!tree.element?.isConnected,
600
425
  });
@@ -603,7 +428,6 @@ const recursive = (
603
428
  }
604
429
  }
605
430
 
606
- // Check name bindings (bindings in attribute names)
607
431
  if (tree.nameBindings) {
608
432
  const isInitialHydration = state === newState;
609
433
  const keyMap = isInitialHydration ? null : nameKeyMapOf(newState);
@@ -617,7 +441,7 @@ const recursive = (
617
441
  type: "nameBinding",
618
442
  nameBinding: m.nameBinding,
619
443
  matchOuter: m.outer,
620
- matchInner: m.inner, // Keep original, evalInScope will resolve this.
444
+ matchInner: m.inner,
621
445
  element: tree.element,
622
446
  scopedState: scopedStateForHydration,
623
447
  binding: m,
@@ -627,9 +451,6 @@ const recursive = (
627
451
  }
628
452
  }
629
453
 
630
- // Process children — except under an outgoing wrapper, whose subtree is
631
- // frozen until the swap (the node's own bindings above stay live: the next
632
- // navigation must still re-trigger the remount through them).
633
454
  const children = tree.children;
634
455
  if (children && !isOutgoing(tree.element)) {
635
456
  for (const key in children) {
@@ -650,10 +471,6 @@ const recursive = (
650
471
  return affected;
651
472
  };
652
473
 
653
- // The whole collect is a speculative context: evaluations here probe "did
654
- // this change?" against snapshots where scoped aliases may legitimately be
655
- // absent — expected throws, not author errors (debug.js reportEvalError
656
- // stays quiet inside).
657
474
  export default (tree, state, newState) => {
658
475
  walkVisited = new WeakSet();
659
476
  beginSpeculative();
@@ -1,80 +1,40 @@
1
1
  import { debugLog } from './debug.js';
2
- import {
3
- PHASE_READY,
4
- FOUC_CLASS_OR_ATTR,
5
- DEHYDRATE_CLASS_OR_ATTR,
6
- NON_REACTIVE_ELEMENTS,
7
- } from './constants.js';
2
+ import { isInert } from './inert.js';
3
+ import { PHASE_READY, FOUC_CLASS_OR_ATTR } from './constants.js';
4
+
5
+ const skipInert = (root) => ({
6
+ acceptNode: (node) => (isInert(node, root) ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT),
7
+ });
8
8
 
9
- /**
10
- * Check if all Vibe processing is complete and cleanup can run
11
- * @param {Element} rootElement - Root element to check
12
- * @returns {Boolean} - true if cleanup should run
13
- */
14
9
  export const shouldCleanup = (rootElement) => {
15
- // 1. Check for pending <component> elements (only those with src - fetched components)
16
- // Inline component wrappers (<component> without src) are fine to remain
17
- const componentElements = rootElement.querySelectorAll('component[src], div.component[src]');
10
+ const componentElements = [
11
+ ...rootElement.querySelectorAll('component[src], div.component[src]'),
12
+ ].filter((el) => !isInert(el, rootElement));
18
13
  if (componentElements.length > 0) {
19
14
  return false;
20
15
  }
21
16
 
22
- // 2. Check for unhydrated bindings (literal @[...] in DOM, excluding dehydrated
23
- // and non-reactive elements — hydrate never touches those, so a literal inside
24
- // them is final content, not pending work)
25
- const walker = document.createTreeWalker(rootElement, NodeFilter.SHOW_TEXT, {
26
- acceptNode(node) {
27
- let parent = node.parentElement;
28
- while (parent && parent !== rootElement) {
29
- if (NON_REACTIVE_ELEMENTS.includes(parent.nodeName)) {
30
- return NodeFilter.FILTER_REJECT;
31
- }
32
- if (parent.hasAttribute(DEHYDRATE_CLASS_OR_ATTR) || parent.classList?.contains(DEHYDRATE_CLASS_OR_ATTR)) {
33
- return NodeFilter.FILTER_REJECT; // Skip dehydrated content
34
- }
35
- parent = parent.parentElement;
36
- }
37
- return NodeFilter.FILTER_ACCEPT;
38
- },
39
- });
17
+ const walker = document.createTreeWalker(
18
+ rootElement,
19
+ NodeFilter.SHOW_TEXT,
20
+ skipInert(rootElement),
21
+ );
40
22
 
41
23
  let node;
42
24
  while ((node = walker.nextNode())) {
43
- if (/@\[.+?\]/.test(node.textContent)) {
44
- return false; // Found literal binding (not in dehydrated element)
25
+ if (!node._vibeBoundValue && /@\[.+?\]/.test(node.textContent)) {
26
+ return false;
45
27
  }
46
28
  }
47
29
 
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
- });
30
+ const elementWalker = document.createTreeWalker(
31
+ rootElement,
32
+ NodeFilter.SHOW_ELEMENT,
33
+ skipInert(rootElement),
34
+ );
70
35
 
71
36
  let el;
72
37
  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
38
  if (
79
39
  el.nodeName === 'COMPONENT' ||
80
40
  (el.nodeName === 'DIV' && el.classList?.contains('component'))
@@ -83,36 +43,27 @@ export const shouldCleanup = (rootElement) => {
83
43
  }
84
44
  for (const attr of el.attributes) {
85
45
  if (attr.name.startsWith('data-vibe-')) continue;
46
+ if (el._vibeBoundAttrs?.has(attr.name)) continue;
86
47
  if (/@\[.+?\]/.test(attr.value)) {
87
48
  return false;
88
49
  }
89
50
  }
90
51
  }
91
52
 
92
- // 4. All processing appears complete
93
53
  return true;
94
54
  };
95
55
 
96
- /**
97
- * Perform cleanup - remove attribute or class from all matching elements
98
- * @param {Element} rootElement - Root element
99
- * @param {Boolean} debug - Debug mode
100
- */
101
56
  export const cleanup = (rootElement, debug = false) => {
102
- // Force reflow
103
57
  rootElement.offsetHeight;
104
58
 
105
- // Determine if selector is a class (starts with .) or attribute (default)
106
59
  const isClass = FOUC_CLASS_OR_ATTR.startsWith('.');
107
60
  const cleanName = FOUC_CLASS_OR_ATTR.replace(/^\./, '').replace(/^\[/, '').replace(/\]$/, '');
108
61
 
109
62
  if (isClass) {
110
- // Remove class from all elements in document that have it
111
63
  const elements = document.querySelectorAll(`.${cleanName}`);
112
64
  elements.forEach((el) => el.classList.remove(cleanName));
113
65
  debugLog(PHASE_READY, `Removing .${cleanName} class from ${elements.length} ${elements.length === 1 ? 'element' : 'elements'}`, debug);
114
66
  } else {
115
- // Remove attribute from all elements in document that have it
116
67
  const elements = document.querySelectorAll(`[${cleanName}]`);
117
68
  elements.forEach((el) => el.removeAttribute(cleanName));
118
69
  debugLog(
@@ -1,39 +1,7 @@
1
- // Component template cache.
2
- //
3
- // Vibe loads each `<component src="...">` by fetching its HTML template. A page
4
- // commonly mounts the same component many times (a list of cards, a row of
5
- // stat bars), and an SPA re-mounts components on every navigation. Without a
6
- // cache, each instance — and each revisit — refetches an identical template,
7
- // and a burst of same-tick mounts stampedes the network with N concurrent
8
- // requests for one file.
9
- //
10
- // This module dedupes those fetches by `src`:
11
- // - concurrent mounts in the same tick share one in-flight request, because
12
- // the PROMISE (not the resolved text) is cached synchronously before the
13
- // first await — so callers coalesce onto it instead of each starting their own
14
- // - later mounts (including after SPA navigation) resolve from memory, with
15
- // no network request at all — the one win a browser HTTP cache cannot
16
- // provide, since it revalidates per request and never coalesces concurrent ones
17
- //
18
- // The cache is session-lived and content-busted, never time-busted. In
19
- // production a component template is immutable for the life of the page (it
20
- // only changes on redeploy, which is a new session anyway), so there is nothing
21
- // to invalidate. In development, tooling busts entries on file change via
22
- // `clearComponentCache(path)` — which keeps this module free of any dev/HMR
23
- // coupling; it never references the dev server or its events.
24
- //
25
- // Disable entirely with `vibe(state, { noCache: true })`.
26
-
27
1
  let enabled = true;
28
2
 
29
- // src -> Promise<string> (raw template HTML). Stores the in-flight promise so
30
- // concurrent callers coalesce; the resolved value is held by the promise, so a
31
- // settled entry is an instant cache hit on every later read.
32
3
  const templates = new Map();
33
4
 
34
- // Configure from the runtime config (`{ noCache }`). Called once at boot. When
35
- // caching is turned off we also drop anything already cached, so toggling at
36
- // runtime (e.g. between test cases) can't serve a stale hit.
37
5
  export const configureComponentCache = (config = {}) => {
38
6
  enabled = !config?.noCache;
39
7
  if (!enabled) templates.clear();
@@ -41,19 +9,8 @@ export const configureComponentCache = (config = {}) => {
41
9
 
42
10
  export const isComponentCacheEnabled = () => enabled;
43
11
 
44
- // True when `src` will resolve without a new network request — either a settled
45
- // template or an in-flight request this mount coalesces onto. Callers capture
46
- // this BEFORE fetchComponentTemplate so the debug layer can distinguish a real
47
- // network fetch from a cache hit.
48
12
  export const isComponentCached = (src) => enabled && templates.has(src);
49
13
 
50
- // Fetch a component template, deduped by `src`. Returns a Promise<string>.
51
- //
52
- // `signal` aborts the request when the host element is removed. It is honored
53
- // only on the uncached path: a shared cached fetch must NOT be aborted by one
54
- // element unmounting while other elements still await the same template. The
55
- // caller already re-checks `el.parentNode` after the fetch settles, so dropping
56
- // the abort on the shared path costs nothing but a tiny, redundant download.
57
14
  export const fetchComponentTemplate = (src, signal) => {
58
15
  if (!enabled) {
59
16
  return fetch(src, { signal }).then((response) => response.text());
@@ -63,25 +20,15 @@ export const fetchComponentTemplate = (src, signal) => {
63
20
  if (!entry) {
64
21
  entry = fetch(src).then(async (response) => {
65
22
  const text = await response.text();
66
- // Never persist a failed response — the immediate caller still gets the
67
- // body (parity with the uncached path), but the next mount may retry.
68
23
  if (!response.ok) templates.delete(src);
69
24
  return text;
70
25
  });
71
- // Cache synchronously, before the first await, so same-tick concurrent
72
- // mounts find this pending entry and coalesce onto it.
73
26
  templates.set(src, entry);
74
- // Drop the entry if the fetch rejects, so a transient network error isn't
75
- // sticky for the rest of the session.
76
27
  entry.catch(() => templates.delete(src));
77
28
  }
78
29
  return entry;
79
30
  };
80
31
 
81
- // Invalidate cached templates. With a `path`, drops that one entry (the query
82
- // string is ignored when matching, so `/components/Foo.html` also clears a
83
- // versioned `/components/Foo.html?v=…`); with no argument, clears everything.
84
- // Exposed publicly as `$.clearComponentCache` for tooling to call on change.
85
32
  export const clearComponentCache = (path) => {
86
33
  if (!path) {
87
34
  templates.clear();