@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 +1 -1
- package/package.json +1 -1
- package/runtime/affected.js +13 -0
- package/runtime/component.js +5 -1
- package/runtime/constants.js +14 -0
- package/runtime/hydrate.js +30 -10
- package/runtime/index.js +17 -0
- package/runtime/iterate.js +74 -20
- package/runtime/iteration-utils.js +28 -1
- package/runtime/parse.js +36 -0
- 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.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
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/component.js
CHANGED
|
@@ -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
|
}
|
package/runtime/constants.js
CHANGED
|
@@ -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 = [
|
package/runtime/hydrate.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 = {};
|
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;
|
|
@@ -1,5 +1,32 @@
|
|
|
1
1
|
// Utility functions for array iteration
|
|
2
|
-
import {
|
|
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
|
-
|
|
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.
|