@ape-egg/vibe 4.0.0 → 4.1.1
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 +3 -1
- package/boot.js +10 -9
- package/component.js +0 -29
- package/hot-module-refresh.js +0 -0
- package/index.js +0 -28
- package/package.json +3 -2
- package/runtime/_vibe-compiled-iteration-batch.js +0 -56
- package/runtime/affected.js +6 -189
- package/runtime/cleanup.js +4 -35
- package/runtime/component-cache.js +0 -53
- package/runtime/component.js +7 -401
- package/runtime/conditionals.js +1 -143
- package/runtime/constants.js +19 -82
- package/runtime/debug.js +22 -47
- package/runtime/dispatch.js +0 -46
- package/runtime/hydrate.js +10 -129
- package/runtime/index.js +7 -365
- package/runtime/iterate.js +21 -579
- package/runtime/iteration-utils.js +18 -71
- package/runtime/loop-scope.js +0 -58
- package/runtime/manifest.js +0 -27
- package/runtime/parse.js +2 -116
- package/runtime/pre-compiled-iterations.js +3 -51
- package/runtime/pre-compiled-manifest.js +6 -169
- package/runtime/raw-html.js +0 -5
- package/runtime/reconcile.js +4 -159
- package/runtime/staging.js +0 -57
- package/runtime/state.js +0 -61
- package/runtime/this-scope.js +0 -17
- package/runtime/tracking.js +0 -65
- package/runtime/utils.js +1 -144
- package/runtime/vibe-css.js +54 -0
- package/spa.js +0 -76
- package/vibe.css +22 -44
package/runtime/iterate.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import parse from './parse.js';
|
|
2
2
|
import affected, { nodeSubscriberOf } from './affected.js';
|
|
3
3
|
import hydrate from './hydrate.js';
|
|
4
|
-
import { resolvePath, getItemKey, computeDiff, iterPropWrappersOf, bumpIterPropGeneration } from './iteration-utils.js';
|
|
4
|
+
import { resolvePath, getItemKey, computeDiff, iterPropWrappersOf, bumpIterPropGeneration, markBoundValues } from './iteration-utils.js';
|
|
5
5
|
import { resolveThisPath, evalInScope, findComponentIdForElement, rememberScopedKeys, overlayKeysOf } from './utils.js';
|
|
6
6
|
import { recordRead, beginTracking, endTracking, pruneDisconnected, markAlways } from './tracking.js';
|
|
7
7
|
import { managedNodes, extractDependencies } from './conditionals.js';
|
|
@@ -15,25 +15,12 @@ import {
|
|
|
15
15
|
THIS_PROP_REGEX,
|
|
16
16
|
} from './constants.js';
|
|
17
17
|
|
|
18
|
-
// Pre-compiled iteration optimization (production)
|
|
19
18
|
import * as compiled from './pre-compiled-iterations.js';
|
|
20
19
|
|
|
21
20
|
import { generateComponentId, executeCompiledComponentScriptsIn } from './component.js';
|
|
22
21
|
|
|
23
|
-
// Runtime batch-render helpers for full-replacement of simple templates.
|
|
24
|
-
// Build an HTML string via template-literal compilation, then parse once —
|
|
25
|
-
// avoids per-item clone/parse/hydrate in the hot path.
|
|
26
|
-
// Only used for templates without nested <!-- each --> / <!-- if -->.
|
|
27
|
-
//
|
|
28
|
-
// Invariant: batch produces identical DOM to the clone+hydrate path for any
|
|
29
|
-
// input. New binding forms must be supported in both paths simultaneously,
|
|
30
|
-
// with equivalence tests under tests/e2e/batch-vs-clone-equivalence.spec.js
|
|
31
|
-
// — the test harness flips a force-clone flag and compares region-by-region,
|
|
32
|
-
// so silent divergence between the two paths fails loudly.
|
|
33
22
|
const batchParseTemplate = document.createElement('template');
|
|
34
23
|
|
|
35
|
-
// innerHTML serialization encodes <, >, &, ", ' inside attribute values.
|
|
36
|
-
// Decode them back before wrapping @[expr] in ${...} for the template literal.
|
|
37
24
|
const decodeEntities = (s) => s
|
|
38
25
|
.replace(/</g, '<')
|
|
39
26
|
.replace(/>/g, '>')
|
|
@@ -52,27 +39,12 @@ const hasNestedStructures = (tree) => {
|
|
|
52
39
|
return false;
|
|
53
40
|
};
|
|
54
41
|
|
|
55
|
-
// Batch render template-literal-interpolates `@[expr]` as `${expr}` — that
|
|
56
|
-
// stringifies object/array values, which breaks `<component src>` props that
|
|
57
|
-
// rely on resolveIterationComponentProps to stash non-primitives in the
|
|
58
|
-
// registry. Templates carrying any `<component src>` go through the
|
|
59
|
-
// clone+hydrate path instead.
|
|
60
42
|
const hasComponentSrc = (templateEl) =>
|
|
61
43
|
!!templateEl.querySelector?.('component[src], div.component[src]');
|
|
62
44
|
|
|
63
|
-
// A compiled inlined component with its own setup script carries component-local
|
|
64
|
-
// state (`component({...})` → `$._cN`). Batch render emits one shared HTML string
|
|
65
|
-
// per row, which would duplicate the baked `_cN` id across rows and collapse
|
|
66
|
-
// their state into one bucket. Route these through the clone path, where
|
|
67
|
-
// initializeBlock isolates each row's component ids.
|
|
68
45
|
const hasInlinedComponentScript = (templateEl) =>
|
|
69
46
|
!!templateEl.querySelector?.('script[type="vibe-module"]');
|
|
70
47
|
|
|
71
|
-
// `__vibe.forceClonePath` is a debug/test escape hatch — set it to
|
|
72
|
-
// route every iteration through the clone+hydrate path, even templates that
|
|
73
|
-
// would otherwise qualify for batch. Used by the batch-vs-clone-equivalence
|
|
74
|
-
// test harness so the same templates can be rendered through both paths and
|
|
75
|
-
// compared. Not part of the public API.
|
|
76
48
|
const canUseBatchRender = (template) =>
|
|
77
49
|
!globalThis.__vibe?.forceClonePath &&
|
|
78
50
|
!hasNestedStructures(template) &&
|
|
@@ -80,17 +52,7 @@ const canUseBatchRender = (template) =>
|
|
|
80
52
|
!hasComponentSrc(template.element) &&
|
|
81
53
|
!hasInlinedComponentScript(template.element);
|
|
82
54
|
|
|
83
|
-
// Patterns used by compileBatchFn to recognize bindings in attribute-name and
|
|
84
|
-
// attribute-value positions. The inner alternation mirrors BINDING_INNER from
|
|
85
|
-
// constants.js — it has to allow nested brackets and quoted strings inside the
|
|
86
|
-
// expression so things like `items[0]` or `x.replace(',', '')` round-trip
|
|
87
|
-
// safely. Kept inline rather than re-exported because they live entirely
|
|
88
|
-
// within this compile-time string transformation.
|
|
89
55
|
const BATCH_BINDING_INNER = String.raw`(?:[^\[\]'"]|\[[^\]]*\]|'[^']*'|"[^"]*")+`;
|
|
90
|
-
// Note: lookahead allows `=` because innerHTML serializes a bare attribute
|
|
91
|
-
// name (no value) as `name=""`, so a name-binding written `<icon @[attrName]>`
|
|
92
|
-
// arrives here as `@[attrname]=""`. The leading `\s` keeps this from matching
|
|
93
|
-
// attribute-value bindings like `attr="@[x]"` (preceded by `"`, not whitespace).
|
|
94
56
|
const BATCH_NAME_BINDING_REGEX = new RegExp(
|
|
95
57
|
String.raw`(\s)@\[(${BATCH_BINDING_INNER})\](?:="")?(?=[\s/>])`,
|
|
96
58
|
'g',
|
|
@@ -100,10 +62,6 @@ const BATCH_ATTR_BINDING_REGEX = new RegExp(
|
|
|
100
62
|
'g',
|
|
101
63
|
);
|
|
102
64
|
|
|
103
|
-
// Apply fn to tag spans only (`<el ...>`, quote-aware so a `>` inside an
|
|
104
|
-
// attribute value doesn't end the span), leaving text spans and comments
|
|
105
|
-
// untouched. Used to scope the name-binding rewrite to positions where a
|
|
106
|
-
// name binding can actually occur.
|
|
107
65
|
const mapTagSpans = (html, fn) => {
|
|
108
66
|
let out = '';
|
|
109
67
|
let i = 0;
|
|
@@ -134,58 +92,22 @@ const mapTagSpans = (html, fn) => {
|
|
|
134
92
|
return out;
|
|
135
93
|
};
|
|
136
94
|
|
|
137
|
-
// Whether a hydrate'd attribute is a "value-style" string attr (kept verbatim)
|
|
138
|
-
// rather than a boolean-coerced attr (added/removed by truthiness). Mirrors
|
|
139
|
-
// the predicate hydrate.js uses, so batch and clone classify identically.
|
|
140
95
|
const isValueStyleAttr = (attrName) =>
|
|
141
96
|
VALUE_ATTRS.includes(attrName) ||
|
|
142
97
|
attrName.startsWith('data-') ||
|
|
143
98
|
attrName.startsWith('aria-') ||
|
|
144
99
|
attrName.startsWith('on');
|
|
145
100
|
|
|
146
|
-
// Compile the iteration template into a single function that emits the full
|
|
147
|
-
// HTML for any input array. The function produced here must match the DOM
|
|
148
|
-
// shape of the clone+hydrate path for any binding form — gaps documented in
|
|
149
|
-
// the prior iteration of `do-this-job.md` were closed here:
|
|
150
|
-
//
|
|
151
|
-
// 1. `this.X` rewriting: pre-rewritten at compile time using the iteration's
|
|
152
|
-
// anchor element to resolve the owning componentId. No per-item cost.
|
|
153
|
-
// 2. Boolean-coerced attributes: emitted as conditional template literal
|
|
154
|
-
// expressions so the attribute is absent when the binding evaluates falsy
|
|
155
|
-
// and present (with empty string value, matching hydrate.js) when truthy.
|
|
156
|
-
// 3. DOM properties (value/checked/selected): emitted as attributes for
|
|
157
|
-
// runtime DOM accuracy AND collected into `domPropertyWrites` so
|
|
158
|
-
// renderBatch can run a small post-stamp loop that sets the actual DOM
|
|
159
|
-
// property on each instance — the attribute alone isn't enough.
|
|
160
|
-
// 4. Name bindings (`<el @[expr]>`): emitted as conditional template literal
|
|
161
|
-
// expressions that produce ` resolvedName=""` when truthy and nothing
|
|
162
|
-
// when falsy.
|
|
163
|
-
//
|
|
164
|
-
// Anything the clone path renders correctly, the function returned here has
|
|
165
|
-
// to render identically. New binding forms must update both paths in lockstep
|
|
166
|
-
// or the equivalence harness in tests/e2e/batch-vs-clone-equivalence.spec.js
|
|
167
|
-
// will catch the divergence.
|
|
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
101
|
const CID_ROOT_REGEX =
|
|
175
102
|
/('(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*")|(?<![\w$.])_c(\d+)\b/g;
|
|
176
103
|
|
|
177
104
|
export const canonicalizeComponentIds = (expr) =>
|
|
178
105
|
expr.replace(CID_ROOT_REGEX, (m, literal, id) => (literal !== undefined ? literal : `$['_c${id}']`));
|
|
179
106
|
|
|
180
|
-
// Name-binding emission: resolve the expression under the `_e` guard, emit
|
|
181
|
-
// ` value=""` when truthy, nothing otherwise.
|
|
182
107
|
const nameBindingEmit = (exprSrc) =>
|
|
183
108
|
'${(()=>{const _v=_e(()=>(' + exprSrc + '));return _v?\' \'+_v+\'=""\':\'\';})()}';
|
|
184
109
|
|
|
185
110
|
export const compileBatchFn = (template, itemAlias, indexAlias, stateKeys, anchorEl) => {
|
|
186
|
-
// Walk a clone of the template DOM so we can stash a transient
|
|
187
|
-
// `data-vibe-batch` marker on every element with DOM-property bindings
|
|
188
|
-
// without polluting the cached template that subsequent renders read.
|
|
189
111
|
const tplClone = template.element.cloneNode(true);
|
|
190
112
|
const componentId = anchorEl ? findComponentIdForElement(anchorEl) : null;
|
|
191
113
|
const domPropertyWrites = [];
|
|
@@ -212,20 +134,12 @@ export const compileBatchFn = (template, itemAlias, indexAlias, stateKeys, ancho
|
|
|
212
134
|
|
|
213
135
|
let templateHtml = tplClone.innerHTML.trim();
|
|
214
136
|
|
|
215
|
-
// A name-binding the compiler relocated into `data-vibe-namebind` (its expression has
|
|
216
|
-
// whitespace, so it can't be an HTML attribute name) — restore the `@[expr]=""` form
|
|
217
|
-
// the name-binding pass below understands. Safe in this template STRING: only the
|
|
218
|
-
// resolved value (no whitespace) ever reaches innerHTML. Mirrors the clone path, which
|
|
219
|
-
// reads the same binding from the manifest's nameBindings.
|
|
220
137
|
templateHtml = templateHtml.replace(
|
|
221
138
|
/\sdata-vibe-namebind="((?:@\[(?:[^[\]'"]|\[[^\]]*\]|'[^']*'|"[^"]*")+\])+)"/g,
|
|
222
139
|
(_, bindings) =>
|
|
223
140
|
bindings.replace(/@\[(?:[^[\]'"]|\[[^\]]*\]|'[^']*'|"[^"]*")+\]/g, (b) => ' ' + b + '=""'),
|
|
224
141
|
);
|
|
225
142
|
|
|
226
|
-
// Rewrite `this.X` only inside @[…] expressions so literal occurrences in
|
|
227
|
-
// text content (e.g. a code example explaining `this.foo`) aren't mangled.
|
|
228
|
-
// Mirrors evalInScope's behavior in the clone path.
|
|
229
143
|
if (componentId) {
|
|
230
144
|
BINDING_REGEX.lastIndex = 0;
|
|
231
145
|
templateHtml = templateHtml.replace(BINDING_REGEX, (_, expr) =>
|
|
@@ -233,12 +147,6 @@ export const compileBatchFn = (template, itemAlias, indexAlias, stateKeys, ancho
|
|
|
233
147
|
);
|
|
234
148
|
}
|
|
235
149
|
|
|
236
|
-
// Compiler-stamped component references (`_cN.x` in SPA fragments) resolve
|
|
237
|
-
// through `$` at call time — the mirror of evalInScope's id canonicalization.
|
|
238
|
-
// Baked as bare identifiers they'd be a ReferenceError whenever the id is
|
|
239
|
-
// missing from the compile-time state-key snapshot (a fragment iteration can
|
|
240
|
-
// hydrate before its component script registers), and a state key that
|
|
241
|
-
// arrives later would never be picked up by the cached batch function.
|
|
242
150
|
BINDING_REGEX.lastIndex = 0;
|
|
243
151
|
templateHtml = templateHtml.replace(BINDING_REGEX, (_, expr) =>
|
|
244
152
|
'@[' + canonicalizeComponentIds(expr) + ']',
|
|
@@ -251,25 +159,6 @@ export const compileBatchFn = (template, itemAlias, indexAlias, stateKeys, ancho
|
|
|
251
159
|
|
|
252
160
|
let code = escaped;
|
|
253
161
|
|
|
254
|
-
// Name bindings: `<el @[expr]>` — emit ` resolvedName=""` when truthy, else
|
|
255
|
-
// emit nothing. The lookahead `(?=[\s/>])` distinguishes name-position
|
|
256
|
-
// bindings from attribute-value-position bindings (which are followed by `=`)
|
|
257
|
-
// — but a TEXT-position binding on its own line is also whitespace-bounded,
|
|
258
|
-
// so the pass runs only over tag spans (mapTagSpans): name bindings can only
|
|
259
|
-
// exist inside a tag.
|
|
260
|
-
//
|
|
261
|
-
// HTML parses attribute names lowercase, so a binding like
|
|
262
|
-
// `<icon @[attrName]>` arrives here as `@[attrname]` and a dotted form like
|
|
263
|
-
// `<icon @[obj.iconName]>` arrives as `@[obj.iconname]`. We recover the
|
|
264
|
-
// proper case in two places:
|
|
265
|
-
// - Single identifier → resolve against `stateKeys` at compile time and
|
|
266
|
-
// emit a direct identifier reference.
|
|
267
|
-
// - Dotted path → split off the first segment (resolved at compile time
|
|
268
|
-
// against `stateKeys` + iteration aliases), then emit a runtime call to
|
|
269
|
-
// `_walkCi` (injected into the function body below) for the remaining
|
|
270
|
-
// segments. Mirrors `resolveCaseInsensitivePath` from utils.js.
|
|
271
|
-
// Bracket / call expressions pass through unchanged — they need a real
|
|
272
|
-
// evaluator and aren't worth special-casing here.
|
|
273
162
|
let needsCiWalker = false;
|
|
274
163
|
code = mapTagSpans(code, (tag) => tag.replace(BATCH_NAME_BINDING_REGEX, (_, _ws, expr) => {
|
|
275
164
|
let decExpr = decodeEntities(expr);
|
|
@@ -291,11 +180,6 @@ export const compileBatchFn = (template, itemAlias, indexAlias, stateKeys, ancho
|
|
|
291
180
|
return nameBindingEmit('_walkCi(' + ciHead + ',' + tailJSON + ')');
|
|
292
181
|
}));
|
|
293
182
|
|
|
294
|
-
// Pure-binding attributes (`attr="@[expr]"`) — classify by attribute name:
|
|
295
|
-
// DOM property → emit attribute (post-stamp also writes the property)
|
|
296
|
-
// value-style → emit attribute as-is
|
|
297
|
-
// boolean-coerced → emit conditional ` attr=""` so the attribute is
|
|
298
|
-
// absent when expr is falsy
|
|
299
183
|
code = code.replace(BATCH_ATTR_BINDING_REGEX, (_, _ws, attrName, expr) => {
|
|
300
184
|
const decExpr = decodeEntities(expr);
|
|
301
185
|
if (DOM_PROPERTIES.includes(attrName) || isValueStyleAttr(attrName)) {
|
|
@@ -304,21 +188,8 @@ export const compileBatchFn = (template, itemAlias, indexAlias, stateKeys, ancho
|
|
|
304
188
|
return '${_e(()=>(' + decExpr + ')) ? \' ' + attrName + '=""\' : \'\'}';
|
|
305
189
|
});
|
|
306
190
|
|
|
307
|
-
// Remaining @[…] markers — text content and partial-binding attribute
|
|
308
|
-
// values like `href="/items/@[id]/edit"`.
|
|
309
191
|
code = code.replace(BINDING_REGEX, (_, expr) => '${_e(()=>(' + decodeEntities(expr) + '))}');
|
|
310
192
|
|
|
311
|
-
// `$` is supplied as the last parameter so component-scoped expressions like
|
|
312
|
-
// `$['_c0'].chosen` (produced by the this.X rewrite above) can resolve
|
|
313
|
-
// against the live state. Mirrors evalInScope, which also exposes `$`.
|
|
314
|
-
// `_walkCi` is only emitted when at least one dotted-path name binding was
|
|
315
|
-
// rewritten above — keeps the function body free of dead code for the
|
|
316
|
-
// common no-name-binding case.
|
|
317
|
-
// Per-expression failure contract, mirroring evalInScope: an expression
|
|
318
|
-
// that can't evaluate yet (component state not registered, alias mid-swap)
|
|
319
|
-
// renders '' and the next flush corrects it — it must never throw out of
|
|
320
|
-
// the batch loop and abort every row after it. Non-throwing values pass
|
|
321
|
-
// through untouched, so template-literal coercion stays identical.
|
|
322
193
|
const evalGuardSrc = "const _e = (f) => { try { return f(); } catch { return ''; } };";
|
|
323
194
|
const ciWalkerSrc = needsCiWalker
|
|
324
195
|
? `const _walkCi = (cur, segs) => {
|
|
@@ -354,27 +225,11 @@ export const compileBatchFn = (template, itemAlias, indexAlias, stateKeys, ancho
|
|
|
354
225
|
return { fn, domPropertyWrites };
|
|
355
226
|
};
|
|
356
227
|
|
|
357
|
-
// Registry for non-primitive iteration prop snapshots. Lives on `window` (not
|
|
358
|
-
// on `$`) so it doesn't pollute user-visible state enumeration, but is still
|
|
359
|
-
// reachable from binding expressions because `window` is in evalInScope's
|
|
360
|
-
// known-globals list. Each entry is freed when the owning component element
|
|
361
|
-
// is detached (see releaseOrphanedIterationProps).
|
|
362
|
-
//
|
|
363
|
-
// The name is intentionally all-lowercase. resolveIterationComponentProps
|
|
364
|
-
// injects `@[window.__vibe.iterProps._pN]` into the component's bindings, and
|
|
365
|
-
// prop substitution carries that accessor into the template's own bindings —
|
|
366
|
-
// including name-bindings (`<icon @[props.element]>`). HTML lowercases
|
|
367
|
-
// attribute names, so a camelCase accessor would arrive at hydrate as
|
|
368
|
-
// `window.__vibe.iterProps` and resolve to undefined, silently dropping the
|
|
369
|
-
// attribute. Keeping the global lowercase makes it survive that normalization.
|
|
370
228
|
let __vibeIterPropCounter = 0;
|
|
371
229
|
const ensureIterPropsRegistry = () => {
|
|
372
230
|
return ((window.__vibe ??= {}).iterProps ??= {});
|
|
373
231
|
};
|
|
374
232
|
|
|
375
|
-
// Walk a removed subtree and free any iteration-prop registry slots stashed
|
|
376
|
-
// on `<component>` elements inside it. Called from the mutation-observer
|
|
377
|
-
// cleanup path after DOM detachment.
|
|
378
233
|
export const releaseOrphanedIterationProps = (nodes) => {
|
|
379
234
|
if (!window.__vibe?.iterProps) return;
|
|
380
235
|
for (const node of nodes) {
|
|
@@ -390,22 +245,8 @@ export const releaseOrphanedIterationProps = (nodes) => {
|
|
|
390
245
|
}
|
|
391
246
|
};
|
|
392
247
|
|
|
393
|
-
// Slot content projected into a <component src> is captured raw (`_vibeSlotContent`)
|
|
394
|
-
// and inlined only when processComponent runs — by which point the row's iteration
|
|
395
|
-
// scope is gone. Any `@[...]` in that content rooted in a loop alias (item/index/
|
|
396
|
-
// outer) or `this` therefore can't resolve later: value bindings render undefined
|
|
397
|
-
// and name-bindings (`<icon @[row.icon]>`) never set their attribute. Pre-resolve
|
|
398
|
-
// those into registry-backed global refs here — the same snapshot mechanism the
|
|
399
|
-
// component's own prop attributes use — so the inlined slot hydrates against the
|
|
400
|
-
// right values and the row's update path refreshes them in place (the wrapper
|
|
401
|
-
// inherits `_vibeIterPropExprs`/`data-vibe-iter-prop`, so refreshIterationComponentProps
|
|
402
|
-
// re-evaluates them on each item change). Globals-only bindings are left raw; they
|
|
403
|
-
// resolve through the normal reactive path against the inlined component's scope.
|
|
404
248
|
const SLOT_DIRECTIVE_REGEX = /<!--\s*(if|else if|each)\s+([^]*?)\s*-->/g;
|
|
405
249
|
|
|
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
250
|
const STRING_LITERAL_REGEX = /(['"`])(?:\\.|(?!\1)[^\\])*\1/g;
|
|
410
251
|
const substituteOutsideStrings = (expr, idRegex, replacement) => {
|
|
411
252
|
let out = '';
|
|
@@ -424,9 +265,6 @@ const resolveSlotContentBindings = (el, scopedState, aliases) => {
|
|
|
424
265
|
if (!html || (!html.includes('@[') && !html.includes('<!--'))) return;
|
|
425
266
|
const registry = ensureIterPropsRegistry();
|
|
426
267
|
const idByExpr = new Map();
|
|
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
268
|
const snapshot = (expr) => {
|
|
431
269
|
let id = idByExpr.get(expr);
|
|
432
270
|
if (id === undefined) {
|
|
@@ -453,25 +291,8 @@ const resolveSlotContentBindings = (el, scopedState, aliases) => {
|
|
|
453
291
|
const id = snapshot(expr);
|
|
454
292
|
return id === undefined ? whole : `@[window.__vibe.iterProps.${id}]`;
|
|
455
293
|
});
|
|
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
294
|
if (aliases?.size) {
|
|
472
295
|
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
296
|
const asMatch = kw === 'each' && expr.match(/^([^]*?)\s+as\s+([^]*)$/);
|
|
476
297
|
const original = asMatch ? asMatch[1] : expr;
|
|
477
298
|
let code = original;
|
|
@@ -493,21 +314,18 @@ const resolveSlotContentBindings = (el, scopedState, aliases) => {
|
|
|
493
314
|
}
|
|
494
315
|
};
|
|
495
316
|
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
// inlines the component, since by then iteration scope is gone. Only called
|
|
509
|
-
// from iteration code paths; conditionals don't need this because their branch
|
|
510
|
-
// content is registered in the global manifest and reacts to state updates.
|
|
317
|
+
const resolveScopedSrc = (el, scopedState, scopeKeys) => {
|
|
318
|
+
const src = el.getAttribute('src');
|
|
319
|
+
BINDING_REGEX.lastIndex = 0;
|
|
320
|
+
if (!BINDING_REGEX.test(src)) return;
|
|
321
|
+
const resolved = src.replace(BINDING_REGEX, (match, expr) =>
|
|
322
|
+
extractDependencies(expr).some((dep) => scopeKeys.has(dep))
|
|
323
|
+
? (evalInScope(expr, scopedState, el) ?? match)
|
|
324
|
+
: match,
|
|
325
|
+
);
|
|
326
|
+
if (resolved !== src) el.setAttribute('src', resolved);
|
|
327
|
+
};
|
|
328
|
+
|
|
511
329
|
export const resolveIterationComponentProps = (nodes, scopedState, aliases) => {
|
|
512
330
|
for (let n = 0; n < nodes.length; n++) {
|
|
513
331
|
const node = nodes[n];
|
|
@@ -524,18 +342,6 @@ export const resolveIterationComponentProps = (nodes, scopedState, aliases) => {
|
|
|
524
342
|
const match = attr.value.match(/^@\[(.+)\]$/);
|
|
525
343
|
if (!match) continue;
|
|
526
344
|
const expr = match[1];
|
|
527
|
-
// The registry snapshot only exists to carry iteration-local values
|
|
528
|
-
// (item/index/outer aliases, or component-local `this.X`) past the point
|
|
529
|
-
// where processComponent inlines the component and that scope is gone.
|
|
530
|
-
// A prop whose expression references ONLY globals doesn't need it — and
|
|
531
|
-
// routing it through the snapshot would freeze it, since the slot is
|
|
532
|
-
// refreshed solely on array diffs (the Brawling-loader freeze: a prop
|
|
533
|
-
// bound to `elapsedMilliseconds` in a row whose array never changes).
|
|
534
|
-
// Leave such a binding raw so the normal reactive path tracks the
|
|
535
|
-
// global, but still tag the wrapper: `_vibeIterPropExprs` makes index.js
|
|
536
|
-
// stamp `_vibeIterTree`, which is what lets affected.js's
|
|
537
|
-
// walkInlinedComponentTrees descend in and re-hydrate the raw binding on
|
|
538
|
-
// a global-state change.
|
|
539
345
|
const usesLocalScope =
|
|
540
346
|
/\bthis\b/.test(expr) ||
|
|
541
347
|
(aliases && extractDependencies(expr).some((d) => aliases.has(d)));
|
|
@@ -557,7 +363,6 @@ export const resolveIterationComponentProps = (nodes, scopedState, aliases) => {
|
|
|
557
363
|
(el._vibeIterPropIds = el._vibeIterPropIds || []).push(id);
|
|
558
364
|
(el._vibeIterPropExprs = el._vibeIterPropExprs || []).push({ id, attrName: attr.name, expr });
|
|
559
365
|
} catch {
|
|
560
|
-
// Leave binding raw — processComponent will handle it as a binding
|
|
561
366
|
}
|
|
562
367
|
}
|
|
563
368
|
resolveSlotContentBindings(el, scopedState, aliases);
|
|
@@ -565,13 +370,6 @@ export const resolveIterationComponentProps = (nodes, scopedState, aliases) => {
|
|
|
565
370
|
}
|
|
566
371
|
};
|
|
567
372
|
|
|
568
|
-
// Walk a row's live cloned nodes and invoke `fn` for every inlined-component
|
|
569
|
-
// wrapper carrying `marker` — the resolved node itself plus any
|
|
570
|
-
// `[data-vibe-iter-prop]` descendant that carries it. The prop-refresh and
|
|
571
|
-
// inlined-hydrate passes are identical except for this marker and the per-wrapper
|
|
572
|
-
// work, so they share this walk. (`[data-vibe-iter-prop]` always implies
|
|
573
|
-
// `_vibeIterPropExprs`, set together at mount, so filtering descendants by the
|
|
574
|
-
// marker matches the historical "take all, skip those without exprs" behavior.)
|
|
575
373
|
const forEachIterWrapper = (clonedNodes, marker, fn) => {
|
|
576
374
|
for (let n = 0; n < clonedNodes.length; n++) {
|
|
577
375
|
const node = liveNode(clonedNodes[n]);
|
|
@@ -584,17 +382,6 @@ const forEachIterWrapper = (clonedNodes, marker, fn) => {
|
|
|
584
382
|
}
|
|
585
383
|
};
|
|
586
384
|
|
|
587
|
-
// Walk a row's clones for any element tagged as iteration-prop owner —
|
|
588
|
-
// pre-process `<component src>` (still has the src attribute) and post-process
|
|
589
|
-
// `<component>` wrappers both carry `_vibeIterPropExprs`. For each tracked
|
|
590
|
-
// expression, re-evaluate against the row's new scoped state and write into
|
|
591
|
-
// the registry slot the inlined bindings already reference. Idempotent: same
|
|
592
|
-
// scoped state → same value → no-op write.
|
|
593
|
-
// Returns the set of registry slot ids whose value actually changed this
|
|
594
|
-
// refresh. A slot holding the same reference (e.g. a static ability array on a
|
|
595
|
-
// combatant that only moved) is a no-op, so forceRegistryBackedIterationUpdates
|
|
596
|
-
// can skip re-diffing the nested iteration it feeds — the dominant cost when a
|
|
597
|
-
// row carries large static nested iterations.
|
|
598
385
|
const refreshIterationComponentProps = (clonedNodes, scopedState) => {
|
|
599
386
|
const changed = new Set();
|
|
600
387
|
const registry = ensureIterPropsRegistry();
|
|
@@ -602,54 +389,24 @@ const refreshIterationComponentProps = (clonedNodes, scopedState) => {
|
|
|
602
389
|
for (const { id, expr } of el._vibeIterPropExprs) {
|
|
603
390
|
try {
|
|
604
391
|
const value = evalInScope(expr, scopedState, el);
|
|
605
|
-
// Don't clobber a slot with undefined — mirrors the mount-time guard in
|
|
606
|
-
// resolveIterationComponentProps. forEachIterWrapper reaches every
|
|
607
|
-
// [data-vibe-iter-prop] descendant, including components owned by a
|
|
608
|
-
// DEEPER iteration (e.g. a cell component inside a nested each). Their
|
|
609
|
-
// prop expressions reference the inner each's alias, which isn't in this
|
|
610
|
-
// (outer) row's scope, so they evaluate to undefined here. Skipping keeps
|
|
611
|
-
// the value the inner iteration's own update already set with the correct
|
|
612
|
-
// scope, instead of wiping it to undefined and leaving raw @[...] bindings.
|
|
613
392
|
if (value === undefined) continue;
|
|
614
393
|
if (registry[id] !== value) {
|
|
615
394
|
registry[id] = value;
|
|
616
395
|
changed.add(id);
|
|
617
396
|
}
|
|
618
397
|
} catch {
|
|
619
|
-
// Leave previous registry value in place — same fail-safe as
|
|
620
|
-
// resolveIterationComponentProps's mount-time path.
|
|
621
398
|
}
|
|
622
399
|
}
|
|
623
400
|
});
|
|
624
401
|
return changed;
|
|
625
402
|
};
|
|
626
403
|
|
|
627
|
-
// Walk an inlined component's parsed tree and force `updateIteration` on any
|
|
628
|
-
// iteration node whose arrayPath resolves through the iteration-prop registry
|
|
629
|
-
// (`window.__vibe.iterProps._pN`). The registry slot was just refreshed in
|
|
630
|
-
// place by `refreshIterationComponentProps`, so `affected()` can't notice
|
|
631
|
-
// the change — both old/new evaluations of the path read the same updated
|
|
632
|
-
// value. `updateIteration` is the only place equipped to diff against
|
|
633
|
-
// `iterationNode.runtime.instances` (which still hold the previously rendered
|
|
634
|
-
// items) and emit ADD / REMOVE / UPDATE / MOVE ops to bring the inlined DOM
|
|
635
|
-
// in sync. Without this, an `<inner-component>` whose template iterates over
|
|
636
|
-
// an array prop stays frozen on its initial-render items when the prop's
|
|
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).
|
|
645
404
|
const REGISTRY_SLOT_REGEX = /__vibe\.iterProps\.(_p\d+)/;
|
|
646
405
|
const forceRegistryBackedIterationUpdates = (tree, state, manifest, parentScope, changedSlots) => {
|
|
647
406
|
if (!tree) return;
|
|
648
407
|
if (tree.type === 'iteration') {
|
|
649
408
|
const arrPath = tree.meta?.arrayPath;
|
|
650
409
|
const slot = arrPath && arrPath.match(REGISTRY_SLOT_REGEX);
|
|
651
|
-
// Skip iterations whose backing slot didn't change this cycle. changedSlots
|
|
652
|
-
// is undefined only on legacy/unguarded calls — fall back to always-update.
|
|
653
410
|
if (slot && (!changedSlots || changedSlots.has(slot[1]))) {
|
|
654
411
|
updateIteration(tree, state, state, manifest, parentScope);
|
|
655
412
|
}
|
|
@@ -661,12 +418,6 @@ const forceRegistryBackedIterationUpdates = (tree, state, manifest, parentScope,
|
|
|
661
418
|
}
|
|
662
419
|
};
|
|
663
420
|
|
|
664
|
-
// After registry slots are refreshed, re-hydrate the bindings inside each
|
|
665
|
-
// inlined component wrapper so the new values reach the DOM. Each post-process
|
|
666
|
-
// wrapper carries a `_vibeIterTree` snapshot captured at inline time (in
|
|
667
|
-
// component.js) — that tree retains the original `@[...]` binding text even
|
|
668
|
-
// after the wrapper's live DOM has been hydrated, so subsequent affected→
|
|
669
|
-
// hydrate passes work the same way they would on initial render.
|
|
670
421
|
const hydrateInlinedIterationComponents = (clonedNodes, oldState, newState, manifest, changedSlots) => {
|
|
671
422
|
forEachIterWrapper(clonedNodes, '_vibeIterTree', (wrapper) => {
|
|
672
423
|
const tree = wrapper._vibeIterTree;
|
|
@@ -674,20 +425,10 @@ const hydrateInlinedIterationComponents = (clonedNodes, oldState, newState, mani
|
|
|
674
425
|
if (affectedList.length > 0) {
|
|
675
426
|
hydrate(affectedList, newState, manifest, oldState);
|
|
676
427
|
}
|
|
677
|
-
// Bindings into the iteration-prop registry are visited above, but iteration
|
|
678
|
-
// nodes whose arrayPath resolves through the registry need explicit driving:
|
|
679
|
-
// the refreshed slot is a side effect `affected()` can't see. Update only the
|
|
680
|
-
// iterations whose slot actually changed this cycle (changedSlots).
|
|
681
428
|
forceRegistryBackedIterationUpdates(tree, newState, manifest, {}, changedSlots);
|
|
682
429
|
});
|
|
683
430
|
};
|
|
684
431
|
|
|
685
|
-
// Apply DOM-property writes that compileBatchFn collected. Each batch row
|
|
686
|
-
// gets a built tiny scope (item + index + every state key) which is then
|
|
687
|
-
// passed to evalInScope — same evaluator the clone path uses, so identifier
|
|
688
|
-
// resolution rules (this.X already pre-rewritten, undefined-tolerant lookups)
|
|
689
|
-
// stay aligned. The transient `data-vibe-batch` marker is removed once its
|
|
690
|
-
// expressions have been applied so it doesn't leak into the live DOM.
|
|
691
432
|
const applyDomPropertyWrites = (instances, array, state, itemAlias, indexAlias, domPropertyWrites) => {
|
|
692
433
|
for (let i = 0; i < instances.length; i++) {
|
|
693
434
|
const root = instances[i].element;
|
|
@@ -712,8 +453,6 @@ const applyDomPropertyWrites = (instances, array, state, itemAlias, indexAlias,
|
|
|
712
453
|
if (el.getAttribute(prop) !== str) el.setAttribute(prop, str);
|
|
713
454
|
}
|
|
714
455
|
} else if (value) {
|
|
715
|
-
// checked/selected are boolean — presence/absence is the truthful
|
|
716
|
-
// attribute form, matching the clone path in hydrate.js.
|
|
717
456
|
if (el.getAttribute(prop) !== '') el.setAttribute(prop, '');
|
|
718
457
|
} else if (el.hasAttribute(prop)) {
|
|
719
458
|
el.removeAttribute(prop);
|
|
@@ -724,14 +463,6 @@ const applyDomPropertyWrites = (instances, array, state, itemAlias, indexAlias,
|
|
|
724
463
|
}
|
|
725
464
|
};
|
|
726
465
|
|
|
727
|
-
// Resolve the loop's array against the LIVE `$` proxy root (not the plain
|
|
728
|
-
// diff-snapshot the pipeline renders against — see extractPlainValue in
|
|
729
|
-
// index.js). The plain clones are never reference-identical to the proxy
|
|
730
|
-
// elements the app sees through `$`, so a loop var that flows into an `on*`
|
|
731
|
-
// handler must come from here for `item === $.arr[i]` to hold. Returns null
|
|
732
|
-
// when unresolvable (no live root yet, a derivation that builds fresh objects,
|
|
733
|
-
// or a nested loop whose source hangs off an outer plain item) — callers fall
|
|
734
|
-
// back to the plain item, which is no worse than the pre-fix behavior.
|
|
735
466
|
const resolveLiveArray = (iterationNode, manifest, parentScope = {}) => {
|
|
736
467
|
const liveRoot = (manifest && manifest.__live) || globalThis.$;
|
|
737
468
|
if (!liveRoot) return null;
|
|
@@ -750,11 +481,6 @@ const resolveLiveArray = (iterationNode, manifest, parentScope = {}) => {
|
|
|
750
481
|
const liveItemAt = (liveArray, index, fallback) =>
|
|
751
482
|
liveArray && index < liveArray.length ? liveArray[index] : fallback;
|
|
752
483
|
|
|
753
|
-
// Refresh each instance's `liveItem` (the live `$`-proxy element handed to
|
|
754
|
-
// loop-scoped `$scope` handlers, set when the instance was built) and stamp
|
|
755
|
-
// scope. Re-resolving here keeps `liveItem` correct after the diff reorders or
|
|
756
|
-
// updates instances. Diffing still keys off the plain `inst.item`; only the
|
|
757
|
-
// handler-facing `$scope` value is live.
|
|
758
484
|
const stampScopes = (iterationNode, manifest, parentScope = {}) => {
|
|
759
485
|
const liveArray = resolveLiveArray(iterationNode, manifest, parentScope);
|
|
760
486
|
const instances = iterationNode.runtime.instances;
|
|
@@ -779,17 +505,13 @@ const renderBatch = (iterationNode, array, state, parent, endComment, parentScop
|
|
|
779
505
|
const { batchFn, stateKeys, domPropertyWrites } = iterationNode.runtime;
|
|
780
506
|
const stateValues = stateKeys.map((k) => state[k]);
|
|
781
507
|
const html = batchFn(array, ...stateValues, state);
|
|
782
|
-
// Remember the rendered output so a later update can skip re-rendering when an
|
|
783
|
-
// unrelated state change produces identical HTML (see updateIteration).
|
|
784
508
|
iterationNode.runtime.lastBatchHtml = html;
|
|
785
509
|
|
|
786
|
-
// Batch rows close over every state key (stateKeys = Object.keys(state) at
|
|
787
|
-
// compile time) — "re-render on any change" is their honest dependency
|
|
788
|
-
// set; the identical-HTML guard above absorbs the no-ops (walk parity).
|
|
789
510
|
markAlways(nodeSubscriberOf(iterationNode, 'iteration'));
|
|
790
511
|
|
|
791
512
|
batchParseTemplate.innerHTML = html;
|
|
792
513
|
const frag = batchParseTemplate.content;
|
|
514
|
+
markBoundValues(frag, html);
|
|
793
515
|
const kids = frag.children;
|
|
794
516
|
|
|
795
517
|
const arrayLen = array.length;
|
|
@@ -805,13 +527,9 @@ const renderBatch = (iterationNode, array, state, parent, endComment, parentScop
|
|
|
805
527
|
applyDomPropertyWrites(instances, array, state, itemAlias, indexAlias, domPropertyWrites);
|
|
806
528
|
}
|
|
807
529
|
|
|
808
|
-
// Stamp loop scope so `$scope(this,'alias')` handlers resolve the live item.
|
|
809
530
|
stampScopes(iterationNode, manifest, parentScope);
|
|
810
531
|
};
|
|
811
532
|
|
|
812
|
-
/**
|
|
813
|
-
* Find a comment node with matching text content in the given nodes.
|
|
814
|
-
*/
|
|
815
533
|
const findComment = (nodes, text) => {
|
|
816
534
|
const trimmedText = text.trim();
|
|
817
535
|
for (let i = 0; i < nodes.length; i++) {
|
|
@@ -823,10 +541,6 @@ const findComment = (nodes, text) => {
|
|
|
823
541
|
return null;
|
|
824
542
|
};
|
|
825
543
|
|
|
826
|
-
/**
|
|
827
|
-
* Clone a parsed tree, mapping element references from original to cloned DOM.
|
|
828
|
-
* Walks both trees in lockstep - no map building needed since structure is identical.
|
|
829
|
-
*/
|
|
830
544
|
const cloneTreeWithElements = (originalTree, clonedRoot) => {
|
|
831
545
|
if (!originalTree) return null;
|
|
832
546
|
|
|
@@ -836,7 +550,6 @@ const cloneTreeWithElements = (originalTree, clonedRoot) => {
|
|
|
836
550
|
children: {},
|
|
837
551
|
};
|
|
838
552
|
|
|
839
|
-
// Avoid spread operator for performance
|
|
840
553
|
if (originalTree.attributes) {
|
|
841
554
|
cloned.attributes = originalTree.attributes;
|
|
842
555
|
}
|
|
@@ -848,16 +561,13 @@ const cloneTreeWithElements = (originalTree, clonedRoot) => {
|
|
|
848
561
|
|
|
849
562
|
const clonedChildNodes = clonedRoot?.childNodes;
|
|
850
563
|
|
|
851
|
-
// Clone children recursively - use index from key to find cloned element
|
|
852
564
|
for (const key in originalTree.children) {
|
|
853
565
|
const child = originalTree.children[key];
|
|
854
566
|
if (!child || typeof child !== 'object') continue;
|
|
855
567
|
|
|
856
|
-
// Handle conditional nodes - need to update comment references
|
|
857
568
|
if (child.type === 'conditional' && clonedChildNodes) {
|
|
858
569
|
const { startComment, elseComment, endComment, branches } = child.meta;
|
|
859
570
|
|
|
860
|
-
// Find corresponding comments in cloned DOM
|
|
861
571
|
const clonedStart = findComment(clonedChildNodes, startComment.textContent);
|
|
862
572
|
const clonedElse = elseComment
|
|
863
573
|
? findComment(clonedChildNodes, elseComment.textContent)
|
|
@@ -871,7 +581,7 @@ const cloneTreeWithElements = (originalTree, clonedRoot) => {
|
|
|
871
581
|
startComment: clonedStart || startComment,
|
|
872
582
|
elseComment: clonedElse,
|
|
873
583
|
endComment: clonedEnd || endComment,
|
|
874
|
-
branches,
|
|
584
|
+
branches,
|
|
875
585
|
},
|
|
876
586
|
runtime: {
|
|
877
587
|
activeBranch: undefined,
|
|
@@ -883,11 +593,9 @@ const cloneTreeWithElements = (originalTree, clonedRoot) => {
|
|
|
883
593
|
continue;
|
|
884
594
|
}
|
|
885
595
|
|
|
886
|
-
// Handle iteration nodes - need to update comment references and fresh runtime
|
|
887
596
|
if (child.type === 'iteration' && clonedChildNodes) {
|
|
888
597
|
const { startComment, endComment, template } = child.meta;
|
|
889
598
|
|
|
890
|
-
// Find corresponding comments in cloned DOM
|
|
891
599
|
const clonedStart = findComment(clonedChildNodes, startComment.textContent);
|
|
892
600
|
const clonedEnd = findComment(clonedChildNodes, endComment.textContent);
|
|
893
601
|
|
|
@@ -899,7 +607,7 @@ const cloneTreeWithElements = (originalTree, clonedRoot) => {
|
|
|
899
607
|
indexAlias: child.meta.indexAlias,
|
|
900
608
|
startComment: clonedStart || startComment,
|
|
901
609
|
endComment: clonedEnd || endComment,
|
|
902
|
-
template,
|
|
610
|
+
template,
|
|
903
611
|
},
|
|
904
612
|
runtime: {
|
|
905
613
|
instances: [],
|
|
@@ -910,7 +618,6 @@ const cloneTreeWithElements = (originalTree, clonedRoot) => {
|
|
|
910
618
|
continue;
|
|
911
619
|
}
|
|
912
620
|
|
|
913
|
-
// Extract index from key (format: "nodename_index") - avoid regex for speed
|
|
914
621
|
const underscoreIdx = key.lastIndexOf('_');
|
|
915
622
|
const childIndex = underscoreIdx >= 0 ? parseInt(key.slice(underscoreIdx + 1), 10) : -1;
|
|
916
623
|
const clonedChild = childIndex >= 0 && clonedChildNodes ? clonedChildNodes[childIndex] : null;
|
|
@@ -921,28 +628,6 @@ const cloneTreeWithElements = (originalTree, clonedRoot) => {
|
|
|
921
628
|
return cloned;
|
|
922
629
|
};
|
|
923
630
|
|
|
924
|
-
/**
|
|
925
|
-
* Initialize a block instance from template nodes.
|
|
926
|
-
* Uses cached template tree when available for speed.
|
|
927
|
-
*
|
|
928
|
-
* @param {NodeList} templateNodes - Template nodes to clone
|
|
929
|
-
* @param {Object} scopedState - Scoped state (with localVars/parentScope already applied)
|
|
930
|
-
* @param {Object} cachedTree - Optional cached parsed tree from template
|
|
931
|
-
* @param {string|null} componentId - Component the row belongs to, when the
|
|
932
|
-
* iteration sits inside a component. Stashed on the parseContainer so
|
|
933
|
-
* `findComponentIdForElement` can resolve `this.X` during the hydrate pass
|
|
934
|
-
* that runs while clones are still detached.
|
|
935
|
-
* @returns {Object} { element, tree, clonedNodes }
|
|
936
|
-
*/
|
|
937
|
-
// Compiled mode bakes a fixed `data-vibe-component-id` into each inlined
|
|
938
|
-
// component and stamps its `@[this.x]` bindings / `$.this.x` handlers to that
|
|
939
|
-
// id. An iteration clones its template once per row, so every row would
|
|
940
|
-
// otherwise share that one id — and thus one local-state bucket. (Two brawler
|
|
941
|
-
// slots both resolving to `_c2.open` is why opening one ability drawer opened
|
|
942
|
-
// them all.) Per row, remap every baked id in the clone to a fresh one and
|
|
943
|
-
// rewrite the references to it; the caller then runs the row's component
|
|
944
|
-
// scripts so each registers isolated state under its fresh id. Runtime mode has
|
|
945
|
-
// no baked ids here (components are still `<component src>`), so this no-ops.
|
|
946
631
|
const COMPONENT_ID = /^_c\d+$/;
|
|
947
632
|
|
|
948
633
|
const isolateInlinedComponentIds = (container) => {
|
|
@@ -955,9 +640,6 @@ const isolateInlinedComponentIds = (container) => {
|
|
|
955
640
|
}
|
|
956
641
|
if (!remap.size) return false;
|
|
957
642
|
|
|
958
|
-
// `_c2` must not match inside `_c20` or a longer identifier, so anchor on a
|
|
959
|
-
// non-word/`$` boundary before and a non-digit/word after. Bindings always
|
|
960
|
-
// read the id as `_cN.prop`, so the trailing `.` satisfies the lookahead.
|
|
961
643
|
const refs = [...remap].map(([oldId, newId]) => [
|
|
962
644
|
new RegExp(`(?<![\\w$])${oldId}(?![\\w\\d])`, 'g'),
|
|
963
645
|
newId,
|
|
@@ -989,19 +671,11 @@ export const initializeBlock = (templateNodes, scopedState, cachedTree = null, c
|
|
|
989
671
|
let clonedNodes = [];
|
|
990
672
|
let firstElement = null;
|
|
991
673
|
|
|
992
|
-
// Use cached tree when available: cloneTreeWithElements maps the existing parsed structure
|
|
993
|
-
// onto cloned DOM nodes, avoiding a full parse() call per iteration item.
|
|
994
|
-
// Only fall back to parse() when no cached tree exists (first parse of a new template).
|
|
995
|
-
// cloneTreeWithElements has a mapping bug with compiled mode's tree structure.
|
|
996
|
-
// Keep disabled until the root cause is fixed — the other optimizations
|
|
997
|
-
// (bulk replacement, evalInScope caching, DocumentFragment) cover the hot paths.
|
|
998
674
|
const useCachedTree = false;
|
|
999
675
|
|
|
1000
|
-
// Create a container for parsing (to capture all nodes including comment nodes like <!-- if -->)
|
|
1001
676
|
const parseContainer = document.createElement('div');
|
|
1002
677
|
if (componentId) parseContainer._vibeComponentId = componentId;
|
|
1003
678
|
|
|
1004
|
-
// Clone template nodes into container
|
|
1005
679
|
for (let i = 0; i < templateNodes.length; i++) {
|
|
1006
680
|
const cloned = templateNodes[i].cloneNode(true);
|
|
1007
681
|
parseContainer.appendChild(cloned);
|
|
@@ -1010,44 +684,30 @@ export const initializeBlock = (templateNodes, scopedState, cachedTree = null, c
|
|
|
1010
684
|
}
|
|
1011
685
|
}
|
|
1012
686
|
|
|
1013
|
-
// Preserve raw slot content of nested <component src> elements before renderAllConditionals /
|
|
1014
|
-
// renderAllIterations runs on this branch/iteration instance — those paths strip <!-- if -->
|
|
1015
|
-
// and <!-- each --> templates from the live DOM, so by the time processComponent reads
|
|
1016
|
-
// el.innerHTML (next microtask, when MutationObserver fires) the inactive branch templates
|
|
1017
|
-
// would be gone. cloneNode(true) does not copy expando JS properties, so we must (re)capture
|
|
1018
|
-
// _vibeSlotContent on every clone.
|
|
1019
687
|
const components = parseContainer.querySelectorAll('component[src], div.component[src]');
|
|
688
|
+
const scopeKeys = overlayKeysOf(scopedState);
|
|
1020
689
|
for (let i = 0; i < components.length; i++) {
|
|
1021
690
|
const el = components[i];
|
|
1022
691
|
if (el._vibeSlotContent === undefined) el._vibeSlotContent = el.innerHTML;
|
|
692
|
+
if (scopeKeys) resolveScopedSrc(el, scopedState, scopeKeys);
|
|
1023
693
|
}
|
|
1024
694
|
|
|
1025
|
-
// Give this row its own component ids (compiled mode) before parse() reads the
|
|
1026
|
-
// bindings, then run the row's inlined setup scripts so each registers its own
|
|
1027
|
-
// local state under the fresh id — mirroring the conditional-branch path.
|
|
1028
695
|
if (isolateComponents && isolateInlinedComponentIds(parseContainer)) {
|
|
1029
696
|
executeCompiledComponentScriptsIn([...parseContainer.childNodes]);
|
|
1030
697
|
}
|
|
1031
698
|
|
|
1032
699
|
if (useCachedTree) {
|
|
1033
|
-
// Fast path: map cached tree structure onto cloned DOM (no regex, no DOM walking)
|
|
1034
700
|
tree = cloneTreeWithElements(cachedTree, parseContainer);
|
|
1035
701
|
} else {
|
|
1036
|
-
// Full parse: walk DOM, extract bindings, build tree from scratch. Pass this
|
|
1037
|
-
// loop's aliases so loop-scoped `on*` handlers (and nested ones, via the
|
|
1038
|
-
// parser's child-alias accumulation) rewrite to `$scope(this,'alias')`.
|
|
1039
702
|
tree = parse(parseContainer, undefined, aliasSet);
|
|
1040
703
|
}
|
|
1041
704
|
|
|
1042
|
-
// Extract the cloned nodes from the container (these are the same nodes the tree references)
|
|
1043
|
-
// Avoid Array.from for performance
|
|
1044
705
|
const childNodes = parseContainer.childNodes;
|
|
1045
706
|
clonedNodes = [];
|
|
1046
707
|
for (let i = 0; i < childNodes.length; i++) {
|
|
1047
708
|
clonedNodes.push(childNodes[i]);
|
|
1048
709
|
}
|
|
1049
710
|
|
|
1050
|
-
// If no firstElement found, use parseContainer as fallback
|
|
1051
711
|
if (!firstElement) {
|
|
1052
712
|
firstElement = parseContainer;
|
|
1053
713
|
}
|
|
@@ -1064,19 +724,13 @@ export const initializeBlock = (templateNodes, scopedState, cachedTree = null, c
|
|
|
1064
724
|
};
|
|
1065
725
|
};
|
|
1066
726
|
|
|
1067
|
-
// Create a proxied state with scoped variables (item, index, array)
|
|
1068
727
|
export const createScopedState = (globalState, localVars, parentScope = {}) => {
|
|
1069
|
-
// Pre-compute the combined key list once at creation time.
|
|
1070
|
-
// Avoids rebuilding 3 arrays + Set on every Object.keys() call.
|
|
1071
728
|
const cachedKeys = [...new Set([
|
|
1072
729
|
...Object.keys(localVars),
|
|
1073
730
|
...Object.keys(parentScope),
|
|
1074
731
|
...Reflect.ownKeys(globalState),
|
|
1075
732
|
])];
|
|
1076
733
|
|
|
1077
|
-
// Overlay = the aliases this proxy resolves locally (localVars wins over
|
|
1078
|
-
// parentScope, matching the get order below). affected's descent reuses it for
|
|
1079
|
-
// a cheap plain merge instead of materializing the proxy.
|
|
1080
734
|
const overlay =
|
|
1081
735
|
Object.keys(parentScope).length === 0 ? localVars : { ...parentScope, ...localVars };
|
|
1082
736
|
|
|
@@ -1084,10 +738,6 @@ export const createScopedState = (globalState, localVars, parentScope = {}) => {
|
|
|
1084
738
|
get(target, prop) {
|
|
1085
739
|
if (prop in localVars) return localVars[prop];
|
|
1086
740
|
if (prop in parentScope) return parentScope[prop];
|
|
1087
|
-
// Global fallthrough is a root-key read; alias hits above are the
|
|
1088
|
-
// row's own scope (owned by its iteration's diff) and never record.
|
|
1089
|
-
// When the target is the live root the proxy trap records too — the
|
|
1090
|
-
// window's read Set dedupes.
|
|
1091
741
|
recordRead(prop);
|
|
1092
742
|
return Reflect.get(target, prop);
|
|
1093
743
|
},
|
|
@@ -1124,19 +774,14 @@ export const createScopedState = (globalState, localVars, parentScope = {}) => {
|
|
|
1124
774
|
return rememberScopedKeys(proxy, cachedKeys, overlay);
|
|
1125
775
|
};
|
|
1126
776
|
|
|
1127
|
-
// Render all iterations in the parsed tree
|
|
1128
777
|
export const renderAllIterations = (tree, state, manifest, parentScope = {}) => {
|
|
1129
778
|
let count = 0;
|
|
1130
779
|
|
|
1131
|
-
// If this is an iteration node, render it
|
|
1132
780
|
if (tree.type === 'iteration') {
|
|
1133
781
|
renderIteration(tree, state, manifest, parentScope);
|
|
1134
782
|
return 1;
|
|
1135
783
|
}
|
|
1136
784
|
|
|
1137
|
-
// Recursively render iterations in child nodes — except under an outgoing
|
|
1138
|
-
// wrapper (reactive-src remount in flight): its subtree is frozen until
|
|
1139
|
-
// the swap replaces it.
|
|
1140
785
|
if (tree.children && !isOutgoing(tree.element)) {
|
|
1141
786
|
for (const key in tree.children) {
|
|
1142
787
|
const child = tree.children[key];
|
|
@@ -1149,44 +794,32 @@ export const renderAllIterations = (tree, state, manifest, parentScope = {}) =>
|
|
|
1149
794
|
return count;
|
|
1150
795
|
};
|
|
1151
796
|
|
|
1152
|
-
// Callback for rendering conditionals - set by conditionals.js to avoid circular import
|
|
1153
797
|
let _renderAllConditionals = () => {};
|
|
1154
798
|
export const setRenderAllConditionals = (fn) => {
|
|
1155
799
|
_renderAllConditionals = fn;
|
|
1156
800
|
};
|
|
1157
801
|
|
|
1158
|
-
// Initial render of an iteration block
|
|
1159
802
|
export const renderIteration = (iterationNode, state, manifest, parentScope = {}) => {
|
|
1160
803
|
const { arrayPath, startComment, endComment } = iterationNode.meta;
|
|
1161
804
|
|
|
1162
|
-
// Already rendered - updates go through updateIteration
|
|
1163
805
|
if (iterationNode.runtime.instances?.length > 0) {
|
|
1164
806
|
return;
|
|
1165
807
|
}
|
|
1166
808
|
|
|
1167
|
-
// Check if this iteration has already been rendered
|
|
1168
|
-
// We use a marker on the startComment node itself (survives re-parsing)
|
|
1169
|
-
// @ts-ignore - adding custom property to comment node
|
|
1170
809
|
if (startComment.__vibeRendered) {
|
|
1171
810
|
return;
|
|
1172
811
|
}
|
|
1173
812
|
|
|
1174
|
-
// Recovery for lost comment markers: a <component src> item re-processes its node
|
|
1175
|
-
// out of managedNodes, dropping startComment.__vibeRendered. templateRemoved lives
|
|
1176
|
-
// on runtime (not the DOM), so it survives — a true value means a prior render
|
|
1177
|
-
// already cleared the template, so if content is still present between the
|
|
1178
|
-
// comments, it's rendered; don't rebuild. Falls through only if content was lost.
|
|
1179
813
|
if (iterationNode.runtime.templateRemoved) {
|
|
1180
814
|
let currentNode = startComment.nextSibling;
|
|
1181
815
|
while (currentNode && currentNode !== endComment) {
|
|
1182
816
|
if (currentNode.nodeType === 1) {
|
|
1183
|
-
return;
|
|
817
|
+
return;
|
|
1184
818
|
}
|
|
1185
819
|
currentNode = currentNode.nextSibling;
|
|
1186
820
|
}
|
|
1187
821
|
}
|
|
1188
822
|
|
|
1189
|
-
// Remove template nodes from DOM on first render
|
|
1190
823
|
if (!iterationNode.runtime.templateRemoved) {
|
|
1191
824
|
let node = startComment.nextSibling;
|
|
1192
825
|
while (node && node !== endComment) {
|
|
@@ -1199,12 +832,7 @@ export const renderIteration = (iterationNode, state, manifest, parentScope = {}
|
|
|
1199
832
|
|
|
1200
833
|
const parent = startComment.parentNode;
|
|
1201
834
|
|
|
1202
|
-
// Evaluate the array expression — supports state paths (items),
|
|
1203
|
-
// window globals (window.fights), method calls (items.filter(...)),
|
|
1204
|
-
// and inline literals (['a', 'b']). Falls back to resolvePath for
|
|
1205
|
-
// simple paths that evalInScope might miss in scoped contexts.
|
|
1206
835
|
const resolvedExpr = resolveThisPath(arrayPath, startComment.parentElement);
|
|
1207
|
-
// First render doubles as the iteration's subscription registration.
|
|
1208
836
|
const trackSub = nodeSubscriberOf(iterationNode, 'iteration');
|
|
1209
837
|
trackSub.lastScope = state;
|
|
1210
838
|
beginTracking(trackSub, overlayKeysOf(state));
|
|
@@ -1215,35 +843,22 @@ export const renderIteration = (iterationNode, state, manifest, parentScope = {}
|
|
|
1215
843
|
return;
|
|
1216
844
|
}
|
|
1217
845
|
|
|
1218
|
-
// Compiled path: Check for pre-compiled batch function in manifest
|
|
1219
846
|
if (compiled.canUseCompiled(iterationNode)) {
|
|
1220
847
|
const compiledMeta = compiled.getCompiledMeta(iterationNode);
|
|
1221
848
|
if (compiled.renderCompiled(iterationNode, array, state, compiledMeta, parent, endComment)) {
|
|
1222
|
-
// Compiled rows close over every state key — "any change" is their
|
|
1223
|
-
// honest dependency set (walk parity for treeless instances).
|
|
1224
849
|
markAlways(trackSub);
|
|
1225
|
-
// Mark as rendered
|
|
1226
850
|
startComment.__vibeRendered = true;
|
|
1227
851
|
startComment.__vibeIterationRuntime = iterationNode.runtime;
|
|
1228
852
|
return;
|
|
1229
853
|
}
|
|
1230
|
-
// Fall through to runtime path if compiled failed
|
|
1231
854
|
}
|
|
1232
855
|
|
|
1233
|
-
// Standard path: one shared clone+hydrate render loop (also used by bulkReplace).
|
|
1234
856
|
renderInstances(iterationNode, array, state, manifest, parentScope);
|
|
1235
857
|
|
|
1236
|
-
// Mark comment as rendered (survives re-parsing since comment nodes persist in DOM)
|
|
1237
|
-
// Also store runtime data on the DOM node so it persists across re-parses
|
|
1238
|
-
// @ts-ignore - adding custom property to comment node
|
|
1239
858
|
startComment.__vibeRendered = true;
|
|
1240
|
-
// @ts-ignore - adding custom property to comment node
|
|
1241
859
|
startComment.__vibeIterationRuntime = iterationNode.runtime;
|
|
1242
860
|
};
|
|
1243
861
|
|
|
1244
|
-
// Evaluate the iteration's optional key expression for one item.
|
|
1245
|
-
// Returns undefined when no keyExpr is declared, falling back to the default
|
|
1246
|
-
// heuristic in getItemKey.
|
|
1247
862
|
const evalKeyForItem = (iterationNode, item, index, state, parentScope) => {
|
|
1248
863
|
const { keyExpr, itemAlias, indexAlias } = iterationNode.meta;
|
|
1249
864
|
if (!keyExpr) return undefined;
|
|
@@ -1256,9 +871,6 @@ const evalKeyForItem = (iterationNode, item, index, state, parentScope) => {
|
|
|
1256
871
|
}
|
|
1257
872
|
};
|
|
1258
873
|
|
|
1259
|
-
// One-shot warning when an unkeyed iteration produces index-coupled keys —
|
|
1260
|
-
// i.e. the fallback `hash_..._<index>` path. Only emits in debug mode and only
|
|
1261
|
-
// once per iteration block, so console doesn't drown.
|
|
1262
874
|
const warnIndexCoupledKey = (iterationNode) => {
|
|
1263
875
|
if (iterationNode.runtime.warnedIndexCoupled) return;
|
|
1264
876
|
if (!globalThis.__vibe?.debug) return;
|
|
@@ -1269,7 +881,6 @@ const warnIndexCoupledKey = (iterationNode) => {
|
|
|
1269
881
|
);
|
|
1270
882
|
};
|
|
1271
883
|
|
|
1272
|
-
// Update an iteration block when array changes
|
|
1273
884
|
export const updateIteration = (iterationNode, newState, oldState, manifest, parentScope = {}) => {
|
|
1274
885
|
if (!iterationNode.runtime.instances || !iterationNode.runtime.templateRemoved) return;
|
|
1275
886
|
|
|
@@ -1278,32 +889,12 @@ export const updateIteration = (iterationNode, newState, oldState, manifest, par
|
|
|
1278
889
|
const resolvedExpr = resolveThisPath(arrayPath, startComment.parentElement);
|
|
1279
890
|
|
|
1280
891
|
const stateOldArray = evalInScope(resolvedExpr, oldState, startComment.parentElement) ?? resolvePath(oldState, resolvedExpr) ?? [];
|
|
1281
|
-
// New-side eval re-records the iteration's subscription (self-healing).
|
|
1282
892
|
const trackSub = nodeSubscriberOf(iterationNode, 'iteration');
|
|
1283
893
|
trackSub.lastScope = newState;
|
|
1284
894
|
beginTracking(trackSub, overlayKeysOf(newState));
|
|
1285
895
|
const newArray = evalInScope(resolvedExpr, newState, startComment.parentElement) ?? resolvePath(newState, resolvedExpr) ?? [];
|
|
1286
896
|
endTracking();
|
|
1287
897
|
|
|
1288
|
-
// Use instances (what's actually rendered) as ground truth for old array
|
|
1289
|
-
// whenever the only signal of change is a registry side-effect, or when
|
|
1290
|
-
// the rendered count doesn't match the freshly evaluated state. Three
|
|
1291
|
-
// cases collapse to "trust the rendered snapshot":
|
|
1292
|
-
// 1. oldState === newState — forceRegistryBackedIterationUpdates calls
|
|
1293
|
-
// updateIteration with the same state on both sides because the only
|
|
1294
|
-
// mutation was a registry slot rewrite. The previously rendered
|
|
1295
|
-
// items are the only honest record of what was there before.
|
|
1296
|
-
// 2. stateOldArray === newArray — the iteration's arrayPath resolves
|
|
1297
|
-
// directly to a registry slot (`window.__vibe.iterProps._pN`); the
|
|
1298
|
-
// slot was swapped in place, so both reads return the same NEW
|
|
1299
|
-
// array.
|
|
1300
|
-
// 3. length mismatch — oldState predates the current render.
|
|
1301
|
-
// Otherwise the freshly evaluated state is a trustworthy "old".
|
|
1302
|
-
//
|
|
1303
|
-
// Literal-wrap expressions like `[s]` would otherwise slip through this
|
|
1304
|
-
// net: they build different array refs each eval but both contain the
|
|
1305
|
-
// just-rewritten registry value, so a naive diff sees no change. Case 1
|
|
1306
|
-
// catches them.
|
|
1307
898
|
const instances = iterationNode.runtime.instances;
|
|
1308
899
|
const useInstancesAsOld =
|
|
1309
900
|
oldState === newState ||
|
|
@@ -1313,7 +904,6 @@ export const updateIteration = (iterationNode, newState, oldState, manifest, par
|
|
|
1313
904
|
? instances.map((inst) => inst.item)
|
|
1314
905
|
: stateOldArray;
|
|
1315
906
|
|
|
1316
|
-
// Compiled path: Use pre-compiled batch function when available
|
|
1317
907
|
if (compiled.canUseCompiled(iterationNode)) {
|
|
1318
908
|
const compiledMeta = compiled.getCompiledMeta(iterationNode);
|
|
1319
909
|
if (
|
|
@@ -1326,49 +916,25 @@ export const updateIteration = (iterationNode, newState, oldState, manifest, par
|
|
|
1326
916
|
endComment,
|
|
1327
917
|
)
|
|
1328
918
|
) {
|
|
1329
|
-
// Still treeless — keep the honest any-change subscription.
|
|
1330
919
|
markAlways(trackSub);
|
|
1331
920
|
return;
|
|
1332
921
|
}
|
|
1333
|
-
// Fall through to runtime path if compiled failed
|
|
1334
922
|
}
|
|
1335
923
|
|
|
1336
|
-
// Bulk path: skip O(n²) LCS when arrays share no common items
|
|
1337
|
-
// Handles empty→full, full→empty, and full replacement (no shared keys)
|
|
1338
924
|
if (oldArray.length === 0 || newArray.length === 0) {
|
|
1339
925
|
bulkReplace(iterationNode, newArray, newState, manifest, parentScope);
|
|
1340
926
|
return;
|
|
1341
927
|
}
|
|
1342
928
|
|
|
1343
|
-
// Treeless rows (batch-rendered) can't be patched in place — they have no
|
|
1344
|
-
// parsed tree for updateInstance to walk. When affected pushes them as
|
|
1345
|
-
// iteration-affected (see the same predicate in affected.js), do a full
|
|
1346
|
-
// re-batch so row bindings reflect current state. Clone-rendered iterations
|
|
1347
|
-
// skip this — they take the diff path below.
|
|
1348
929
|
const hasTreelessInstances = instances.length > 0 && !instances[0].tree;
|
|
1349
930
|
if (hasTreelessInstances && oldState !== newState) {
|
|
1350
|
-
// Treeless rows stay on the any-change subscription. If bulkReplace
|
|
1351
|
-
// below clone-renders trees instead, the next new-side eval re-records
|
|
1352
|
-
// real deps (self-healing).
|
|
1353
931
|
markAlways(trackSub);
|
|
1354
|
-
// affected.js conservatively flags a treeless (batch-rendered) iteration on
|
|
1355
|
-
// ANY state change, since it can't walk per-row trees to see which bindings
|
|
1356
|
-
// actually depend on what changed. Before tearing down and recreating every
|
|
1357
|
-
// row, re-run the batch: if it yields identical HTML, the rows don't depend
|
|
1358
|
-
// on what changed, so keep the existing DOM nodes — preserving their event
|
|
1359
|
-
// listeners (e.g. tooltip mouseleave) and any in-progress click on a row
|
|
1360
|
-
// control. DOM-property writes (value/checked/etc.) aren't reflected in the
|
|
1361
|
-
// HTML string, so they're re-applied against the kept rows — property
|
|
1362
|
-
// assignment also wins over a user-dirtied checkbox, which an attribute
|
|
1363
|
-
// rewrite wouldn't.
|
|
1364
932
|
const rt = iterationNode.runtime;
|
|
1365
933
|
if (rt.batchFn && rt.lastBatchHtml !== undefined) {
|
|
1366
934
|
const stateValues = rt.stateKeys.map((k) => newState[k]);
|
|
1367
935
|
const newHtml = rt.batchFn(newArray, ...stateValues, newState);
|
|
1368
936
|
if (newHtml === rt.lastBatchHtml) {
|
|
1369
937
|
if (rt.domPropertyWrites?.length) {
|
|
1370
|
-
// Identical HTML implies identical row count — refresh item refs so
|
|
1371
|
-
// $scope handlers and property writes read the live array.
|
|
1372
938
|
for (let i = 0; i < instances.length; i++) instances[i].item = newArray[i];
|
|
1373
939
|
applyDomPropertyWrites(
|
|
1374
940
|
instances,
|
|
@@ -1393,25 +959,18 @@ export const updateIteration = (iterationNode, newState, oldState, manifest, par
|
|
|
1393
959
|
getItemKey(item, i, evalKeyForItem(iterationNode, item, i, newState, parentScope)),
|
|
1394
960
|
);
|
|
1395
961
|
|
|
1396
|
-
// Detect index-coupled fallback keys (debug-mode warning only).
|
|
1397
962
|
if (!iterationNode.meta.keyExpr && newKeys.some(k => k.startsWith('hash_') || k.startsWith('val_'))) {
|
|
1398
963
|
warnIndexCoupledKey(iterationNode);
|
|
1399
964
|
}
|
|
1400
965
|
|
|
1401
|
-
// O(n) check: if no keys are shared, do bulk replacement instead of O(n²) LCS
|
|
1402
966
|
const oldKeySet = new Set(oldKeys);
|
|
1403
967
|
if (!newKeys.some(k => oldKeySet.has(k))) {
|
|
1404
968
|
bulkReplace(iterationNode, newArray, newState, manifest, parentScope);
|
|
1405
969
|
return;
|
|
1406
970
|
}
|
|
1407
971
|
|
|
1408
|
-
// Standard diff-based updates (arrays share some common items)
|
|
1409
972
|
const operations = computeDiff(oldKeys, newKeys, oldArray, newArray);
|
|
1410
973
|
|
|
1411
|
-
// Apply array-shape operations and capture which instances were
|
|
1412
|
-
// bindings-refreshed by an op (ADD built a fresh tree with newState;
|
|
1413
|
-
// UPDATE patched bindings in place). Captured by reference, not index,
|
|
1414
|
-
// because indices shift during the loop.
|
|
1415
974
|
const refreshedByOps = new WeakSet();
|
|
1416
975
|
operations.forEach((op) => {
|
|
1417
976
|
switch (op.type) {
|
|
@@ -1432,10 +991,6 @@ export const updateIteration = (iterationNode, newState, oldState, manifest, par
|
|
|
1432
991
|
}
|
|
1433
992
|
});
|
|
1434
993
|
|
|
1435
|
-
// Refresh every row the ops didn't touch: moved rows (index changed,
|
|
1436
|
-
// bindings stale) and untouched rows whose outer-scope bindings depend
|
|
1437
|
-
// on state that changed in this update cycle. Each row goes through
|
|
1438
|
-
// updateInstance exactly once — via an op or here.
|
|
1439
994
|
const retained = iterationNode.runtime.instances;
|
|
1440
995
|
for (let i = 0; i < retained.length; i++) {
|
|
1441
996
|
retained[i].index = i;
|
|
@@ -1443,27 +998,13 @@ export const updateIteration = (iterationNode, newState, oldState, manifest, par
|
|
|
1443
998
|
updateInstance(iterationNode, i, retained[i].item, oldState, newState, manifest, parentScope);
|
|
1444
999
|
}
|
|
1445
1000
|
|
|
1446
|
-
// Re-stamp after the diff settles: moved/updated instances now carry their
|
|
1447
|
-
// current item + index, so `$scope` handlers resolve correctly post-reorder.
|
|
1448
1001
|
stampScopes(iterationNode, manifest, parentScope);
|
|
1449
1002
|
};
|
|
1450
1003
|
|
|
1451
|
-
// Prune removed iteration rows from the global manifest (dotPath -> element)
|
|
1452
|
-
// and parsed tree. Both views still reference rows that mounted globally-tracked
|
|
1453
|
-
// content (e.g. a <component src> registers its subtree in the manifest). Vibe
|
|
1454
|
-
// disconnects the page MutationObserver while it reconciles, so the removals
|
|
1455
|
-
// below are never observed — left unpruned, the entries pin detached subtrees
|
|
1456
|
-
// (memory leak) and bloat every later affected/hydrate walk (the
|
|
1457
|
-
// combat-fps-decays-per-reset bug). Scoped to exactly the removed nodes.
|
|
1458
1004
|
const releaseRemovedSubtrees = (removedRoots, manifest) => {
|
|
1459
1005
|
if (!manifest || removedRoots.length === 0) return;
|
|
1460
1006
|
const tree = manifest.__tree;
|
|
1461
1007
|
|
|
1462
|
-
// Reverse the manifest (dotPath -> element) once so each removed root resolves
|
|
1463
|
-
// to its path. An element can be registered at more than one path (a component
|
|
1464
|
-
// wrapper appears at both its own node and an inlined child slot); keep the
|
|
1465
|
-
// SHORTEST so the path scopes the whole row subtree, not an inner slot.
|
|
1466
|
-
// __live / __tree are non-enumerable, so for-in skips them.
|
|
1467
1008
|
const pathOf = new Map();
|
|
1468
1009
|
for (const key in manifest) {
|
|
1469
1010
|
const el = manifest[key];
|
|
@@ -1472,8 +1013,8 @@ const releaseRemovedSubtrees = (removedRoots, manifest) => {
|
|
|
1472
1013
|
if (existing === undefined || key.length < existing.length) pathOf.set(el, key);
|
|
1473
1014
|
}
|
|
1474
1015
|
|
|
1475
|
-
const removedEls = new Set();
|
|
1476
|
-
const removedPaths = new Set();
|
|
1016
|
+
const removedEls = new Set();
|
|
1017
|
+
const removedPaths = new Set();
|
|
1477
1018
|
for (let i = 0; i < removedRoots.length; i++) {
|
|
1478
1019
|
const root = removedRoots[i];
|
|
1479
1020
|
if (!root) continue;
|
|
@@ -1483,30 +1024,15 @@ const releaseRemovedSubtrees = (removedRoots, manifest) => {
|
|
|
1483
1024
|
}
|
|
1484
1025
|
if (removedPaths.size === 0 && removedEls.size === 0) return;
|
|
1485
1026
|
|
|
1486
|
-
// Manifest: drop every entry at or under a removed root's path. Path scope (not
|
|
1487
|
-
// element identity) is what catches content hoisted out of the DOM — an inactive
|
|
1488
|
-
// conditional branch template sits in a detached container yet stays registered
|
|
1489
|
-
// under its row's path.
|
|
1490
1027
|
if (removedPaths.size > 0) {
|
|
1491
1028
|
for (const key in manifest) {
|
|
1492
1029
|
if (pathUnderRemoved(key, removedPaths)) delete manifest[key];
|
|
1493
1030
|
}
|
|
1494
1031
|
}
|
|
1495
1032
|
|
|
1496
|
-
// Parsed tree: delete each removed root's node — its whole subtree (nested
|
|
1497
|
-
// conditionals/iterations and their branch templates) goes with it. Prune
|
|
1498
|
-
// by CONTAINMENT, not just root identity: a row's inlined component trees
|
|
1499
|
-
// are also linked under OTHER ancestors' children (the dual linkage the
|
|
1500
|
-
// affected-walk dedupe documents), keyed by inner wrapper elements that are
|
|
1501
|
-
// never themselves removed roots. Left in place, every later walk descends
|
|
1502
|
-
// those detached trees — re-hydrating dead DOM and (since subscriptions)
|
|
1503
|
-
// re-registering its subscribers after teardown pruned them, pinning the
|
|
1504
|
-
// whole removed subtree forever.
|
|
1505
1033
|
const roots = [...removedEls];
|
|
1506
1034
|
if (tree) pruneTreeNodes(tree, removedEls, roots);
|
|
1507
1035
|
|
|
1508
|
-
// The removed rows' subscribers (bindings, nested conditionals/iterations)
|
|
1509
|
-
// are anchored to nodes that just left the document.
|
|
1510
1036
|
pruneDisconnected();
|
|
1511
1037
|
};
|
|
1512
1038
|
|
|
@@ -1517,7 +1043,6 @@ const underRemovedRoot = (el, roots) => {
|
|
|
1517
1043
|
return false;
|
|
1518
1044
|
};
|
|
1519
1045
|
|
|
1520
|
-
// True when `key` is, or is a descendant of, any path in `removedPaths`.
|
|
1521
1046
|
const pathUnderRemoved = (key, removedPaths) => {
|
|
1522
1047
|
if (removedPaths.has(key)) return true;
|
|
1523
1048
|
for (let i = key.indexOf('.', 1); i !== -1; i = key.indexOf('.', i + 1)) {
|
|
@@ -1538,17 +1063,10 @@ const pruneTreeNodes = (node, removedEls, roots) => {
|
|
|
1538
1063
|
}
|
|
1539
1064
|
};
|
|
1540
1065
|
|
|
1541
|
-
// Bulk replacement: clear all DOM and re-render from scratch
|
|
1542
|
-
// Used when arrays share no common keys (avoids O(n²) LCS)
|
|
1543
1066
|
const bulkReplace = (iterationNode, newArray, state, manifest, parentScope) => {
|
|
1544
1067
|
const { template, startComment, endComment } = iterationNode.meta;
|
|
1545
1068
|
const parent = startComment.parentNode;
|
|
1546
1069
|
|
|
1547
|
-
// Clear all existing DOM between comments using Range (single operation),
|
|
1548
|
-
// then prune the removed subtrees from the manifest + tree. Collect the LIVE
|
|
1549
|
-
// nodes in the range rather than the instances' clonedNodes: a <component src>
|
|
1550
|
-
// row is replaced in place by component.js, so clonedNodes can point at the
|
|
1551
|
-
// stale original wrapper, not the processed content actually being removed.
|
|
1552
1070
|
if (iterationNode.runtime.instances.length > 0) {
|
|
1553
1071
|
const removedRoots = [];
|
|
1554
1072
|
for (let cur = startComment.nextSibling; cur && cur !== endComment; cur = cur.nextSibling) {
|
|
@@ -1566,8 +1084,6 @@ const bulkReplace = (iterationNode, newArray, state, manifest, parentScope) => {
|
|
|
1566
1084
|
return;
|
|
1567
1085
|
}
|
|
1568
1086
|
|
|
1569
|
-
// For simple templates (no nested iterations/conditionals, single root element),
|
|
1570
|
-
// use batch string rendering: one string concatenation loop + one innerHTML parse
|
|
1571
1087
|
if (canUseBatchRender(template)) {
|
|
1572
1088
|
renderBatch(iterationNode, newArray, state, parent, endComment, parentScope, manifest);
|
|
1573
1089
|
const instances = iterationNode.runtime.instances;
|
|
@@ -1577,20 +1093,9 @@ const bulkReplace = (iterationNode, newArray, state, manifest, parentScope) => {
|
|
|
1577
1093
|
return;
|
|
1578
1094
|
}
|
|
1579
1095
|
|
|
1580
|
-
// Complex templates: one shared clone+hydrate render loop.
|
|
1581
1096
|
renderInstances(iterationNode, newArray, state, manifest, parentScope);
|
|
1582
1097
|
};
|
|
1583
1098
|
|
|
1584
|
-
// Find an instance's canonical in-DOM anchor (the first of its cloned nodes
|
|
1585
|
-
// that still lives directly under the iteration's parent). Nested primitives
|
|
1586
|
-
// inside the iteration template — <!-- if -->, <!-- each -->, <component> —
|
|
1587
|
-
// can move/replace cloned nodes between iteration renders (inactive branches
|
|
1588
|
-
// get hoisted into template containers; component[src] wrappers get swapped
|
|
1589
|
-
// for processed wrappers). Any of those mutations make `clonedNodes[0]` a
|
|
1590
|
-
// stale reference to a node no longer under the iteration parent. Callers use
|
|
1591
|
-
// this anchor instead of trusting `clonedNodes[0]` directly, so that
|
|
1592
|
-
// insert-before / move operations always resolve against the iteration's real
|
|
1593
|
-
// DOM slot.
|
|
1594
1099
|
const findInstanceAnchor = (instance, iterationParent) => {
|
|
1595
1100
|
const nodes = instance.clonedNodes || (instance.element ? [instance.element] : []);
|
|
1596
1101
|
for (let i = 0; i < nodes.length; i++) {
|
|
@@ -1599,10 +1104,6 @@ const findInstanceAnchor = (instance, iterationParent) => {
|
|
|
1599
1104
|
return null;
|
|
1600
1105
|
};
|
|
1601
1106
|
|
|
1602
|
-
// Resolve the reference node for `parent.insertBefore(..., ref)` at a given
|
|
1603
|
-
// logical iteration index. Walks later instances until it finds one with a
|
|
1604
|
-
// live anchor under the iteration parent; falls back to `endComment` when no
|
|
1605
|
-
// later instance has any node currently mounted in the iteration.
|
|
1606
1107
|
const resolveInsertBefore = (iterationNode, index, parent) => {
|
|
1607
1108
|
const { instances } = iterationNode.runtime;
|
|
1608
1109
|
for (let i = index; i < instances.length; i++) {
|
|
@@ -1612,28 +1113,15 @@ const resolveInsertBefore = (iterationNode, index, parent) => {
|
|
|
1612
1113
|
return iterationNode.meta.endComment;
|
|
1613
1114
|
};
|
|
1614
1115
|
|
|
1615
|
-
// Detach every DOM node belonging to a logical instance, including content
|
|
1616
|
-
// mounted by nested primitives (conditional branches, nested each rows,
|
|
1617
|
-
// fetched component wrappers) that isn't tracked in `instance.clonedNodes`.
|
|
1618
|
-
// Walks iteration-parent siblings from this instance's anchor up to the next
|
|
1619
|
-
// instance's anchor / endComment, so anything in between — clones, mounted
|
|
1620
|
-
// branches, swapped-in component wrappers — all gets detached. Also sweeps
|
|
1621
|
-
// any clonedNodes that were hoisted out of the iteration parent (e.g. into a
|
|
1622
|
-
// sibling conditional's template container).
|
|
1623
1116
|
const detachInstanceDom = (iterationNode, index, parent) => {
|
|
1624
1117
|
const instance = iterationNode.runtime.instances[index];
|
|
1625
1118
|
const { endComment } = iterationNode.meta;
|
|
1626
1119
|
const anchor = findInstanceAnchor(instance, parent);
|
|
1627
1120
|
const nextAnchor = resolveInsertBefore(iterationNode, index + 1, parent);
|
|
1628
1121
|
|
|
1629
|
-
// Return the live nodes actually removed so callers can prune them from the
|
|
1630
|
-
// manifest + tree (clonedNodes can be stale once a <component src> row is
|
|
1631
|
-
// replaced in place by component.js).
|
|
1632
1122
|
const removed = [];
|
|
1633
1123
|
if (anchor) {
|
|
1634
1124
|
let cur = anchor;
|
|
1635
|
-
// endComment caps the walk even if nextAnchor ordering is ever
|
|
1636
|
-
// corrupted — iteration DOM is bounded by startComment / endComment.
|
|
1637
1125
|
while (cur && cur !== nextAnchor && cur !== endComment) {
|
|
1638
1126
|
const nextSibling = cur.nextSibling;
|
|
1639
1127
|
parent.removeChild(cur);
|
|
@@ -1653,14 +1141,6 @@ const detachInstanceDom = (iterationNode, index, parent) => {
|
|
|
1653
1141
|
return removed;
|
|
1654
1142
|
};
|
|
1655
1143
|
|
|
1656
|
-
// Build a fresh instance's DOM + tree + scope from the iteration template.
|
|
1657
|
-
// Pure function — no DOM insertion, no side effects on iteration state.
|
|
1658
|
-
// Callers decide where the clones go (iteration parent, DocumentFragment).
|
|
1659
|
-
// `liveItem` (resolved by the caller via resolveLiveArray, so a derived-array
|
|
1660
|
-
// loop evaluates the expression once per render, not once per row) is the live
|
|
1661
|
-
// `$`-proxy element for this index. Building scope from it gives nested
|
|
1662
|
-
// conditional stamps and `$scope` handlers the app-visible identity; `item`
|
|
1663
|
-
// (plain snapshot) is still tracked for the diff.
|
|
1664
1144
|
const buildInstance = (iterationNode, item, index, state, parentScope, liveItem) => {
|
|
1665
1145
|
const { itemAlias, indexAlias, template, startComment } = iterationNode.meta;
|
|
1666
1146
|
const localVars = { [itemAlias]: liveItem, [indexAlias]: index };
|
|
@@ -1672,11 +1152,6 @@ const buildInstance = (iterationNode, item, index, state, parentScope, liveItem)
|
|
|
1672
1152
|
return { ...built, scopedState, localVars, liveItem };
|
|
1673
1153
|
};
|
|
1674
1154
|
|
|
1675
|
-
// After a built instance's clones are placed in the DOM (directly or via a
|
|
1676
|
-
// fragment), mark element clones as managed so the page-level MutationObserver
|
|
1677
|
-
// skips them in processMutations, then fire nested iteration/conditional
|
|
1678
|
-
// renders. Without the managed mark those clones would be re-parsed + hydrated
|
|
1679
|
-
// on top of the internal render, duplicating every nested branch.
|
|
1680
1155
|
const finalizeInstance = (built, manifest, parentScope) => {
|
|
1681
1156
|
const { clonedNodes, tree, scopedState, localVars } = built;
|
|
1682
1157
|
for (let i = 0; i < clonedNodes.length; i++) {
|
|
@@ -1689,10 +1164,6 @@ const finalizeInstance = (built, manifest, parentScope) => {
|
|
|
1689
1164
|
}
|
|
1690
1165
|
};
|
|
1691
1166
|
|
|
1692
|
-
// Build, finalize, and commit a fresh set of instances for `array` in one
|
|
1693
|
-
// batched DOM insertion, replacing iterationNode.runtime.instances. The single
|
|
1694
|
-
// clone+hydrate render loop shared by initial render (renderIteration) and full
|
|
1695
|
-
// rebuild (bulkReplace) — they differ only in their preamble, not this loop.
|
|
1696
1167
|
const renderInstances = (iterationNode, array, state, manifest, parentScope) => {
|
|
1697
1168
|
const { startComment, endComment } = iterationNode.meta;
|
|
1698
1169
|
const parent = startComment.parentNode;
|
|
@@ -1710,7 +1181,6 @@ const renderInstances = (iterationNode, array, state, manifest, parentScope) =>
|
|
|
1710
1181
|
}
|
|
1711
1182
|
parent.insertBefore(frag, endComment);
|
|
1712
1183
|
iterationNode.runtime.instances = instances;
|
|
1713
|
-
// Stamp loop scope so `$scope(this,'alias')` handlers resolve the live item.
|
|
1714
1184
|
stampScopes(iterationNode, manifest, parentScope);
|
|
1715
1185
|
};
|
|
1716
1186
|
|
|
@@ -1747,30 +1217,18 @@ const moveInstance = (iterationNode, fromIndex, toIndex) => {
|
|
|
1747
1217
|
|
|
1748
1218
|
const insertBefore = resolveInsertBefore(iterationNode, toIndex + 1, parent);
|
|
1749
1219
|
const nodes = instance.clonedNodes || [instance.element];
|
|
1750
|
-
// Re-insert only nodes currently under the iteration parent — those hoisted
|
|
1751
|
-
// into nested-conditional template containers stay there so we don't
|
|
1752
|
-
// double-count branch content.
|
|
1753
1220
|
for (let i = 0; i < nodes.length; i++) {
|
|
1754
1221
|
const n = nodes[i];
|
|
1755
1222
|
if (n?.parentNode === parent) parent.insertBefore(n, insertBefore);
|
|
1756
1223
|
}
|
|
1757
1224
|
};
|
|
1758
1225
|
|
|
1759
|
-
// Detach-and-rebuild fallback. Used when the row's parsed tree isn't
|
|
1760
|
-
// available (compiled iterations), or when the row template's top-level
|
|
1761
|
-
// shape itself depends on the item (a `<!-- if -->` directly under the
|
|
1762
|
-
// iteration with the item in its expression — flipping branches needs a
|
|
1763
|
-
// rebuild because the active root element type changes).
|
|
1764
1226
|
const updateInstanceRebuild = (iterationNode, index, newItem, state, manifest, parentScope) => {
|
|
1765
1227
|
const parent = iterationNode.meta.startComment.parentNode;
|
|
1766
1228
|
const instance = iterationNode.runtime.instances[index];
|
|
1767
1229
|
|
|
1768
1230
|
const liveItem = liveItemAt(resolveLiveArray(iterationNode, manifest, parentScope), index, newItem);
|
|
1769
1231
|
const built = buildInstance(iterationNode, newItem, index, state, parentScope, liveItem);
|
|
1770
|
-
// Prune the OLD row's manifest/tree entries before finalizing the rebuilt one,
|
|
1771
|
-
// so the replaced subtree is released (same removal contract as removeInstance).
|
|
1772
|
-
// Order matters: prune the detached old nodes before finalizeInstance registers
|
|
1773
|
-
// the new ones, so the new entries are never touched.
|
|
1774
1232
|
const removed = parent ? detachInstanceDom(iterationNode, index, parent) : [];
|
|
1775
1233
|
releaseRemovedSubtrees(removed, manifest);
|
|
1776
1234
|
const insertBefore = resolveInsertBefore(iterationNode, index + 1, parent);
|
|
@@ -1785,17 +1243,6 @@ const updateInstanceRebuild = (iterationNode, index, newItem, state, manifest, p
|
|
|
1785
1243
|
instance.scopedState = built.scopedState;
|
|
1786
1244
|
};
|
|
1787
1245
|
|
|
1788
|
-
// In-place refresh of a single row: rebuild scoped states from oldState +
|
|
1789
|
-
// newState (and possibly a new item), run affected→hydrate against the row's
|
|
1790
|
-
// parsed tree. Same pipeline as top-level state changes; the row's DOM nodes
|
|
1791
|
-
// survive, only changed bindings update.
|
|
1792
|
-
//
|
|
1793
|
-
// Called from two sites in updateIteration:
|
|
1794
|
-
// 1. UPDATE diff op — newItem differs from instance.item
|
|
1795
|
-
// 2. Post-diff outer-state propagation — newItem === instance.item
|
|
1796
|
-
// Both flow through here so iteration updates have one code path, not two.
|
|
1797
|
-
// Compiled / batch instances have no row tree to walk and fall back to a
|
|
1798
|
-
// full rebuild via updateInstanceRebuild.
|
|
1799
1246
|
const updateInstance = (iterationNode, index, newItem, oldState, newState, manifest, parentScope = {}) => {
|
|
1800
1247
|
if (index < 0 || index >= iterationNode.runtime.instances.length) return;
|
|
1801
1248
|
const instance = iterationNode.runtime.instances[index];
|
|
@@ -1807,11 +1254,6 @@ const updateInstance = (iterationNode, index, newItem, oldState, newState, manif
|
|
|
1807
1254
|
|
|
1808
1255
|
const { itemAlias, indexAlias } = iterationNode.meta;
|
|
1809
1256
|
const newLocalVars = { [itemAlias]: newItem, [indexAlias]: index };
|
|
1810
|
-
// The "old" scoped state is exactly last cycle's "new" one — same global
|
|
1811
|
-
// snapshot (this cycle's oldState) and same item/index — already stored on the
|
|
1812
|
-
// instance. Reuse it instead of allocating a second proxy per row per frame
|
|
1813
|
-
// (createScopedState is a combat hot spot). Falls back on the first update
|
|
1814
|
-
// after an add, before scopedState has been recorded.
|
|
1815
1257
|
const oldScopedState =
|
|
1816
1258
|
instance.scopedState ||
|
|
1817
1259
|
createScopedState(oldState, { [itemAlias]: instance.item, [indexAlias]: index }, parentScope);
|