@ape-egg/vibe 3.0.2 → 3.0.5

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # Vibe
2
2
 
3
- **Version 3.0.2** — Runtime-first reactivity for plain HTML. Drop a script tag into any page and get reactive bindings, control flow, and URL-loaded components — no build step required. Compile later if you want; the compiler is a separate, optional package (`@ape-egg/vibe-compiler`).
3
+ **Version 3.0.5** — Runtime-first reactivity for plain HTML. Drop a script tag into any page and get reactive bindings, control flow, and URL-loaded components — no build step required. Compile later if you want; the compiler is a separate, optional package (`@ape-egg/vibe-compiler`).
4
4
 
5
5
  ## Security model & CSP
6
6
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ape-egg/vibe",
3
- "version": "3.0.2",
3
+ "version": "3.0.5",
4
4
  "type": "module",
5
5
  "description": "Runtime-first reactivity for plain HTML — no build step, no virtual DOM, no new syntax to learn",
6
6
  "main": "index.js",
@@ -309,18 +309,29 @@ const recursive = (
309
309
  // catches outer-state bindings inside rows. Plain paths keep pure ref
310
310
  // semantics — `$.items = [...$.items]` after an in-place item mutation must
311
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.
312
320
  if (oldArray !== newArray) {
313
321
  const freshRefArtifact =
314
322
  CALL_EXPR_REGEX.test(resolvedExpr) &&
315
323
  Array.isArray(oldArray) &&
316
324
  Array.isArray(newArray) &&
317
325
  oldArray.length === newArray.length &&
326
+ (tree.runtime.instances?.length ?? 0) === newArray.length &&
318
327
  oldArray.every((item, i) => item === newArray[i]);
319
328
  if (!freshRefArtifact) {
320
329
  affected.push({
321
330
  type: "iteration",
322
331
  node: tree,
323
332
  changeType: "array",
333
+ scopedState: newState,
334
+ oldScopedState: state,
324
335
  });
325
336
  return affected;
326
337
  }
@@ -351,6 +362,8 @@ const recursive = (
351
362
  type: "iteration",
352
363
  node: tree,
353
364
  changeType: "array",
365
+ scopedState: newState,
366
+ oldScopedState: state,
354
367
  });
355
368
  return affected;
356
369
  }
@@ -11,7 +11,7 @@ import {
11
11
  ITERATION_START_REGEX,
12
12
  } from './constants.js';
13
13
  import { evalInScope } from './utils.js';
14
- import { bumpIterPropGeneration } from './iteration-utils.js';
14
+ import { bumpIterPropGeneration, parkFetchableSrc } from './iteration-utils.js';
15
15
  import { fetchComponentTemplate, isComponentCached } from './component-cache.js';
16
16
  import { notifyChanged } from './state.js';
