@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 +1 -1
- package/package.json +1 -1
- package/runtime/affected.js +13 -0
- package/runtime/hydrate.js +28 -4
- package/runtime/iterate.js +74 -20
- package/runtime/pre-compiled-manifest.js +42 -32
- package/runtime/state.js +13 -2
- package/runtime/tracking.js +6 -0
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Vibe
|
|
2
2
|
|
|
3
|
-
**Version 3.0.
|
|
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
package/runtime/affected.js
CHANGED
|
@@ -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
|
}
|
package/runtime/hydrate.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|
package/runtime/iterate.js
CHANGED
|
@@ -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
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
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
|
|
437
|
+
return undefined;
|
|
432
438
|
}
|
|
433
|
-
if (value === undefined) return
|
|
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
|
|
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
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
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
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
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
|
-
|
|
449
|
-
|
|
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
|
-
|
|
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
|
|
package/runtime/tracking.js
CHANGED
|
@@ -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.
|