@ape-egg/vibe 3.0.2 → 3.0.4

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.4** — 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.4",
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
  }
@@ -3,7 +3,12 @@ import { updateConditional, managedNodes } from './conditionals.js';
3
3
  import { liveComponentWrapper, remountComponent, forceRemount } from './component.js';
4
4
  import { isComponentWrapper, 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';
@@ -199,9 +204,24 @@ export default (affected, state, manifest = {}, oldState = {}) => {
199
204
  // Use scoped state if provided (from iteration instances)
200
205
  const effectiveState = aff.scopedState || state;
201
206
 
202
- // Handle iteration updates
207
+ // Handle iteration updates. Same scoped-pair contract as conditionals
208
+ // below: a NESTED iteration re-renders its rows against the enclosing
209
+ // row's scope, so bindings reading the outer alias (`@[ability.healing]`
210
+ // inside `<!-- each ability.chainDividers as d -->`) still resolve. The
211
+ // overlay doubles as the nested parentScope — exactly what the initial
212
+ // render passes (renderAllIterations with `{...parentScope, ...localVars}`).
213
+ // Top-level iterations carry the cycle's state and no overlay, so they
214
+ // keep resolving against globals.
203
215
  if (aff.type === 'iteration') {
204
- updateIteration(aff.node, state, oldState, manifest);
216
+ const iterNewState = aff.scopedState || state;
217
+ const iterOldState = aff.oldScopedState || oldState;
218
+ updateIteration(
219
+ aff.node,
220
+ iterNewState,
221
+ iterOldState,
222
+ manifest,
223
+ scopedOverlayOf(iterNewState) || {},
224
+ );
205
225
  return;
206
226
  }
207
227
 
@@ -338,7 +358,11 @@ export default (affected, state, manifest = {}, oldState = {}) => {
338
358
  // Function replacer: a string replacement would run GetSubstitution on
339
359
  // the VALUE — `$$` collapses, `$&` re-inserts the binding text into the
340
360
  // DOM (which the settle gates then read as an unhydrated binding).
341
- const toReplace = input.replaceAll(matchOuter, () => evaluated).trim();
361
+ // No trim: the text node's source whitespace is significant — a
362
+ // prettier line-wrap after an inline end tag (`</strong>\n remain`)
363
+ // is that word's boundary, and the compiled SPA shell stamps values
364
+ // untrimmed, so trimming here glued words and diverged from the shell.
365
+ const toReplace = input.replaceAll(matchOuter, () => evaluated);
342
366
 
343
367
  affected.forEach((innerAff) => {
344
368
  if (innerAff.element === element) {
@@ -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;
@@ -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.