17
17
  import {
@@ -857,6 +857,10 @@ const processSingle = (el, debug) => {
857
857
  }
858
858
 
859
859
  newWrapper.innerHTML = transformedHtml;
860
+ // Park binding-valued src while the wrapper still lives in the inert
861
+ // document — adoption into the live document is what starts image
862
+ // loads, so this is the last moment a raw `@[...]` src is harmless.
863
+ parkFetchableSrc(newWrapper);
860
864
  if (firstComponentId !== null) {
861
865
  newWrapper.setAttribute('data-vibe-component-id', firstComponentId);
862
866
  }
@@ -1,3 +1,7 @@
1
+ // The released version — check-release.js holds this in lockstep with
2
+ // package.json, the READMEs and the CHANGELOG.
3
+ export const VERSION = '3.0.5';
4
+
1
5
  // Debug logger name
2
6
  export const DEBUGGER_NAME = '[vibe-debug]:';
3
7
  export const FOUC_CLASS_OR_ATTR = 'vibe-fouc'; // Class or attribute used to prevent FOUC (default: [vibe])
@@ -26,6 +30,16 @@ export const PHASE_HYPERSPEED = 'Hyperspeed'; // Restores @[...] markers from pr
26
30
  // Fetched components (<component src="">) are handled separately by processComponent()
27
31
  export const NON_REACTIVE_ELEMENTS = ['SCRIPT', 'HEAD', 'PRE'];
28
32
 
33
+ // Elements whose src-family attributes the browser fetches the moment they are
34
+ // set — a raw `src="@[binding]"` reaching one of these fires a network request
35
+ // for the literal binding text before hydration can stamp the real value.
36
+ // Bindings on them are parked on data-vibe-<attr> (the established transport)
37
+ // until hydration writes the evaluated URL. <component src> is NOT here — its
38
+ // src is consumed by processComponent, never by the browser.
39
+ export const FETCH_SRC_ATTRS = ['src', 'srcset', 'poster'];
40
+ export const FETCH_SRC_SELECTOR = 'img, source, iframe, video, audio, embed, track';
41
+ export const FETCH_SRC_ELEMENTS = ['IMG', 'SOURCE', 'IFRAME', 'VIDEO', 'AUDIO', 'EMBED', 'TRACK'];
42
+
29
43
  // Attributes where the string value is meaningful (should NOT be removed when falsy)
30
44
  // All other attributes are treated as boolean-like (removed when falsy, present when truthy)
31
45
  export const VALUE_ATTRS = [
@@ -1,9 +1,14 @@
1
1
  import { updateIteration } from './iterate.js';
2
2
  import { updateConditional, managedNodes } from './conditionals.js';
3
3
  import { liveComponentWrapper, remountComponent, forceRemount } from './component.js';
4
- import { isComponentWrapper, parkRootFor, parkBinding } from './staging.js';
4
+ import { isComponentWrapper, isRemountTrigger, parkRootFor, parkBinding } from './staging.js';
5
5
  import { VALUE_ATTRS, DOM_PROPERTIES, BINDING_REGEX, PURE_BINDING_REGEX } from './constants.js';
6
- import { evalInScope, resolveCaseInsensitivePath, overlayKeysOf } from './utils.js';
6
+ import {
7
+ evalInScope,
8
+ resolveCaseInsensitivePath,
9
+ overlayKeysOf,
10
+ scopedOverlayOf,
11
+ } from './utils.js';
7
12
  import { RawHtml } from './raw-html.js';
8
13
  import { beginTracking, endTracking } from './tracking.js';
9
14
  import { reportEvalError } from './debug.js';
@@ -172,11 +177,7 @@ export default (affected, state, manifest = {}, oldState = {}) => {
172
177
  const remountTriggers = [];
173
178
  const ordinary = [];
174
179
  for (const aff of affected) {
175
- const isRemountTrigger =
176
- aff.type === 'attribute' &&
177
- (aff.attrName === 'src' || aff.attrName === 'key') &&
178
- isComponentWrapper(aff.element);
179
- (isRemountTrigger ? remountTriggers : ordinary).push(aff);
180
+ (isRemountTrigger(aff) ? remountTriggers : ordinary).push(aff);
180
181
  }
181
182
 
182
183
  const processEntry = (aff) => {
@@ -199,9 +200,24 @@ export default (affected, state, manifest = {}, oldState = {}) => {
199
200
  // Use scoped state if provided (from iteration instances)
200
201
  const effectiveState = aff.scopedState || state;
201
202
 
202
- // Handle iteration updates
203
+ // Handle iteration updates. Same scoped-pair contract as conditionals
204
+ // below: a NESTED iteration re-renders its rows against the enclosing
205
+ // row's scope, so bindings reading the outer alias (`@[ability.healing]`
206
+ // inside `<!-- each ability.chainDividers as d -->`) still resolve. The
207
+ // overlay doubles as the nested parentScope — exactly what the initial
208
+ // render passes (renderAllIterations with `{...parentScope, ...localVars}`).
209
+ // Top-level iterations carry the cycle's state and no overlay, so they
210
+ // keep resolving against globals.
203
211
  if (aff.type === 'iteration') {
204
- updateIteration(aff.node, state, oldState, manifest);
212
+ const iterNewState = aff.scopedState || state;
213
+ const iterOldState = aff.oldScopedState || oldState;
214
+ updateIteration(
215
+ aff.node,
216
+ iterNewState,
217
+ iterOldState,
218
+ manifest,
219
+ scopedOverlayOf(iterNewState) || {},
220
+ );
205
221
  return;
206
222
  }
207
223
 
@@ -338,7 +354,11 @@ export default (affected, state, manifest = {}, oldState = {}) => {
338
354
  // Function replacer: a string replacement would run GetSubstitution on
339
355
  // the VALUE — `$$` collapses, `$&` re-inserts the binding text into the
340
356
  // DOM (which the settle gates then read as an unhydrated binding).
341
- const toReplace = input.replaceAll(matchOuter, () => evaluated).trim();
357
+ // No trim: the text node's source whitespace is significant — a
358
+ // prettier line-wrap after an inline end tag (`</strong>\n remain`)
359
+ // is that word's boundary, and the compiled SPA shell stamps values
360
+ // untrimmed, so trimming here glued words and diverged from the shell.
361
+ const toReplace = input.replaceAll(matchOuter, () => evaluated);
342
362
 
343
363
  affected.forEach((innerAff) => {
344
364
  if (innerAff.element === element) {
package/runtime/index.js CHANGED
@@ -9,6 +9,7 @@ import { renderAllIterations, setRenderAllConditionals, releaseOrphanedIteration
9
9
  import { renderAllConditionals, branchNodeRegistry, managedNodes , settleConditionals} from './conditionals.js';
10
10
  import { installScopeResolver } from './loop-scope.js';
11
11
  import {
12
+ VERSION,
12
13
  NON_REACTIVE_ELEMENTS,
13
14
  PHASE_ATTACH,
14
15
  PHASE_PARSE,
@@ -40,6 +41,10 @@ import {
40
41
  // Wire up cross-module dependency after all modules are loaded
41
42
  setRenderAllConditionals(renderAllConditionals);
42
43
 
44
+ // Stamped at module load, not boot — a console can read __vibe.version even
45
+ // on a page whose boot died, which is exactly when a bug report needs it.
46
+ ((globalThis.__vibe ??= {}).version = VERSION);
47
+
43
48
  // Check if node should be processed by Vibe
44
49
  const shouldProcessNode = (node) => {
45
50
  // Only process element nodes
@@ -302,6 +307,18 @@ const mergeManifests = (hyperspeedTree, runtimeTree) => {
302
307
  mergedNode.runtime = runtimeNode.runtime;
303
308
  }
304
309
 
310
+ // The runtime parse of the live DOM is the ground truth for what exists.
311
+ // A manifest child with no runtime counterpart sits at an index the DOM
312
+ // no longer agrees with (a content script prepending into <body> shifts
313
+ // every sibling) — it can never bind an element, and hydrating its
314
+ // bindings would dereference null. Drop it; the runtime-discovered
315
+ // sibling added below carries the real element.
316
+ if (mergedNode.children) {
317
+ for (const key in mergedNode.children) {
318
+ if (!runtimeNode.children?.[key]) delete mergedNode.children[key];
319
+ }
320
+ }
321
+
305
322
  // Augment children recursively
306
323
  if (runtimeNode.children) {
307
324
  if (!mergedNode.children) mergedNode.children = {};
@@ -190,17 +190,6 @@ export const compileBatchFn = (template, itemAlias, indexAlias, stateKeys, ancho
190
190
  const componentId = anchorEl ? findComponentIdForElement(anchorEl) : null;
191
191
  const domPropertyWrites = [];
192
192
 
193
- // Trim binding-bearing text nodes — the clone path writes the interpolated
194
- // text node content trimmed (hydrate.js), so the batch template must not
195
- // carry the author's indentation around a binding that sits on its own line.
196
- const textWalker = document.createTreeWalker(tplClone, NodeFilter.SHOW_TEXT);
197
- while (textWalker.nextNode()) {
198
- const textNode = textWalker.currentNode;
199
- if (textNode.textContent.includes('@[')) {
200
- textNode.textContent = textNode.textContent.trim();
201
- }
202
- }
203
-
204
193
  const allEls = tplClone.querySelectorAll('*');
205
194
  for (let n = 0; n < allEls.length; n++) {
206
195
  const el = allEls[n];
@@ -412,33 +401,91 @@ export const releaseOrphanedIterationProps = (nodes) => {
412
401
  // inherits `_vibeIterPropExprs`/`data-vibe-iter-prop`, so refreshIterationComponentProps
413
402
  // re-evaluates them on each item change). Globals-only bindings are left raw; they
414
403
  // resolve through the normal reactive path against the inlined component's scope.
404
+ const SLOT_DIRECTIVE_REGEX = /<!--\s*(if|else if|each)\s+([^]*?)\s*-->/g;
405
+
406
+ // Substitute an identifier only outside string literals — the same contract as
407
+ // component.js's prop substitution: an alias that also appears inside a quoted
408
+ // string ('materials' containing the alias name) must be left intact.
409
+ const STRING_LITERAL_REGEX = /(['"`])(?:\\.|(?!\1)[^\\])*\1/g;
410
+ const substituteOutsideStrings = (expr, idRegex, replacement) => {
411
+ let out = '';
412
+ let last = 0;
413
+ let m;
414
+ STRING_LITERAL_REGEX.lastIndex = 0;
415
+ while ((m = STRING_LITERAL_REGEX.exec(expr))) {
416
+ out += expr.slice(last, m.index).replace(idRegex, replacement) + m[0];
417
+ last = m.index + m[0].length;
418
+ }
419
+ return out + expr.slice(last).replace(idRegex, replacement);
420
+ };
421
+
415
422
  const resolveSlotContentBindings = (el, scopedState, aliases) => {
416
423
  const html = el._vibeSlotContent;
417
- if (!html || !html.includes('@[')) return;
424
+ if (!html || (!html.includes('@[') && !html.includes('<!--'))) return;
418
425
  const registry = ensureIterPropsRegistry();
419
426
  const idByExpr = new Map();
420
- const rewritten = html.replace(BINDING_REGEX, (whole, expr) => {
421
- const usesLocalScope =
422
- /\bthis\b/.test(expr) ||
423
- (aliases && extractDependencies(expr).some((d) => aliases.has(d)));
424
- if (!usesLocalScope) return whole;
427
+ // Snapshot one expression into a registry slot, reusing the slot for repeat
428
+ // occurrences. Returns undefined (leave raw) when evaluation fails or yields
429
+ // undefined — same fail-safe as resolveIterationComponentProps.
430
+ const snapshot = (expr) => {
425
431
  let id = idByExpr.get(expr);
426
432
  if (id === undefined) {
427
433
  let value;
428
434
  try {
429
435
  value = evalInScope(expr, scopedState, el);
430
436
  } catch {
431
- return whole;
437
+ return undefined;
432
438
  }
433
- if (value === undefined) return whole;
439
+ if (value === undefined) return undefined;
434
440
  id = `_p${__vibeIterPropCounter++}`;
435
441
  registry[id] = value;
436
442
  idByExpr.set(expr, id);
437
443
  (el._vibeIterPropExprs = el._vibeIterPropExprs || []).push({ id, attrName: null, expr });
438
444
  (el._vibeIterPropIds = el._vibeIterPropIds || []).push(id);
439
445
  }
440
- return `@[window.__vibe.iterProps.${id}]`;
446
+ return id;
447
+ };
448
+ let rewritten = html.replace(BINDING_REGEX, (whole, expr) => {
449
+ const usesLocalScope =
450
+ /\bthis\b/.test(expr) ||
451
+ (aliases && extractDependencies(expr).some((d) => aliases.has(d)));
452
+ if (!usesLocalScope) return whole;
453
+ const id = snapshot(expr);
454
+ return id === undefined ? whole : `@[window.__vibe.iterProps.${id}]`;
441
455
  });
456
+ // Directive comments in slot content — <!-- if -->, <!-- else if -->,
457
+ // <!-- each --> — hit the same wall as @[...] bindings: after processComponent
458
+ // inlines the template, the wrapper's subtree is re-processed against global
459
+ // state, where the loop aliases don't resolve and every branch renders empty
460
+ // (the battleborn rewards-window icons). Route each alias the expression
461
+ // reads through its own registry slot (`drop.kind === 'materials'` →
462
+ // `window.__vibe.iterProps._pN.kind === ...`), so the inlined directive
463
+ // evaluates against the row's values in any scope. Updates need no extra
464
+ // wiring: refreshIterationComponentProps re-snapshots the alias per row
465
+ // update, the rewritten expression records no state read so its subscriber
466
+ // re-dispatches from the tracking always-bucket on every flush, and the
467
+ // affected() pass over the wrapper's tree stamps that subscriber's lastScope
468
+ // with the row's merged scope — so a branch flip mounts with the loop
469
+ // aliases reachable. A registry-backed <!-- each --> is driven by
470
+ // forceRegistryBackedIterationUpdates like any other registry iteration.
471
+ if (aliases?.size) {
472
+ rewritten = rewritten.replace(SLOT_DIRECTIVE_REGEX, (whole, kw, expr) => {
473
+ // An each's alias clause introduces fresh names — only its array
474
+ // expression is rewritable.
475
+ const asMatch = kw === 'each' && expr.match(/^([^]*?)\s+as\s+([^]*)$/);
476
+ const original = asMatch ? asMatch[1] : expr;
477
+ let code = original;
478
+ for (const dep of extractDependencies(code)) {
479
+ if (!aliases.has(dep)) continue;
480
+ const id = snapshot(dep);
481
+ if (id === undefined) continue;
482
+ const idRegex = new RegExp(`(?<![\\w$.])${dep}(?![\\w$])`, 'g');
483
+ code = substituteOutsideStrings(code, idRegex, `window.__vibe.iterProps.${id}`);
484
+ }
485
+ if (code === original) return whole;
486
+ return `<!-- ${kw} ${asMatch ? `${code} as ${asMatch[2]}` : code} -->`;
487
+ });
488
+ }
442
489
  if (idByExpr.size) {
443
490
  el._vibeSlotContent = rewritten;
444
491
  el.setAttribute('data-vibe-iter-prop', '');
@@ -588,6 +635,13 @@ const refreshIterationComponentProps = (clonedNodes, scopedState) => {
588
635
  // in sync. Without this, an `<inner-component>` whose template iterates over
589
636
  // an array prop stays frozen on its initial-render items when the prop's
590
637
  // contents change.
638
+ // Registry-backed CONDITIONALS (rewritten by resolveSlotContentBindings) need
639
+ // no counterpart here: their evaluation records no state read, so they sit in
640
+ // the tracking always-bucket and re-dispatch on every flush — and the
641
+ // affected() pass below stamps their subscriber's lastScope with this row's
642
+ // merged scope, so the dispatch flips the branch with the loop aliases in
643
+ // reach (letting the mounting branch hydrate alias bindings whose build-time
644
+ // snapshot was undefined, e.g. drop.item.icon before an icon exists).
591
645
  const REGISTRY_SLOT_REGEX = /__vibe\.iterProps\.(_p\d+)/;
592
646
  const forceRegistryBackedIterationUpdates = (tree, state, manifest, parentScope, changedSlots) => {
593
647
  if (!tree) return;
@@ -1,5 +1,32 @@
1
1
  // Utility functions for array iteration
2
- import { ITERATION_REGEX, ITERATION_START_REGEX, CONDITIONAL_START_REGEX } from './constants.js';
2
+ import {
3
+ ITERATION_REGEX,
4
+ ITERATION_START_REGEX,
5
+ CONDITIONAL_START_REGEX,
6
+ BINDING_REGEX,
7
+ FETCH_SRC_ATTRS,
8
+ FETCH_SRC_SELECTOR,
9
+ } from './constants.js';
10
+
11
+ // Move binding-valued src/srcset/poster on browser-fetchable elements to
12
+ // data-vibe-<attr> so the literal `@[...]` text never becomes a fetchable URL.
13
+ // Called on subtrees that are still in the inert document (component finalize)
14
+ // — parse.js recaptures the parked binding under the real attribute name and
15
+ // hydration writes the evaluated URL, which is the first value the browser
16
+ // ever sees.
17
+ export const parkFetchableSrc = (root) => {
18
+ const elements = root.querySelectorAll(FETCH_SRC_SELECTOR);
19
+ for (let i = 0; i < elements.length; i++) {
20
+ for (const attr of FETCH_SRC_ATTRS) {
21
+ const value = elements[i].getAttribute(attr);
22
+ BINDING_REGEX.lastIndex = 0;
23
+ if (value && BINDING_REGEX.test(value)) {
24
+ elements[i].setAttribute(`data-vibe-${attr}`, value);
25
+ elements[i].removeAttribute(attr);
26
+ }
27
+ }
28
+ }
29
+ };
3
30
 
4
31
  // Parse an `each` directive body (the text inside `<!-- ... -->`, markers
5
32
  // stripped) into its parts, or null when it isn't a valid each. The index alias
package/runtime/parse.js CHANGED
@@ -13,6 +13,8 @@ import {
13
13
  DEHYDRATE_CLASS_OR_ATTR,
14
14
  THIS_PROP_REGEX,
15
15
  STATE_THIS_PROP_REGEX,
16
+ FETCH_SRC_ATTRS,
17
+ FETCH_SRC_ELEMENTS,
16
18
  } from './constants.js';
17
19
  import { rewriteHandlerAliases } from './loop-scope.js';
18
20
 
@@ -75,6 +77,8 @@ const captureAttributeBindings = (element, aliasSet) => {
75
77
 
76
78
  const attributes = {};
77
79
  const nameBindings = [];
80
+ const isFetchableElement = FETCH_SRC_ELEMENTS.includes(nodeName);
81
+ let srcToPark = null;
78
82
 
79
83
  for (let j = 0; j < element.attributes.length; j++) {
80
84
  const attr = element.attributes[j];
@@ -88,6 +92,31 @@ const captureAttributeBindings = (element, aliasSet) => {
88
92
  continue;
89
93
  }
90
94
 
95
+ // A src-family binding parked on data-vibe-<attr> (by an earlier parse of
96
+ // this template, or by component finalize neutralizing fetched HTML):
97
+ // bind it to the real attribute — hydration writes the evaluated URL there.
98
+ if (isFetchableElement && attr.name.startsWith('data-vibe-')) {
99
+ const realName = attr.name.slice(10);
100
+ if (FETCH_SRC_ATTRS.includes(realName)) {
101
+ attributes[realName] = attr.value;
102
+ continue;
103
+ }
104
+ }
105
+
106
+ // A raw binding-valued src reaching a fetchable element fires a request
107
+ // for the literal `@[...]` text (innerHTML parse and cloneNode both
108
+ // trigger it). Park it on data-vibe-<attr> — after the loop, since
109
+ // removeAttribute would shift the live NamedNodeMap being iterated —
110
+ // so the template and every clone of it are inert until hydration.
111
+ if (isFetchableElement && FETCH_SRC_ATTRS.includes(attr.name)) {
112
+ BINDING_REGEX.lastIndex = 0;
113
+ if (BINDING_REGEX.test(attr.value)) {
114
+ attributes[attr.name] = attr.value;
115
+ (srcToPark ??= []).push([attr.name, attr.value]);
116
+ continue;
117
+ }
118
+ }
119
+
91
120
  // Attribute name itself contains a binding (e.g. <icon @[section.icon]>).
92
121
  if (BINDING_REGEX.test(attr.name)) {
93
122
  nameBindings.push(attr.name);
@@ -128,6 +157,13 @@ const captureAttributeBindings = (element, aliasSet) => {
128
157
  }
129
158
  }
130
159
 
160
+ if (srcToPark) {
161
+ for (const [name, value] of srcToPark) {
162
+ element.setAttribute(`data-vibe-${name}`, value);
163
+ element.removeAttribute(name);
164
+ }
165
+ }
166
+
131
167
  return {
132
168
  attributes: Object.keys(attributes).length > 0 ? attributes : null,
133
169
  nameBindings: nameBindings.length > 0 ? nameBindings : null,
@@ -411,43 +411,53 @@ export const restoreMarkersFromManifest = (
411
411
 
412
412
  // IMPORTANT: Restore text nodes BEFORE processing conditionals/iterations
413
413
  // Text nodes use childNodes indices which become invalid after DOM modifications
414
+ // Ascending index order, same invariant the directive restoration below
415
+ // relies on: each text node is returned to its pre-stamp state before a
416
+ // later index is consulted, so every index stays valid as we go.
414
417
  if (tree.children) {
415
- for (const key in tree.children) {
416
- if (key.startsWith("text_")) {
417
- const childTree = tree.children[key];
418
- const restoration = childTree.compiled?.restoration;
419
-
420
- if (restoration?.parsed && Array.isArray(restoration.parsed)) {
421
- const hasBindings = restoration.parsed.some(
422
- (item) => typeof item === "string" && item.includes("@["),
423
- );
424
-
425
- if (hasBindings) {
426
- // Transform component-scoped bindings back to this. format
427
- let originalContent = restoration.parsed.join("");
428
- originalContent = originalContent.replace(
429
- /@\[_c\d+\./g,
430
- "@[this.",
431
- );
418
+ const texts = Object.keys(tree.children)
419
+ .filter((key) => key.startsWith("text_"))
420
+ .map((key) => ({ key, index: Number(key.match(/_(\d+)$/)?.[1]) }))
421
+ .filter(({ index }) => Number.isInteger(index))
422
+ .sort((a, b) => a.index - b.index);
432
423
 
433
- // Extract index from key (e.g., text_0 -> 0)
434
- const match = key.match(/_(\d+)$/);
435
- if (match) {
436
- const index = parseInt(match[1], 10);
437
-
438
- if (index < element.childNodes.length) {
439
- const textNode = element.childNodes[index];
440
- if (textNode && textNode.nodeType === Node.TEXT_NODE) {
441
- textNode.textContent = originalContent;
442
- }
443
- }
444
- }
424
+ for (const { key, index } of texts) {
425
+ const childTree = tree.children[key];
426
+ const restoration = childTree.compiled?.restoration;
427
+
428
+ if (restoration?.parsed && Array.isArray(restoration.parsed)) {
429
+ const hasBindings = restoration.parsed.some(
430
+ (item) => typeof item === "string" && item.includes("@["),
431
+ );
432
+
433
+ if (hasBindings) {
434
+ // Transform component-scoped bindings back to this. format
435
+ const originalContent = restoration.parsed
436
+ .join("")
437
+ .replace(/@\[_c\d+\./g, "@[this.");
438
+
439
+ const node = element.childNodes[index];
440
+ if (node && node.nodeType === Node.TEXT_NODE) {
441
+ node.textContent = originalContent;
442
+ } else {
443
+ // The stamp ELIDED this text node: every binding in it rendered
444
+ // to an empty string, and an empty text node has no HTML
445
+ // serialization, so the compiled page carries no node here at all
446
+ // (`<out>@[removed.join(',')]</out>` with an empty array stamps to
447
+ // `<out></out>`). Recreate it at its recorded index — otherwise
448
+ // the marker never returns and the binding is dead for the page's
449
+ // lifetime, and every later sibling index at this level is off by
450
+ // one, so their restorations silently miss too.
451
+ element.insertBefore(
452
+ document.createTextNode(originalContent),
453
+ node ?? null,
454
+ );
445
455
  }
446
456
  }
457
+ }
447
458
 
448
- if (childTree.compiled) {
449
- delete childTree.compiled.restoration;
450
- }
459
+ if (childTree.compiled) {
460
+ delete childTree.compiled.restoration;
451
461
  }
452
462
  }
453
463
  }
package/runtime/state.js CHANGED
@@ -1,4 +1,4 @@
1
- import { recordRead } from './tracking.js';
1
+ import { recordRead, recordAbsentRead, isTracking } from './tracking.js';
2
2
 
3
3
  // Track which objects are already proxied to avoid double-wrapping
4
4
  const proxyCache = new WeakMap();
@@ -124,7 +124,18 @@ const createDeepProxy = (target, rerender, rootState = null, rootProp = null) =>
124
124
  // the ROOT key it descended from. This is what makes helper bodies
125
125
  // truthful dependencies — `isPremium()` reading `$.premiumUntil` via
126
126
  // closure lands here mid-evaluation. One null check when inactive.
127
- recordRead(rootProp === null ? prop : rootProp);
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
+ if (rootProp === null && isTracking() && typeof prop === 'string' && !(prop in target)) {
135
+ recordAbsentRead(prop);
136
+ } else {
137
+ recordRead(rootProp === null ? prop : rootProp);
138
+ }
128
139
 
129
140
  const value = Reflect.get(target, prop);
130
141
 
@@ -58,6 +58,7 @@ const dropFromBuckets = (sub, deps) => {
58
58
  }
59
59
  };
60
60
 
61
+
61
62
  const commit = ({ sub, reads, absent, sawScope }) => {
62
63
  const prev = subDeps.get(sub);
63
64
  if (prev) dropFromBuckets(sub, prev);
@@ -83,6 +84,11 @@ const commit = ({ sub, reads, absent, sawScope }) => {
83
84
  subDeps.set(sub, deps);
84
85
  };
85
86
 
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
+ export const isTracking = () => active !== null;
91
+
86
92
  // The single recording entry point — called from state.js's get trap,
87
93
  // evalInScope's parameter resolution, dollarFor's snapshot wrapper, and
88
94
  // createScopedState's global fallthrough. One null check when inactive.