@ape-egg/vibe 2.1.22 → 3.0.0
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 +112 -5
- package/boot.js +4 -4
- package/component.js +27 -29
- package/hot-module-refresh.js +4 -4
- package/index.js +26 -17
- package/llms.txt +36 -5
- package/package.json +20 -14
- package/runtime/affected.js +159 -36
- package/runtime/cleanup.js +45 -1
- package/runtime/component.js +360 -98
- package/runtime/conditionals.js +111 -14
- package/runtime/debug.js +24 -0
- package/runtime/dispatch.js +172 -0
- package/runtime/hydrate.js +277 -110
- package/runtime/index.js +189 -65
- package/runtime/iterate.js +125 -50
- package/runtime/iteration-utils.js +59 -8
- package/runtime/manifest.js +77 -2
- package/runtime/parse.js +81 -11
- package/runtime/pre-compiled-iterations.js +19 -6
- package/runtime/pre-compiled-manifest.js +13 -4
- package/runtime/staging.js +153 -0
- package/runtime/state.js +31 -0
- package/runtime/tracking.js +173 -0
- package/runtime/utils.js +155 -78
- package/spa.js +206 -0
- package/vibe.css +8 -4
- package/CHANGELOG.md +0 -1159
- package/ROADMAP.md +0 -397
- package/compiler/bin/vibe-compile.js +0 -121
- package/compiler/native/.gitkeep +0 -0
- package/compiler/native/vibe-compiler-darwin-arm64 +0 -0
- package/compiler/native/vibe-compiler-linux-x64 +0 -0
- package/compiler/src/Cargo.lock +0 -2023
- package/compiler/src/Cargo.toml +0 -38
- package/compiler/src/compiler/PRE-RENDERING-IMPLEMENTATION.md +0 -241
- package/compiler/src/compiler/binding_case.rs +0 -88
- package/compiler/src/compiler/compile.rs +0 -2522
- package/compiler/src/compiler/component_tagger.rs +0 -469
- package/compiler/src/compiler/iteration_optimizer.rs +0 -455
- package/compiler/src/compiler/js_analyzer.rs +0 -715
- package/compiler/src/compiler/manifest_builder.rs +0 -693
- package/compiler/src/compiler/mod.rs +0 -15
- package/compiler/src/compiler/name_binding_protect.rs +0 -207
- package/compiler/src/compiler/reassignment_analyzer.rs +0 -456
- package/compiler/src/compiler/state_extractor.rs +0 -263
- package/compiler/src/compiler/value_stamper.rs +0 -921
- package/compiler/src/compiler/watcher.rs +0 -1147
- package/compiler/src/config.rs +0 -239
- package/compiler/src/main.rs +0 -347
- package/compiler/src/parser/element.rs +0 -96
- package/compiler/src/parser/html.rs +0 -1004
- package/compiler/src/parser/mod.rs +0 -8
- package/runtime/pre-compiled-manifest.test.mjs +0 -58
- package/runtime/scope.js +0 -50
- package/test-results/.last-run.json +0 -4
package/runtime/iterate.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import parse from './parse.js';
|
|
2
|
-
import affected from './affected.js';
|
|
2
|
+
import affected, { nodeSubscriberOf } from './affected.js';
|
|
3
3
|
import hydrate from './hydrate.js';
|
|
4
|
-
import { resolvePath, getItemKey, computeDiff } from './iteration-utils.js';
|
|
5
|
-
import { resolveThisPath, evalInScope, findComponentIdForElement, rememberScopedKeys } from './utils.js';
|
|
4
|
+
import { resolvePath, getItemKey, computeDiff, iterPropWrappersOf, bumpIterPropGeneration } from './iteration-utils.js';
|
|
5
|
+
import { resolveThisPath, evalInScope, findComponentIdForElement, rememberScopedKeys, overlayKeysOf } from './utils.js';
|
|
6
|
+
import { recordRead, beginTracking, endTracking, pruneDisconnected, markAlways } from './tracking.js';
|
|
6
7
|
import { managedNodes, extractDependencies } from './conditionals.js';
|
|
8
|
+
import { liveNode, isOutgoing } from './staging.js';
|
|
7
9
|
import { stampInstanceScopes } from './loop-scope.js';
|
|
8
10
|
import {
|
|
9
11
|
BINDING_REGEX,
|
|
@@ -66,13 +68,13 @@ const hasComponentSrc = (templateEl) =>
|
|
|
66
68
|
const hasInlinedComponentScript = (templateEl) =>
|
|
67
69
|
!!templateEl.querySelector?.('script[type="vibe-module"]');
|
|
68
70
|
|
|
69
|
-
// `
|
|
71
|
+
// `__vibe.forceClonePath` is a debug/test escape hatch — set it to
|
|
70
72
|
// route every iteration through the clone+hydrate path, even templates that
|
|
71
73
|
// would otherwise qualify for batch. Used by the batch-vs-clone-equivalence
|
|
72
74
|
// test harness so the same templates can be rendered through both paths and
|
|
73
75
|
// compared. Not part of the public API.
|
|
74
76
|
const canUseBatchRender = (template) =>
|
|
75
|
-
!globalThis.
|
|
77
|
+
!globalThis.__vibe?.forceClonePath &&
|
|
76
78
|
!hasNestedStructures(template) &&
|
|
77
79
|
template.element.children.length <= 1 &&
|
|
78
80
|
!hasComponentSrc(template.element) &&
|
|
@@ -163,7 +165,24 @@ const isValueStyleAttr = (attrName) =>
|
|
|
163
165
|
// to render identically. New binding forms must update both paths in lockstep
|
|
164
166
|
// or the equivalence harness in tests/e2e/batch-vs-clone-equivalence.spec.js
|
|
165
167
|
// will catch the divergence.
|
|
166
|
-
|
|
168
|
+
// Root-position `_cN` component references inside a binding expression —
|
|
169
|
+
// string literals are consumed whole and left untouched (an `_cN` inside a
|
|
170
|
+
// quoted string is content, not a reference — same discipline as
|
|
171
|
+
// compileExpression's alternation in utils.js; the two renderers are
|
|
172
|
+
// lockstep-equivalent by contract). Already-canonical `$['_cN']` forms and
|
|
173
|
+
// property accesses stay untouched via the lookbehind.
|
|
174
|
+
const CID_ROOT_REGEX =
|
|
175
|
+
/('(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*")|(?<![\w$.])_c(\d+)\b/g;
|
|
176
|
+
|
|
177
|
+
export const canonicalizeComponentIds = (expr) =>
|
|
178
|
+
expr.replace(CID_ROOT_REGEX, (m, literal, id) => (literal !== undefined ? literal : `$['_c${id}']`));
|
|
179
|
+
|
|
180
|
+
// Name-binding emission: resolve the expression under the `_e` guard, emit
|
|
181
|
+
// ` value=""` when truthy, nothing otherwise.
|
|
182
|
+
const nameBindingEmit = (exprSrc) =>
|
|
183
|
+
'${(()=>{const _v=_e(()=>(' + exprSrc + '));return _v?\' \'+_v+\'=""\':\'\';})()}';
|
|
184
|
+
|
|
185
|
+
export const compileBatchFn = (template, itemAlias, indexAlias, stateKeys, anchorEl) => {
|
|
167
186
|
// Walk a clone of the template DOM so we can stash a transient
|
|
168
187
|
// `data-vibe-batch` marker on every element with DOM-property bindings
|
|
169
188
|
// without polluting the cached template that subsequent renders read.
|
|
@@ -225,6 +244,17 @@ const compileBatchFn = (template, itemAlias, indexAlias, stateKeys, anchorEl) =>
|
|
|
225
244
|
);
|
|
226
245
|
}
|
|
227
246
|
|
|
247
|
+
// Compiler-stamped component references (`_cN.x` in SPA fragments) resolve
|
|
248
|
+
// through `$` at call time — the mirror of evalInScope's id canonicalization.
|
|
249
|
+
// Baked as bare identifiers they'd be a ReferenceError whenever the id is
|
|
250
|
+
// missing from the compile-time state-key snapshot (a fragment iteration can
|
|
251
|
+
// hydrate before its component script registers), and a state key that
|
|
252
|
+
// arrives later would never be picked up by the cached batch function.
|
|
253
|
+
BINDING_REGEX.lastIndex = 0;
|
|
254
|
+
templateHtml = templateHtml.replace(BINDING_REGEX, (_, expr) =>
|
|
255
|
+
'@[' + canonicalizeComponentIds(expr) + ']',
|
|
256
|
+
);
|
|
257
|
+
|
|
228
258
|
const escaped = templateHtml
|
|
229
259
|
.replace(/\\/g, '\\\\')
|
|
230
260
|
.replace(/`/g, '\\`')
|
|
@@ -255,13 +285,13 @@ const compileBatchFn = (template, itemAlias, indexAlias, stateKeys, anchorEl) =>
|
|
|
255
285
|
code = mapTagSpans(code, (tag) => tag.replace(BATCH_NAME_BINDING_REGEX, (_, _ws, expr) => {
|
|
256
286
|
let decExpr = decodeEntities(expr);
|
|
257
287
|
if (decExpr.includes('[') || decExpr.includes('(')) {
|
|
258
|
-
return
|
|
288
|
+
return nameBindingEmit(decExpr);
|
|
259
289
|
}
|
|
260
290
|
const dot = decExpr.indexOf('.');
|
|
261
291
|
if (dot === -1) {
|
|
262
292
|
const ci = stateKeys.find((k) => k.toLowerCase() === decExpr.toLowerCase());
|
|
263
293
|
if (ci) decExpr = ci;
|
|
264
|
-
return
|
|
294
|
+
return nameBindingEmit(decExpr);
|
|
265
295
|
}
|
|
266
296
|
const head = decExpr.slice(0, dot);
|
|
267
297
|
const tail = decExpr.slice(dot + 1).split('.');
|
|
@@ -269,10 +299,7 @@ const compileBatchFn = (template, itemAlias, indexAlias, stateKeys, anchorEl) =>
|
|
|
269
299
|
const ciHead = knownNames.find((k) => k.toLowerCase() === head.toLowerCase()) || head;
|
|
270
300
|
const tailJSON = JSON.stringify(tail);
|
|
271
301
|
needsCiWalker = true;
|
|
272
|
-
return (
|
|
273
|
-
'${(()=>{const _v=_walkCi(' + ciHead + ',' + tailJSON +
|
|
274
|
-
');return _v?\' \'+_v+\'=""\':\'\';})()}'
|
|
275
|
-
);
|
|
302
|
+
return nameBindingEmit('_walkCi(' + ciHead + ',' + tailJSON + ')');
|
|
276
303
|
}));
|
|
277
304
|
|
|
278
305
|
// Pure-binding attributes (`attr="@[expr]"`) — classify by attribute name:
|
|
@@ -283,14 +310,14 @@ const compileBatchFn = (template, itemAlias, indexAlias, stateKeys, anchorEl) =>
|
|
|
283
310
|
code = code.replace(BATCH_ATTR_BINDING_REGEX, (_, _ws, attrName, expr) => {
|
|
284
311
|
const decExpr = decodeEntities(expr);
|
|
285
312
|
if (DOM_PROPERTIES.includes(attrName) || isValueStyleAttr(attrName)) {
|
|
286
|
-
return ' ' + attrName + '="${' + decExpr + '}"';
|
|
313
|
+
return ' ' + attrName + '="${_e(()=>(' + decExpr + '))}"';
|
|
287
314
|
}
|
|
288
|
-
return '${(' + decExpr + ') ? \' ' + attrName + '=""\' : \'\'}';
|
|
315
|
+
return '${_e(()=>(' + decExpr + ')) ? \' ' + attrName + '=""\' : \'\'}';
|
|
289
316
|
});
|
|
290
317
|
|
|
291
318
|
// Remaining @[…] markers — text content and partial-binding attribute
|
|
292
319
|
// values like `href="/items/@[id]/edit"`.
|
|
293
|
-
code = code.replace(BINDING_REGEX, (_, expr) => '${' + decodeEntities(expr) + '}');
|
|
320
|
+
code = code.replace(BINDING_REGEX, (_, expr) => '${_e(()=>(' + decodeEntities(expr) + '))}');
|
|
294
321
|
|
|
295
322
|
// `$` is supplied as the last parameter so component-scoped expressions like
|
|
296
323
|
// `$['_c0'].chosen` (produced by the this.X rewrite above) can resolve
|
|
@@ -298,6 +325,12 @@ const compileBatchFn = (template, itemAlias, indexAlias, stateKeys, anchorEl) =>
|
|
|
298
325
|
// `_walkCi` is only emitted when at least one dotted-path name binding was
|
|
299
326
|
// rewritten above — keeps the function body free of dead code for the
|
|
300
327
|
// common no-name-binding case.
|
|
328
|
+
// Per-expression failure contract, mirroring evalInScope: an expression
|
|
329
|
+
// that can't evaluate yet (component state not registered, alias mid-swap)
|
|
330
|
+
// renders '' and the next flush corrects it — it must never throw out of
|
|
331
|
+
// the batch loop and abort every row after it. Non-throwing values pass
|
|
332
|
+
// through untouched, so template-literal coercion stays identical.
|
|
333
|
+
const evalGuardSrc = "const _e = (f) => { try { return f(); } catch { return ''; } };";
|
|
301
334
|
const ciWalkerSrc = needsCiWalker
|
|
302
335
|
? `const _walkCi = (cur, segs) => {
|
|
303
336
|
for (let i = 0; i < segs.length; i++) {
|
|
@@ -317,6 +350,7 @@ const compileBatchFn = (template, itemAlias, indexAlias, stateKeys, anchorEl) =>
|
|
|
317
350
|
...stateKeys,
|
|
318
351
|
'$',
|
|
319
352
|
`
|
|
353
|
+
${evalGuardSrc}
|
|
320
354
|
${ciWalkerSrc}
|
|
321
355
|
let html = '';
|
|
322
356
|
const len = arr.length;
|
|
@@ -338,29 +372,28 @@ const compileBatchFn = (template, itemAlias, indexAlias, stateKeys, anchorEl) =>
|
|
|
338
372
|
// is detached (see releaseOrphanedIterationProps).
|
|
339
373
|
//
|
|
340
374
|
// The name is intentionally all-lowercase. resolveIterationComponentProps
|
|
341
|
-
// injects `@[window.
|
|
375
|
+
// injects `@[window.__vibe.iterProps._pN]` into the component's bindings, and
|
|
342
376
|
// prop substitution carries that accessor into the template's own bindings —
|
|
343
377
|
// including name-bindings (`<icon @[props.element]>`). HTML lowercases
|
|
344
378
|
// attribute names, so a camelCase accessor would arrive at hydrate as
|
|
345
|
-
// `window.
|
|
379
|
+
// `window.__vibe.iterProps` and resolve to undefined, silently dropping the
|
|
346
380
|
// attribute. Keeping the global lowercase makes it survive that normalization.
|
|
347
381
|
let __vibeIterPropCounter = 0;
|
|
348
382
|
const ensureIterPropsRegistry = () => {
|
|
349
|
-
|
|
350
|
-
return window.__vibeiterprops;
|
|
383
|
+
return ((window.__vibe ??= {}).iterProps ??= {});
|
|
351
384
|
};
|
|
352
385
|
|
|
353
386
|
// Walk a removed subtree and free any iteration-prop registry slots stashed
|
|
354
387
|
// on `<component>` elements inside it. Called from the mutation-observer
|
|
355
388
|
// cleanup path after DOM detachment.
|
|
356
389
|
export const releaseOrphanedIterationProps = (nodes) => {
|
|
357
|
-
if (!window.
|
|
390
|
+
if (!window.__vibe?.iterProps) return;
|
|
358
391
|
for (const node of nodes) {
|
|
359
392
|
if (node.nodeType !== 1) continue;
|
|
360
393
|
const free = (el) => {
|
|
361
394
|
const ids = el._vibeIterPropIds;
|
|
362
395
|
if (!ids) return;
|
|
363
|
-
for (const id of ids) delete window.
|
|
396
|
+
for (const id of ids) delete window.__vibe.iterProps[id];
|
|
364
397
|
el._vibeIterPropIds = null;
|
|
365
398
|
};
|
|
366
399
|
free(node);
|
|
@@ -404,18 +437,19 @@ const resolveSlotContentBindings = (el, scopedState, aliases) => {
|
|
|
404
437
|
(el._vibeIterPropExprs = el._vibeIterPropExprs || []).push({ id, attrName: null, expr });
|
|
405
438
|
(el._vibeIterPropIds = el._vibeIterPropIds || []).push(id);
|
|
406
439
|
}
|
|
407
|
-
return `@[window.
|
|
440
|
+
return `@[window.__vibe.iterProps.${id}]`;
|
|
408
441
|
});
|
|
409
442
|
if (idByExpr.size) {
|
|
410
443
|
el._vibeSlotContent = rewritten;
|
|
411
444
|
el.setAttribute('data-vibe-iter-prop', '');
|
|
445
|
+
bumpIterPropGeneration();
|
|
412
446
|
}
|
|
413
447
|
};
|
|
414
448
|
|
|
415
449
|
// For <component src> elements inside an iteration instance, evaluate any
|
|
416
450
|
// `@[expr]` attribute bindings against the iteration's scoped state and route
|
|
417
451
|
// every resolved value through the global iteration-prop registry. The prop
|
|
418
|
-
// attribute becomes `@[window.
|
|
452
|
+
// attribute becomes `@[window.__vibe.iterProps._pN]` — a live binding into the
|
|
419
453
|
// registry slot — for both primitives and objects. The original expression is
|
|
420
454
|
// stashed on the element so the iteration's update path can re-evaluate it
|
|
421
455
|
// against the new scope and refresh the slot, propagating the change into the
|
|
@@ -460,6 +494,7 @@ export const resolveIterationComponentProps = (nodes, scopedState, aliases) => {
|
|
|
460
494
|
(aliases && extractDependencies(expr).some((d) => aliases.has(d)));
|
|
461
495
|
if (!usesLocalScope) {
|
|
462
496
|
el.setAttribute('data-vibe-iter-prop', '');
|
|
497
|
+
bumpIterPropGeneration();
|
|
463
498
|
el._vibeIterPropExprs = el._vibeIterPropExprs || [];
|
|
464
499
|
continue;
|
|
465
500
|
}
|
|
@@ -469,8 +504,9 @@ export const resolveIterationComponentProps = (nodes, scopedState, aliases) => {
|
|
|
469
504
|
const registry = ensureIterPropsRegistry();
|
|
470
505
|
const id = `_p${__vibeIterPropCounter++}`;
|
|
471
506
|
registry[id] = value;
|
|
472
|
-
el.setAttribute(attr.name, `@[window.
|
|
507
|
+
el.setAttribute(attr.name, `@[window.__vibe.iterProps.${id}]`);
|
|
473
508
|
el.setAttribute('data-vibe-iter-prop', '');
|
|
509
|
+
bumpIterPropGeneration();
|
|
474
510
|
(el._vibeIterPropIds = el._vibeIterPropIds || []).push(id);
|
|
475
511
|
(el._vibeIterPropExprs = el._vibeIterPropExprs || []).push({ id, attrName: attr.name, expr });
|
|
476
512
|
} catch {
|
|
@@ -482,18 +518,6 @@ export const resolveIterationComponentProps = (nodes, scopedState, aliases) => {
|
|
|
482
518
|
}
|
|
483
519
|
};
|
|
484
520
|
|
|
485
|
-
// Resolve a clonedNodes entry to its live counterpart. processComponent's
|
|
486
|
-
// `el.replaceWith(newWrapper)` detaches the original `<component src>` and
|
|
487
|
-
// installs a `<component>` post-process wrapper in its place — but the
|
|
488
|
-
// iteration's `instance.clonedNodes` still points at the original. The
|
|
489
|
-
// `_vibeReplacedBy` chain (set in component.js right before the swap) gives
|
|
490
|
-
// us a single pointer to the live wrapper.
|
|
491
|
-
const liveCloneNode = (node) => {
|
|
492
|
-
let cur = node;
|
|
493
|
-
while (cur && cur._vibeReplacedBy) cur = cur._vibeReplacedBy;
|
|
494
|
-
return cur;
|
|
495
|
-
};
|
|
496
|
-
|
|
497
521
|
// Walk a row's live cloned nodes and invoke `fn` for every inlined-component
|
|
498
522
|
// wrapper carrying `marker` — the resolved node itself plus any
|
|
499
523
|
// `[data-vibe-iter-prop]` descendant that carries it. The prop-refresh and
|
|
@@ -503,11 +527,11 @@ const liveCloneNode = (node) => {
|
|
|
503
527
|
// marker matches the historical "take all, skip those without exprs" behavior.)
|
|
504
528
|
const forEachIterWrapper = (clonedNodes, marker, fn) => {
|
|
505
529
|
for (let n = 0; n < clonedNodes.length; n++) {
|
|
506
|
-
const node =
|
|
530
|
+
const node = liveNode(clonedNodes[n]);
|
|
507
531
|
if (!node || node.nodeType !== 1) continue;
|
|
508
532
|
if (node[marker]) fn(node);
|
|
509
|
-
const found = node
|
|
510
|
-
|
|
533
|
+
const found = iterPropWrappersOf(node);
|
|
534
|
+
for (let i = 0; i < found.length; i++) {
|
|
511
535
|
if (found[i][marker]) fn(found[i]);
|
|
512
536
|
}
|
|
513
537
|
}
|
|
@@ -555,7 +579,7 @@ const refreshIterationComponentProps = (clonedNodes, scopedState) => {
|
|
|
555
579
|
|
|
556
580
|
// Walk an inlined component's parsed tree and force `updateIteration` on any
|
|
557
581
|
// iteration node whose arrayPath resolves through the iteration-prop registry
|
|
558
|
-
// (`window.
|
|
582
|
+
// (`window.__vibe.iterProps._pN`). The registry slot was just refreshed in
|
|
559
583
|
// place by `refreshIterationComponentProps`, so `affected()` can't notice
|
|
560
584
|
// the change — both old/new evaluations of the path read the same updated
|
|
561
585
|
// value. `updateIteration` is the only place equipped to diff against
|
|
@@ -564,7 +588,7 @@ const refreshIterationComponentProps = (clonedNodes, scopedState) => {
|
|
|
564
588
|
// in sync. Without this, an `<inner-component>` whose template iterates over
|
|
565
589
|
// an array prop stays frozen on its initial-render items when the prop's
|
|
566
590
|
// contents change.
|
|
567
|
-
const REGISTRY_SLOT_REGEX = /
|
|
591
|
+
const REGISTRY_SLOT_REGEX = /__vibe\.iterProps\.(_p\d+)/;
|
|
568
592
|
const forceRegistryBackedIterationUpdates = (tree, state, manifest, parentScope, changedSlots) => {
|
|
569
593
|
if (!tree) return;
|
|
570
594
|
if (tree.type === 'iteration') {
|
|
@@ -705,6 +729,11 @@ const renderBatch = (iterationNode, array, state, parent, endComment, parentScop
|
|
|
705
729
|
// unrelated state change produces identical HTML (see updateIteration).
|
|
706
730
|
iterationNode.runtime.lastBatchHtml = html;
|
|
707
731
|
|
|
732
|
+
// Batch rows close over every state key (stateKeys = Object.keys(state) at
|
|
733
|
+
// compile time) — "re-render on any change" is their honest dependency
|
|
734
|
+
// set; the identical-HTML guard above absorbs the no-ops (walk parity).
|
|
735
|
+
markAlways(nodeSubscriberOf(iterationNode, 'iteration'));
|
|
736
|
+
|
|
708
737
|
batchParseTemplate.innerHTML = html;
|
|
709
738
|
const frag = batchParseTemplate.content;
|
|
710
739
|
const kids = frag.children;
|
|
@@ -1001,6 +1030,11 @@ export const createScopedState = (globalState, localVars, parentScope = {}) => {
|
|
|
1001
1030
|
get(target, prop) {
|
|
1002
1031
|
if (prop in localVars) return localVars[prop];
|
|
1003
1032
|
if (prop in parentScope) return parentScope[prop];
|
|
1033
|
+
// Global fallthrough is a root-key read; alias hits above are the
|
|
1034
|
+
// row's own scope (owned by its iteration's diff) and never record.
|
|
1035
|
+
// When the target is the live root the proxy trap records too — the
|
|
1036
|
+
// window's read Set dedupes.
|
|
1037
|
+
recordRead(prop);
|
|
1004
1038
|
return Reflect.get(target, prop);
|
|
1005
1039
|
},
|
|
1006
1040
|
|
|
@@ -1046,8 +1080,10 @@ export const renderAllIterations = (tree, state, manifest, parentScope = {}) =>
|
|
|
1046
1080
|
return 1;
|
|
1047
1081
|
}
|
|
1048
1082
|
|
|
1049
|
-
// Recursively render iterations in child nodes
|
|
1050
|
-
|
|
1083
|
+
// Recursively render iterations in child nodes — except under an outgoing
|
|
1084
|
+
// wrapper (reactive-src remount in flight): its subtree is frozen until
|
|
1085
|
+
// the swap replaces it.
|
|
1086
|
+
if (tree.children && !isOutgoing(tree.element)) {
|
|
1051
1087
|
for (const key in tree.children) {
|
|
1052
1088
|
const child = tree.children[key];
|
|
1053
1089
|
if (child && typeof child === 'object') {
|
|
@@ -1114,7 +1150,12 @@ export const renderIteration = (iterationNode, state, manifest, parentScope = {}
|
|
|
1114
1150
|
// and inline literals (['a', 'b']). Falls back to resolvePath for
|
|
1115
1151
|
// simple paths that evalInScope might miss in scoped contexts.
|
|
1116
1152
|
const resolvedExpr = resolveThisPath(arrayPath, startComment.parentElement);
|
|
1153
|
+
// First render doubles as the iteration's subscription registration.
|
|
1154
|
+
const trackSub = nodeSubscriberOf(iterationNode, 'iteration');
|
|
1155
|
+
trackSub.lastScope = state;
|
|
1156
|
+
beginTracking(trackSub, overlayKeysOf(state));
|
|
1117
1157
|
const array = evalInScope(resolvedExpr, state, startComment.parentElement) ?? resolvePath(state, resolvedExpr);
|
|
1158
|
+
endTracking();
|
|
1118
1159
|
if (!Array.isArray(array) || array.length === 0) {
|
|
1119
1160
|
iterationNode.runtime.instances = [];
|
|
1120
1161
|
return;
|
|
@@ -1124,6 +1165,9 @@ export const renderIteration = (iterationNode, state, manifest, parentScope = {}
|
|
|
1124
1165
|
if (compiled.canUseCompiled(iterationNode)) {
|
|
1125
1166
|
const compiledMeta = compiled.getCompiledMeta(iterationNode);
|
|
1126
1167
|
if (compiled.renderCompiled(iterationNode, array, state, compiledMeta, parent, endComment)) {
|
|
1168
|
+
// Compiled rows close over every state key — "any change" is their
|
|
1169
|
+
// honest dependency set (walk parity for treeless instances).
|
|
1170
|
+
markAlways(trackSub);
|
|
1127
1171
|
// Mark as rendered
|
|
1128
1172
|
startComment.__vibeRendered = true;
|
|
1129
1173
|
startComment.__vibeIterationRuntime = iterationNode.runtime;
|
|
@@ -1163,7 +1207,7 @@ const evalKeyForItem = (iterationNode, item, index, state, parentScope) => {
|
|
|
1163
1207
|
// once per iteration block, so console doesn't drown.
|
|
1164
1208
|
const warnIndexCoupledKey = (iterationNode) => {
|
|
1165
1209
|
if (iterationNode.runtime.warnedIndexCoupled) return;
|
|
1166
|
-
if (!globalThis.
|
|
1210
|
+
if (!globalThis.__vibe?.debug) return;
|
|
1167
1211
|
iterationNode.runtime.warnedIndexCoupled = true;
|
|
1168
1212
|
const arrayPath = iterationNode.meta.arrayPath;
|
|
1169
1213
|
console.warn(
|
|
@@ -1180,7 +1224,12 @@ export const updateIteration = (iterationNode, newState, oldState, manifest, par
|
|
|
1180
1224
|
const resolvedExpr = resolveThisPath(arrayPath, startComment.parentElement);
|
|
1181
1225
|
|
|
1182
1226
|
const stateOldArray = evalInScope(resolvedExpr, oldState, startComment.parentElement) ?? resolvePath(oldState, resolvedExpr) ?? [];
|
|
1227
|
+
// New-side eval re-records the iteration's subscription (self-healing).
|
|
1228
|
+
const trackSub = nodeSubscriberOf(iterationNode, 'iteration');
|
|
1229
|
+
trackSub.lastScope = newState;
|
|
1230
|
+
beginTracking(trackSub, overlayKeysOf(newState));
|
|
1183
1231
|
const newArray = evalInScope(resolvedExpr, newState, startComment.parentElement) ?? resolvePath(newState, resolvedExpr) ?? [];
|
|
1232
|
+
endTracking();
|
|
1184
1233
|
|
|
1185
1234
|
// Use instances (what's actually rendered) as ground truth for old array
|
|
1186
1235
|
// whenever the only signal of change is a registry side-effect, or when
|
|
@@ -1191,7 +1240,7 @@ export const updateIteration = (iterationNode, newState, oldState, manifest, par
|
|
|
1191
1240
|
// mutation was a registry slot rewrite. The previously rendered
|
|
1192
1241
|
// items are the only honest record of what was there before.
|
|
1193
1242
|
// 2. stateOldArray === newArray — the iteration's arrayPath resolves
|
|
1194
|
-
// directly to a registry slot (`window.
|
|
1243
|
+
// directly to a registry slot (`window.__vibe.iterProps._pN`); the
|
|
1195
1244
|
// slot was swapped in place, so both reads return the same NEW
|
|
1196
1245
|
// array.
|
|
1197
1246
|
// 3. length mismatch — oldState predates the current render.
|
|
@@ -1223,6 +1272,8 @@ export const updateIteration = (iterationNode, newState, oldState, manifest, par
|
|
|
1223
1272
|
endComment,
|
|
1224
1273
|
)
|
|
1225
1274
|
) {
|
|
1275
|
+
// Still treeless — keep the honest any-change subscription.
|
|
1276
|
+
markAlways(trackSub);
|
|
1226
1277
|
return;
|
|
1227
1278
|
}
|
|
1228
1279
|
// Fall through to runtime path if compiled failed
|
|
@@ -1242,6 +1293,10 @@ export const updateIteration = (iterationNode, newState, oldState, manifest, par
|
|
|
1242
1293
|
// skip this — they take the diff path below.
|
|
1243
1294
|
const hasTreelessInstances = instances.length > 0 && !instances[0].tree;
|
|
1244
1295
|
if (hasTreelessInstances && oldState !== newState) {
|
|
1296
|
+
// Treeless rows stay on the any-change subscription. If bulkReplace
|
|
1297
|
+
// below clone-renders trees instead, the next new-side eval re-records
|
|
1298
|
+
// real deps (self-healing).
|
|
1299
|
+
markAlways(trackSub);
|
|
1245
1300
|
// affected.js conservatively flags a treeless (batch-rendered) iteration on
|
|
1246
1301
|
// ANY state change, since it can't walk per-row trees to see which bindings
|
|
1247
1302
|
// actually depend on what changed. Before tearing down and recreating every
|
|
@@ -1385,8 +1440,27 @@ const releaseRemovedSubtrees = (removedRoots, manifest) => {
|
|
|
1385
1440
|
}
|
|
1386
1441
|
|
|
1387
1442
|
// Parsed tree: delete each removed root's node — its whole subtree (nested
|
|
1388
|
-
// conditionals/iterations and their branch templates) goes with it.
|
|
1389
|
-
|
|
1443
|
+
// conditionals/iterations and their branch templates) goes with it. Prune
|
|
1444
|
+
// by CONTAINMENT, not just root identity: a row's inlined component trees
|
|
1445
|
+
// are also linked under OTHER ancestors' children (the dual linkage the
|
|
1446
|
+
// affected-walk dedupe documents), keyed by inner wrapper elements that are
|
|
1447
|
+
// never themselves removed roots. Left in place, every later walk descends
|
|
1448
|
+
// those detached trees — re-hydrating dead DOM and (since subscriptions)
|
|
1449
|
+
// re-registering its subscribers after teardown pruned them, pinning the
|
|
1450
|
+
// whole removed subtree forever.
|
|
1451
|
+
const roots = [...removedEls];
|
|
1452
|
+
if (tree) pruneTreeNodes(tree, removedEls, roots);
|
|
1453
|
+
|
|
1454
|
+
// The removed rows' subscribers (bindings, nested conditionals/iterations)
|
|
1455
|
+
// are anchored to nodes that just left the document.
|
|
1456
|
+
pruneDisconnected();
|
|
1457
|
+
};
|
|
1458
|
+
|
|
1459
|
+
const underRemovedRoot = (el, roots) => {
|
|
1460
|
+
for (let i = 0; i < roots.length; i++) {
|
|
1461
|
+
if (roots[i] !== el && roots[i].contains?.(el)) return true;
|
|
1462
|
+
}
|
|
1463
|
+
return false;
|
|
1390
1464
|
};
|
|
1391
1465
|
|
|
1392
1466
|
// True when `key` is, or is a descendant of, any path in `removedPaths`.
|
|
@@ -1398,14 +1472,15 @@ const pathUnderRemoved = (key, removedPaths) => {
|
|
|
1398
1472
|
return false;
|
|
1399
1473
|
};
|
|
1400
1474
|
|
|
1401
|
-
const pruneTreeNodes = (node, removedEls) => {
|
|
1475
|
+
const pruneTreeNodes = (node, removedEls, roots) => {
|
|
1402
1476
|
const children = node.children;
|
|
1403
1477
|
if (!children) return;
|
|
1404
1478
|
for (const key in children) {
|
|
1405
1479
|
const child = children[key];
|
|
1406
1480
|
if (!child) continue;
|
|
1407
|
-
|
|
1408
|
-
|
|
1481
|
+
const el = child.element ?? child.textNode;
|
|
1482
|
+
if (el && (removedEls.has(el) || underRemovedRoot(el, roots))) delete children[key];
|
|
1483
|
+
else pruneTreeNodes(child, removedEls, roots);
|
|
1409
1484
|
}
|
|
1410
1485
|
};
|
|
1411
1486
|
|
|
@@ -35,8 +35,11 @@ export const cloneTemplate = (templateElement) => {
|
|
|
35
35
|
return templateElement.cloneNode(true);
|
|
36
36
|
};
|
|
37
37
|
|
|
38
|
-
// Find matching <!-- /each --> comment with depth tracking
|
|
39
|
-
|
|
38
|
+
// Find matching <!-- /each --> comment with depth tracking. The index form
|
|
39
|
+
// returns -1 when unmatched — parse.js probes with it to tell an authored
|
|
40
|
+
// (but malformed) directive from a prose comment that merely starts with
|
|
41
|
+
// "each".
|
|
42
|
+
export const findEndCommentIndex = (nodes, startIndex) => {
|
|
40
43
|
let depth = 1;
|
|
41
44
|
for (let i = startIndex; i < nodes.length; i++) {
|
|
42
45
|
if (nodes[i].nodeName === '#comment') {
|
|
@@ -51,14 +54,25 @@ export const findEndComment = (nodes, startIndex) => {
|
|
|
51
54
|
}
|
|
52
55
|
}
|
|
53
56
|
}
|
|
54
|
-
|
|
57
|
+
return -1;
|
|
55
58
|
};
|
|
56
59
|
|
|
57
|
-
|
|
58
|
-
|
|
60
|
+
export const findEndComment = (nodes, startIndex) => {
|
|
61
|
+
const i = findEndCommentIndex(nodes, startIndex);
|
|
62
|
+
if (i === -1) throw new Error('Unmatched <!-- each --> comment: missing <!-- /each -->');
|
|
63
|
+
return i;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
// Find matching <!-- /if --> and optional <!-- else --> / <!-- else if -->
|
|
67
|
+
// with depth tracking. Returns { elseIndex, endIndex, elseText } — elseText
|
|
68
|
+
// is the boundary comment's trimmed text ('else' or 'else if <expr>'), so
|
|
69
|
+
// parse.js can desugar a chain: only the FIRST boundary at depth 1 is
|
|
70
|
+
// captured; the rest of the chain lives inside the false-branch span and
|
|
71
|
+
// desugars recursively into nested conditionals.
|
|
59
72
|
export const findConditionalEnd = (nodes, startIndex) => {
|
|
60
73
|
let depth = 1;
|
|
61
74
|
let elseIndex = null;
|
|
75
|
+
let elseText = null;
|
|
62
76
|
|
|
63
77
|
for (let i = startIndex; i < nodes.length; i++) {
|
|
64
78
|
if (nodes[i].nodeName === '#comment') {
|
|
@@ -68,15 +82,20 @@ export const findConditionalEnd = (nodes, startIndex) => {
|
|
|
68
82
|
if (CONDITIONAL_START_REGEX.test(text)) {
|
|
69
83
|
depth++;
|
|
70
84
|
}
|
|
71
|
-
// Check for else at current depth
|
|
72
|
-
else if (
|
|
85
|
+
// Check for else / else-if at current depth
|
|
86
|
+
else if (
|
|
87
|
+
depth === 1 &&
|
|
88
|
+
elseIndex === null &&
|
|
89
|
+
(text === 'else' || /^else\s+if\s+.+/.test(text))
|
|
90
|
+
) {
|
|
73
91
|
elseIndex = i;
|
|
92
|
+
elseText = text;
|
|
74
93
|
}
|
|
75
94
|
// Check for /if
|
|
76
95
|
else if (text === '/if') {
|
|
77
96
|
depth--;
|
|
78
97
|
if (depth === 0) {
|
|
79
|
-
return { elseIndex, endIndex: i };
|
|
98
|
+
return { elseIndex, endIndex: i, elseText };
|
|
80
99
|
}
|
|
81
100
|
}
|
|
82
101
|
}
|
|
@@ -196,6 +215,38 @@ export const longestCommonSubsequence = (arr1, arr2) => {
|
|
|
196
215
|
return lcs;
|
|
197
216
|
};
|
|
198
217
|
|
|
218
|
+
// The affected walk and the iteration update path both ask "which
|
|
219
|
+
// [data-vibe-iter-prop] wrappers live under this row node?" for every row on
|
|
220
|
+
// every flush — re-querying each time dominated flush cost on iteration-heavy
|
|
221
|
+
// pages (~24k querySelectorAll calls per flush on the game's armory, nearly all
|
|
222
|
+
// returning nothing). The wrapper set only changes when a mount path stamps the
|
|
223
|
+
// attribute, so each stamp site bumps a global generation and the query result
|
|
224
|
+
// is cached per node against it. A cache hit still prunes wrappers whose
|
|
225
|
+
// subtree was torn down since (nested each/if removals keep the row node but
|
|
226
|
+
// drop descendants) — additions always arrive through a stamp ⇒ a bump ⇒ a
|
|
227
|
+
// fresh query.
|
|
228
|
+
let iterPropGeneration = 0;
|
|
229
|
+
|
|
230
|
+
export const bumpIterPropGeneration = () => {
|
|
231
|
+
iterPropGeneration++;
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
export const iterPropWrappersOf = (node) => {
|
|
235
|
+
const cached = node._vibeIterPropWrappers;
|
|
236
|
+
if (cached && cached.gen === iterPropGeneration) {
|
|
237
|
+
const list = cached.list;
|
|
238
|
+
for (let i = list.length - 1; i >= 0; i--) {
|
|
239
|
+
if (!node.contains(list[i])) list.splice(i, 1);
|
|
240
|
+
}
|
|
241
|
+
return list;
|
|
242
|
+
}
|
|
243
|
+
const list = [];
|
|
244
|
+
const found = node.querySelectorAll?.('[data-vibe-iter-prop]');
|
|
245
|
+
if (found) for (let i = 0; i < found.length; i++) list.push(found[i]);
|
|
246
|
+
node._vibeIterPropWrappers = { gen: iterPropGeneration, list };
|
|
247
|
+
return list;
|
|
248
|
+
};
|
|
249
|
+
|
|
199
250
|
// Generate unique key for array items.
|
|
200
251
|
// `customKey` (when defined and not null) wins over every default heuristic —
|
|
201
252
|
// the developer has declared identity explicitly via `<!-- each xs as x (expr) -->`.
|
package/runtime/manifest.js
CHANGED
|
@@ -1,6 +1,81 @@
|
|
|
1
|
+
// The manifest is the one-way dotPath→element map the runtime hangs DOM
|
|
2
|
+
// bookkeeping on. Three hot paths need the REVERSE direction (element→path):
|
|
3
|
+
// conditional branch mounts and the mutation observer's add/remove tracking.
|
|
4
|
+
// Scanning Object.entries per lookup is O(manifest) per node — quadratic as
|
|
5
|
+
// pages grow, and the dominant cost of mounting conditional-heavy pages. A
|
|
6
|
+
// WeakMap rides on the manifest object (non-enumerable, like __live/__tree)
|
|
7
|
+
// and is maintained by every write; lookups validate against the forward map
|
|
8
|
+
// so a deleted entry can never resolve stale.
|
|
9
|
+
const indexOf = (manifest) => {
|
|
10
|
+
if (!manifest.__paths) {
|
|
11
|
+
Object.defineProperty(manifest, '__paths', {
|
|
12
|
+
value: new WeakMap(),
|
|
13
|
+
enumerable: false,
|
|
14
|
+
configurable: true,
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
return manifest.__paths;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
// Single write point: forward map + reverse index together. Non-object
|
|
21
|
+
// "elements" (text nodes stored as strings, null placeholders) can't be
|
|
22
|
+
// WeakMap keys and are never reverse-looked-up — forward map only. An
|
|
23
|
+
// element can legitimately hold MORE than one path (its own node plus an
|
|
24
|
+
// inlined-slot alias, branch-graft re-registrations); the index keeps every
|
|
25
|
+
// registered path shortest-first, so lookups answer with the subtree ROOT —
|
|
26
|
+
// removal-time pruning must sweep the whole region, not a deeper alias.
|
|
27
|
+
export const setManifestEntry = (manifest, path, element) => {
|
|
28
|
+
manifest[path] = element;
|
|
29
|
+
if (element !== null && typeof element === 'object') {
|
|
30
|
+
const index = indexOf(manifest);
|
|
31
|
+
const paths = index.get(element);
|
|
32
|
+
if (!paths) {
|
|
33
|
+
index.set(element, [path]);
|
|
34
|
+
} else if (!paths.includes(path)) {
|
|
35
|
+
let at = paths.length;
|
|
36
|
+
while (at > 0 && paths[at - 1].length > path.length) at--;
|
|
37
|
+
paths.splice(at, 0, path);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// O(paths-per-element) reverse lookup, validated: entries whose forward
|
|
43
|
+
// mapping was deleted (branch unmounts, subtree pruning) compact away; the
|
|
44
|
+
// first path the forward map still agrees with — the shortest live one —
|
|
45
|
+
// wins. Null when none survive.
|
|
46
|
+
export const manifestPathOf = (manifest, element) => {
|
|
47
|
+
if (element === null || typeof element !== 'object') return null;
|
|
48
|
+
const paths = manifest.__paths?.get(element);
|
|
49
|
+
if (!paths) return null;
|
|
50
|
+
for (let i = 0; i < paths.length; i++) {
|
|
51
|
+
if (manifest[paths[i]] === element) {
|
|
52
|
+
if (i > 0) paths.splice(0, i);
|
|
53
|
+
return paths[0];
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
manifest.__paths.delete(element);
|
|
57
|
+
return null;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
// Removal counterpart of addToManifest (index.js): drop the root entry and
|
|
61
|
+
// every entry under its dot-scope. A PREFIX SWEEP over the manifest keys, not
|
|
62
|
+
// a tree walk — branch mounts register alias paths a tree walk can't reach,
|
|
63
|
+
// and the sweep stays correct through any tree/manifest drift. Deleting only
|
|
64
|
+
// the root entry leaked the rest: an SPA outlet swap left ~4,400
|
|
65
|
+
// detached-element entries behind per navigation, pinning the DOM of every
|
|
66
|
+
// page ever visited. The manifest stays bounded, so O(manifest) per removal
|
|
67
|
+
// batch is cheap. An empty root path is never a legitimate removal target.
|
|
68
|
+
export const removeManifestSubtree = (manifest, dotPath) => {
|
|
69
|
+
if (!dotPath) return;
|
|
70
|
+
delete manifest[dotPath];
|
|
71
|
+
const prefix = dotPath + '.';
|
|
72
|
+
for (const key in manifest) {
|
|
73
|
+
if (key.startsWith(prefix)) delete manifest[key];
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
|
|
1
77
|
const recursive = (tree, results, tagChain) => {
|
|
2
|
-
|
|
3
|
-
results[path] = tree.element;
|
|
78
|
+
setManifestEntry(results, tagChain.join('.'), tree.element);
|
|
4
79
|
|
|
5
80
|
if (tree.children && Object.keys(tree.children).length > 0) {
|
|
6
81
|
Object.keys(tree.children).forEach((tag) => {
|