@ape-egg/vibe 1.9.1 → 1.9.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +52 -0
- package/README.md +62 -111
- package/ROADMAP.md +6 -12
- package/compiler/native/vibe-compiler-darwin-arm64 +0 -0
- package/compiler/native/vibe-compiler-linux-x64 +0 -0
- package/compiler/src/compiler/PRE-RENDERING-IMPLEMENTATION.md +1 -1
- package/compiler/src/compiler/compile.rs +66 -6
- package/compiler/src/parser/html.rs +7 -1
- package/llms.txt +25 -0
- package/package.json +1 -1
- package/runtime/affected.js +141 -52
- package/runtime/component.js +120 -8
- package/runtime/conditionals.js +45 -3
- package/runtime/constants.js +12 -5
- package/runtime/hydrate.js +8 -11
- package/runtime/index.js +35 -1
- package/runtime/iterate.js +550 -61
- package/runtime/iteration-utils.js +9 -2
- package/runtime/loop-scope.js +157 -0
- package/runtime/parse.js +94 -20
- package/runtime/pre-compiled-iterations.js +12 -0
- package/runtime/state.js +18 -1
- package/runtime/utils.js +36 -1
|
@@ -177,8 +177,15 @@ export const longestCommonSubsequence = (arr1, arr2) => {
|
|
|
177
177
|
return lcs;
|
|
178
178
|
};
|
|
179
179
|
|
|
180
|
-
// Generate unique key for array items
|
|
181
|
-
|
|
180
|
+
// Generate unique key for array items.
|
|
181
|
+
// `customKey` (when defined and not null) wins over every default heuristic —
|
|
182
|
+
// the developer has declared identity explicitly via `<!-- each xs as x (expr) -->`.
|
|
183
|
+
// The default heuristic only runs when no custom key was provided.
|
|
184
|
+
export const getItemKey = (item, index, customKey) => {
|
|
185
|
+
if (customKey !== undefined && customKey !== null) {
|
|
186
|
+
return `key_${customKey}`;
|
|
187
|
+
}
|
|
188
|
+
|
|
182
189
|
// 1. If item has 'id' property, use it
|
|
183
190
|
if (item && typeof item === 'object' && 'id' in item) {
|
|
184
191
|
return `id_${item.id}`;
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// Loop-scoped event handlers.
|
|
2
|
+
//
|
|
3
|
+
// An `on*` handler written inside a `<!-- each X as alias -->` loop can reference
|
|
4
|
+
// the bare loop variable directly, e.g. `onclick="pick(ability)"`. At parse time
|
|
5
|
+
// the alias token is rewritten to `$scope(this,'alias')`; at fire time that
|
|
6
|
+
// global resolver walks up the DOM to the nearest instance root stamped with the
|
|
7
|
+
// live item/index and returns it. This passes the *live object* (identity, not a
|
|
8
|
+
// stringified copy), works for derived-source loops, and survives reorders — all
|
|
9
|
+
// while keeping the handler a visible native `on*` attribute.
|
|
10
|
+
//
|
|
11
|
+
// See implement-loop-scoped-event-handlers.md for the full rationale.
|
|
12
|
+
|
|
13
|
+
const IDENT_START = /[A-Za-z_$]/;
|
|
14
|
+
const IDENT_PART = /[A-Za-z0-9_$]/;
|
|
15
|
+
|
|
16
|
+
// Last non-whitespace character already emitted — lets us tell a standalone
|
|
17
|
+
// identifier (rewrite) from a member access like `foo.alias` (leave alone).
|
|
18
|
+
const lastNonSpace = (s) => {
|
|
19
|
+
for (let i = s.length - 1; i >= 0; i--) {
|
|
20
|
+
const c = s[i];
|
|
21
|
+
if (c !== ' ' && c !== '\t' && c !== '\n' && c !== '\r') return c;
|
|
22
|
+
}
|
|
23
|
+
return '';
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// Copy a quoted string literal beginning at `i` (value[i] is the opening quote)
|
|
27
|
+
// verbatim, honoring backslash escapes. Returns the index just past the closer.
|
|
28
|
+
const copyString = (value, i, push) => {
|
|
29
|
+
const quote = value[i];
|
|
30
|
+
push(quote);
|
|
31
|
+
i++;
|
|
32
|
+
const n = value.length;
|
|
33
|
+
while (i < n) {
|
|
34
|
+
const c = value[i];
|
|
35
|
+
if (c === '\\') {
|
|
36
|
+
push(c);
|
|
37
|
+
i++;
|
|
38
|
+
if (i < n) {
|
|
39
|
+
push(value[i]);
|
|
40
|
+
i++;
|
|
41
|
+
}
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
push(c);
|
|
45
|
+
i++;
|
|
46
|
+
if (c === quote) break;
|
|
47
|
+
}
|
|
48
|
+
return i;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
// Rewrite standalone references to loop-variable aliases inside an event-handler
|
|
52
|
+
// expression into `$scope(this,'alias')` calls. Skips `@[...]` binding spans
|
|
53
|
+
// (they keep their existing hydrate-time stringifying behavior), string literals,
|
|
54
|
+
// and member accesses, so only identifiers that genuinely name a loop alias are
|
|
55
|
+
// touched.
|
|
56
|
+
export const rewriteHandlerAliases = (value, aliasSet) => {
|
|
57
|
+
if (!aliasSet || aliasSet.size === 0 || typeof value !== 'string') return value;
|
|
58
|
+
|
|
59
|
+
let out = '';
|
|
60
|
+
const push = (s) => {
|
|
61
|
+
out += s;
|
|
62
|
+
};
|
|
63
|
+
let i = 0;
|
|
64
|
+
const n = value.length;
|
|
65
|
+
|
|
66
|
+
while (i < n) {
|
|
67
|
+
const ch = value[i];
|
|
68
|
+
|
|
69
|
+
// @[...] binding span — copy verbatim. Track bracket depth and skip inner
|
|
70
|
+
// strings so a `]` inside a quoted expression doesn't close the span early.
|
|
71
|
+
if (ch === '@' && value[i + 1] === '[') {
|
|
72
|
+
push('@[');
|
|
73
|
+
i += 2;
|
|
74
|
+
let depth = 1;
|
|
75
|
+
while (i < n && depth > 0) {
|
|
76
|
+
const c = value[i];
|
|
77
|
+
if (c === "'" || c === '"') {
|
|
78
|
+
i = copyString(value, i, push);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (c === '[') depth++;
|
|
82
|
+
else if (c === ']') depth--;
|
|
83
|
+
push(c);
|
|
84
|
+
i++;
|
|
85
|
+
}
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// String literal — copy verbatim.
|
|
90
|
+
if (ch === "'" || ch === '"' || ch === '`') {
|
|
91
|
+
i = copyString(value, i, push);
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Identifier — rewrite when it's a standalone alias reference.
|
|
96
|
+
if (IDENT_START.test(ch)) {
|
|
97
|
+
let j = i + 1;
|
|
98
|
+
while (j < n && IDENT_PART.test(value[j])) j++;
|
|
99
|
+
const ident = value.slice(i, j);
|
|
100
|
+
const isMember = lastNonSpace(out) === '.';
|
|
101
|
+
if (!isMember && aliasSet.has(ident)) {
|
|
102
|
+
push(`$scope(this,'${ident}')`);
|
|
103
|
+
} else {
|
|
104
|
+
push(ident);
|
|
105
|
+
}
|
|
106
|
+
i = j;
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
push(ch);
|
|
111
|
+
i++;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return out;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
// Walk up from `el` to the nearest ancestor stamped with a scope that defines
|
|
118
|
+
// `name`, returning the live value. Returns undefined if no enclosing loop
|
|
119
|
+
// defines the alias. Nested loops resolve naturally: the innermost stamp is hit
|
|
120
|
+
// first; an outer alias is found by continuing up past inner stamps.
|
|
121
|
+
export const resolveScope = (el, name) => {
|
|
122
|
+
let node = el;
|
|
123
|
+
while (node) {
|
|
124
|
+
const scope = node.__vibeScope;
|
|
125
|
+
if (scope && name in scope) return scope[name];
|
|
126
|
+
node = node.parentNode;
|
|
127
|
+
}
|
|
128
|
+
return undefined;
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
// Stamp the in-scope loop vars onto every iteration instance's root element
|
|
132
|
+
// node(s). The stamp accumulates the enclosing loop vars (`parentScope`) plus
|
|
133
|
+
// this loop's item/index, so a single innermost stamp resolves every alias in
|
|
134
|
+
// scope — which is what makes arbitrarily nested `each`/`if` combinations work
|
|
135
|
+
// even when a loop's template is purely another loop (no wrapper element to walk
|
|
136
|
+
// up to). Re-applied after each render and update so a stamp always reflects the
|
|
137
|
+
// current item/index — including after keyed reorders, where instance objects
|
|
138
|
+
// keep current `.item`/`.index`.
|
|
139
|
+
export const stampInstanceScopes = (iterationNode, parentScope = {}) => {
|
|
140
|
+
const { itemAlias, indexAlias } = iterationNode.meta;
|
|
141
|
+
const instances = iterationNode.runtime.instances;
|
|
142
|
+
for (let k = 0; k < instances.length; k++) {
|
|
143
|
+
const inst = instances[k];
|
|
144
|
+
const scope = { ...parentScope, [itemAlias]: inst.item, [indexAlias]: inst.index };
|
|
145
|
+
const roots = inst.clonedNodes || (inst.element ? [inst.element] : []);
|
|
146
|
+
for (let r = 0; r < roots.length; r++) {
|
|
147
|
+
const node = roots[r];
|
|
148
|
+
if (node && node.nodeType === 1) node.__vibeScope = scope;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
// Install the global `$scope` resolver so native inline handlers (which run in
|
|
154
|
+
// global scope at fire time) can call it. Idempotent across boots.
|
|
155
|
+
export const installScopeResolver = () => {
|
|
156
|
+
globalThis.$scope = resolveScope;
|
|
157
|
+
};
|
package/runtime/parse.js
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
DEHYDRATE_CLASS_OR_ATTR,
|
|
9
9
|
THIS_PROP_REGEX,
|
|
10
10
|
} from './constants.js';
|
|
11
|
+
import { rewriteHandlerAliases } from './loop-scope.js';
|
|
11
12
|
|
|
12
13
|
// Walks up the DOM for the nearest component wrapper tagged by component.js.
|
|
13
14
|
// Used to rewrite `this.property` in event handlers to the component's state path.
|
|
@@ -23,7 +24,7 @@ const findComponentIdForElement = (element) => {
|
|
|
23
24
|
// `<div class="component" src>`) returns nulls — its attributes are props
|
|
24
25
|
// owned by processComponent and must stay raw; hydrating them would coerce
|
|
25
26
|
// objects to "[object Object]" or strip boolean-like attrs to empty.
|
|
26
|
-
const captureAttributeBindings = (element) => {
|
|
27
|
+
const captureAttributeBindings = (element, aliasSet) => {
|
|
27
28
|
const nodeName = element.nodeName;
|
|
28
29
|
const isFetchedComponent =
|
|
29
30
|
(nodeName === 'COMPONENT' || (nodeName === 'DIV' && element.classList?.contains('component'))) &&
|
|
@@ -46,15 +47,25 @@ const captureAttributeBindings = (element) => {
|
|
|
46
47
|
continue;
|
|
47
48
|
}
|
|
48
49
|
|
|
49
|
-
//
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
50
|
+
// Event handlers get two compile-time rewrites, computed off the original
|
|
51
|
+
// value and written once:
|
|
52
|
+
// 1. `this.property` → the component's state path (component-local state).
|
|
53
|
+
// 2. bare loop-variable aliases → `$scope(this,'alias')` (loop-scoped
|
|
54
|
+
// handlers — only when an enclosing <!-- each --> alias is in scope).
|
|
55
|
+
if (attr.name.startsWith('on')) {
|
|
56
|
+
let v = attr.value;
|
|
57
|
+
if (v.includes('this.')) {
|
|
58
|
+
const componentId = findComponentIdForElement(element);
|
|
59
|
+
if (componentId) {
|
|
60
|
+
v = v.replace(THIS_PROP_REGEX, (match, prop) =>
|
|
61
|
+
DOM_ELEMENT_PROPERTIES.has(prop) ? match : `$['${componentId}'].${prop}`,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (aliasSet && aliasSet.size > 0) {
|
|
66
|
+
v = rewriteHandlerAliases(v, aliasSet);
|
|
57
67
|
}
|
|
68
|
+
if (v !== attr.value) element.setAttribute(attr.name, v);
|
|
58
69
|
}
|
|
59
70
|
|
|
60
71
|
BINDING_REGEX.lastIndex = 0;
|
|
@@ -69,6 +80,29 @@ const captureAttributeBindings = (element) => {
|
|
|
69
80
|
};
|
|
70
81
|
};
|
|
71
82
|
|
|
83
|
+
// Walk a parsed children map for any iteration node carrying scoped handlers.
|
|
84
|
+
// Each iteration node's own flag already aggregates its descendants, so we take
|
|
85
|
+
// the flag without re-descending into its template; we still recurse through
|
|
86
|
+
// elements and conditional branches to reach nested iteration nodes.
|
|
87
|
+
const subtreeHasScopedHandlers = (nodes) => {
|
|
88
|
+
for (const key in nodes) {
|
|
89
|
+
const node = nodes[key];
|
|
90
|
+
if (!node || typeof node !== 'object') continue;
|
|
91
|
+
if (node.type === 'iteration') {
|
|
92
|
+
if (node.meta?.hasScopedHandlers) return true;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (node.type === 'conditional') {
|
|
96
|
+
const branches = node.meta?.branches;
|
|
97
|
+
if (branches?.if?.children && subtreeHasScopedHandlers(branches.if.children)) return true;
|
|
98
|
+
if (branches?.else?.children && subtreeHasScopedHandlers(branches.else.children)) return true;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (node.children && subtreeHasScopedHandlers(node.children)) return true;
|
|
102
|
+
}
|
|
103
|
+
return false;
|
|
104
|
+
};
|
|
105
|
+
|
|
72
106
|
const parseHTML = (children, rootKey = undefined) =>
|
|
73
107
|
children.reduce((s, element, i) => {
|
|
74
108
|
const { nodeName, textContent } = element;
|
|
@@ -81,7 +115,7 @@ const parseHTML = (children, rootKey = undefined) =>
|
|
|
81
115
|
return rootKey || `${s}${`\$[${innerNodeIdentifier}]`}`;
|
|
82
116
|
}, '');
|
|
83
117
|
|
|
84
|
-
const recursive = (children, rootKey = undefined, skipIndices = new Set(), stats = { skipped: 0 }) => {
|
|
118
|
+
const recursive = (children, rootKey = undefined, skipIndices = new Set(), stats = { skipped: 0 }, aliasSet = new Set()) => {
|
|
85
119
|
let result = {};
|
|
86
120
|
|
|
87
121
|
for (let i = 0; i < children.length; i++) {
|
|
@@ -103,7 +137,7 @@ const recursive = (children, rootKey = undefined, skipIndices = new Set(), stats
|
|
|
103
137
|
const iterationMatch = textContent.trim().match(ITERATION_REGEX);
|
|
104
138
|
|
|
105
139
|
if (iterationMatch) {
|
|
106
|
-
const [_, arrayPath, itemAlias, indexAlias] = iterationMatch;
|
|
140
|
+
const [_, arrayPath, itemAlias, keyExpr, indexAlias] = iterationMatch;
|
|
107
141
|
|
|
108
142
|
try {
|
|
109
143
|
// Find matching end comment
|
|
@@ -118,8 +152,35 @@ const recursive = (children, rootKey = undefined, skipIndices = new Set(), stats
|
|
|
118
152
|
templateContainer.appendChild(node.cloneNode(true));
|
|
119
153
|
});
|
|
120
154
|
|
|
155
|
+
// Aliases in scope inside this loop's template = enclosing aliases plus
|
|
156
|
+
// this loop's item alias and (only when explicitly declared) its index
|
|
157
|
+
// alias. The implicit default index name is never auto-rewritten.
|
|
158
|
+
const childAliases = new Set(aliasSet);
|
|
159
|
+
childAliases.add(itemAlias);
|
|
160
|
+
if (indexAlias) childAliases.add(indexAlias);
|
|
161
|
+
|
|
121
162
|
// Parse the template recursively
|
|
122
|
-
const templateParsed = recursive([...templateContainer.childNodes], undefined, new Set(), stats);
|
|
163
|
+
const templateParsed = recursive([...templateContainer.childNodes], undefined, new Set(), stats, childAliases);
|
|
164
|
+
|
|
165
|
+
// A handler rewritten to `$scope(this,'alias')` only exists in this
|
|
166
|
+
// freshly-parsed runtime template — the manifest's compiled batchFn was
|
|
167
|
+
// generated from the original (unrewritten) template, so it must be
|
|
168
|
+
// bypassed for this iteration (see canUseCompiled). True when a direct
|
|
169
|
+
// handler in this template was rewritten, OR a nested iteration carries
|
|
170
|
+
// scoped handlers (its inlined batchFn would be wrong too). The runtime
|
|
171
|
+
// clone / batch / diff paths all read the rewritten template correctly.
|
|
172
|
+
const hasScopedHandlers =
|
|
173
|
+
(childAliases.size > 0 && templateContainer.innerHTML.includes("$scope(this,")) ||
|
|
174
|
+
subtreeHasScopedHandlers(templateParsed);
|
|
175
|
+
|
|
176
|
+
// All aliases in scope inside this loop (enclosing + this loop's own) —
|
|
177
|
+
// passed back to parse() by iterate.js when it re-parses a cloned
|
|
178
|
+
// instance. Must be the ACCUMULATED set, not just this loop's own: a
|
|
179
|
+
// handler nested in a further if/each inside the loop can reference an
|
|
180
|
+
// outer alias that wasn't rewritten in this loop's own template (the
|
|
181
|
+
// inner structure's branch was extracted to a separate container), so
|
|
182
|
+
// the re-parse needs every enclosing alias to rewrite it.
|
|
183
|
+
const scopeAliases = [...childAliases];
|
|
123
184
|
|
|
124
185
|
// Store iteration metadata (use index for deterministic keys)
|
|
125
186
|
const iterationKey = `iteration_${i}`;
|
|
@@ -129,6 +190,9 @@ const recursive = (children, rootKey = undefined, skipIndices = new Set(), stats
|
|
|
129
190
|
arrayPath,
|
|
130
191
|
itemAlias,
|
|
131
192
|
indexAlias: indexAlias || 'index',
|
|
193
|
+
keyExpr: keyExpr || null,
|
|
194
|
+
hasScopedHandlers,
|
|
195
|
+
scopeAliases,
|
|
132
196
|
startComment: element,
|
|
133
197
|
endComment: children[endIndex],
|
|
134
198
|
template: {
|
|
@@ -183,8 +247,9 @@ const recursive = (children, rootKey = undefined, skipIndices = new Set(), stats
|
|
|
183
247
|
trueBranchContainer.appendChild(node.cloneNode(true));
|
|
184
248
|
});
|
|
185
249
|
|
|
186
|
-
// Parse the true branch recursively
|
|
187
|
-
|
|
250
|
+
// Parse the true branch recursively (loop aliases stay in scope
|
|
251
|
+
// inside a conditional nested within an iteration).
|
|
252
|
+
const trueBranchParsed = recursive([...trueBranchContainer.childNodes], undefined, new Set(), stats, aliasSet);
|
|
188
253
|
|
|
189
254
|
// Extract false branch nodes if else exists
|
|
190
255
|
let falseBranchParsed = null;
|
|
@@ -195,7 +260,7 @@ const recursive = (children, rootKey = undefined, skipIndices = new Set(), stats
|
|
|
195
260
|
falseBranchNodes.forEach((node) => {
|
|
196
261
|
falseBranchContainer.appendChild(node.cloneNode(true));
|
|
197
262
|
});
|
|
198
|
-
falseBranchParsed = recursive([...falseBranchContainer.childNodes], undefined, new Set(), stats);
|
|
263
|
+
falseBranchParsed = recursive([...falseBranchContainer.childNodes], undefined, new Set(), stats, aliasSet);
|
|
199
264
|
}
|
|
200
265
|
|
|
201
266
|
// Store conditional metadata (use index for deterministic keys)
|
|
@@ -205,6 +270,10 @@ const recursive = (children, rootKey = undefined, skipIndices = new Set(), stats
|
|
|
205
270
|
type: 'conditional',
|
|
206
271
|
meta: {
|
|
207
272
|
expression,
|
|
273
|
+
// Enclosing loop aliases — threaded back into parse() when
|
|
274
|
+
// mountBranch re-parses this branch, so loop-scoped handlers
|
|
275
|
+
// (including those nested deeper in further conditionals) rewrite.
|
|
276
|
+
scopeAliases: [...aliasSet],
|
|
208
277
|
startComment: element,
|
|
209
278
|
elseComment: elseIndex !== null ? children[elseIndex] : null,
|
|
210
279
|
endComment: children[endIndex],
|
|
@@ -257,7 +326,7 @@ const recursive = (children, rootKey = undefined, skipIndices = new Set(), stats
|
|
|
257
326
|
const elementForBindings = isTextNode ? element.parentElement : element;
|
|
258
327
|
const textNodeRef = isTextNode ? element : null; // Store reference to actual text node
|
|
259
328
|
|
|
260
|
-
const { attributes, nameBindings } = captureAttributeBindings(element);
|
|
329
|
+
const { attributes, nameBindings } = captureAttributeBindings(element, aliasSet);
|
|
261
330
|
const hasChildren = childNodes.length;
|
|
262
331
|
|
|
263
332
|
if (hasChildren) {
|
|
@@ -267,7 +336,7 @@ const recursive = (children, rootKey = undefined, skipIndices = new Set(), stats
|
|
|
267
336
|
result[rootKey || nodeIdentifier] = {
|
|
268
337
|
parsed,
|
|
269
338
|
element: elementForBindings,
|
|
270
|
-
children: recursive(iteratableChildren, undefined, new Set(), stats),
|
|
339
|
+
children: recursive(iteratableChildren, undefined, new Set(), stats, aliasSet),
|
|
271
340
|
...(attributes && { attributes }),
|
|
272
341
|
...(nameBindings && { nameBindings }),
|
|
273
342
|
...(textNodeRef && { textNode: textNodeRef }),
|
|
@@ -287,17 +356,22 @@ const recursive = (children, rootKey = undefined, skipIndices = new Set(), stats
|
|
|
287
356
|
return result;
|
|
288
357
|
};
|
|
289
358
|
|
|
290
|
-
|
|
359
|
+
// `aliasSet` carries enclosing `<!-- each -->` aliases into the parse. The root
|
|
360
|
+
// page parse passes none; iterate.js passes a loop's own aliases when it
|
|
361
|
+
// re-parses a cloned instance subtree, so loop-scoped `on*` handlers (including
|
|
362
|
+
// those in nested loops, via child-alias accumulation in recursive) rewrite to
|
|
363
|
+
// `$scope(this,'alias')`.
|
|
364
|
+
export default (root, rootKey = undefined, aliasSet = new Set()) => {
|
|
291
365
|
const { childNodes } = root;
|
|
292
366
|
const stats = { skipped: 0 };
|
|
293
367
|
|
|
294
|
-
const { attributes, nameBindings } = captureAttributeBindings(root);
|
|
368
|
+
const { attributes, nameBindings } = captureAttributeBindings(root, aliasSet);
|
|
295
369
|
|
|
296
370
|
return {
|
|
297
371
|
// html: root.outerHTML,
|
|
298
372
|
parsed: parseHTML([...childNodes], rootKey),
|
|
299
373
|
element: root,
|
|
300
|
-
children: recursive(Array.from(childNodes), rootKey, new Set(), stats),
|
|
374
|
+
children: recursive(Array.from(childNodes), rootKey, new Set(), stats, aliasSet),
|
|
301
375
|
...(attributes && { attributes }),
|
|
302
376
|
...(nameBindings && { nameBindings }),
|
|
303
377
|
stats,
|
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
* Based on the prototype in _vibe-compiled-iteration-batch.js
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
import { stampInstanceScopes } from './loop-scope.js';
|
|
12
|
+
|
|
11
13
|
// Reusable template element for parsing compiled HTML
|
|
12
14
|
const parseTemplate = typeof document !== 'undefined' ? document.createElement('template') : null;
|
|
13
15
|
|
|
@@ -33,6 +35,14 @@ export const canUseCompiled = (iterationNode) => {
|
|
|
33
35
|
return false;
|
|
34
36
|
}
|
|
35
37
|
|
|
38
|
+
// A template with a loop-scoped `$scope(this,'alias')` handler was rewritten at
|
|
39
|
+
// parse time; the manifest's compiled batchFn predates that rewrite and would
|
|
40
|
+
// emit the bare, unresolvable alias. Fall back to the runtime path, which reads
|
|
41
|
+
// the rewritten template.
|
|
42
|
+
if (iterationNode.meta.hasScopedHandlers) {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
|
|
36
46
|
// Check compiled data from manifest merge
|
|
37
47
|
const compiled = iterationNode.compiled;
|
|
38
48
|
if (!compiled || !compiled.iterations || !compiled.iterations.batchFn) {
|
|
@@ -116,6 +126,7 @@ export const renderCompiled = (iterationNode, array, state, compiledMeta, parent
|
|
|
116
126
|
|
|
117
127
|
parent.insertBefore(frag, endComment);
|
|
118
128
|
iterationNode.runtime.instances = instances;
|
|
129
|
+
stampInstanceScopes(iterationNode);
|
|
119
130
|
return true;
|
|
120
131
|
}
|
|
121
132
|
|
|
@@ -159,6 +170,7 @@ export const updateCompiled = (iterationNode, newArray, state, compiledMeta, sta
|
|
|
159
170
|
|
|
160
171
|
parent.insertBefore(frag, endComment);
|
|
161
172
|
iterationNode.runtime.instances = instances;
|
|
173
|
+
stampInstanceScopes(iterationNode);
|
|
162
174
|
return true;
|
|
163
175
|
}
|
|
164
176
|
|
package/runtime/state.js
CHANGED
|
@@ -23,7 +23,7 @@ const createDeepProxy = (target, rerender, rootState = null, rootProp = null) =>
|
|
|
23
23
|
// For root level, rootState is the target itself
|
|
24
24
|
if (rootState === null) {
|
|
25
25
|
rootState = target;
|
|
26
|
-
flushCallback = (props) => rerender(props);
|
|
26
|
+
flushCallback = typeof rerender === 'function' ? (props) => rerender(props) : null;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
// Check cache first
|
|
@@ -50,6 +50,23 @@ const createDeepProxy = (target, rerender, rootState = null, rootProp = null) =>
|
|
|
50
50
|
return Reflect.set(obj, prop, value);
|
|
51
51
|
},
|
|
52
52
|
|
|
53
|
+
deleteProperty(obj, prop) {
|
|
54
|
+
// Without notifying changedProps + scheduleFlush, `delete $.foo` is
|
|
55
|
+
// invisible to the reactive pipeline — bindings depending on `foo` (or
|
|
56
|
+
// on `Object.keys($)`) keep showing the deleted value. Critical for
|
|
57
|
+
// component state cleanup: releaseOrphanedComponentState calls
|
|
58
|
+
// `delete window.$[id]` and downstream consumers (e.g. a state
|
|
59
|
+
// inspector iterating root keys) need to re-render.
|
|
60
|
+
if (!(prop in obj)) return Reflect.deleteProperty(obj, prop);
|
|
61
|
+
|
|
62
|
+
const ref = Reflect.deleteProperty(obj, prop);
|
|
63
|
+
if (ref) {
|
|
64
|
+
changedProps.add(rootProp || prop);
|
|
65
|
+
scheduleFlush();
|
|
66
|
+
}
|
|
67
|
+
return ref;
|
|
68
|
+
},
|
|
69
|
+
|
|
53
70
|
get(target, prop) {
|
|
54
71
|
const value = Reflect.get(target, prop);
|
|
55
72
|
|
package/runtime/utils.js
CHANGED
|
@@ -143,13 +143,48 @@ export const evalInScope = (expr, state, element = null) => {
|
|
|
143
143
|
}
|
|
144
144
|
};
|
|
145
145
|
|
|
146
|
+
// Walk a dotted path against a state-like object, falling back to
|
|
147
|
+
// case-insensitive key matching at each segment. Used by name-binding
|
|
148
|
+
// hydration (clone + batch) to recover camelCase property names that the
|
|
149
|
+
// HTML parser lowercased — `<icon @[fx.convertsIcon]>` arrives at the
|
|
150
|
+
// runtime as `@[fx.convertsicon]`, which doesn't match `convertsIcon` on
|
|
151
|
+
// `fx`. Bails on bracket/call expressions because those need a real
|
|
152
|
+
// evaluator (and `evalInScope` already handled them).
|
|
153
|
+
export const resolveCaseInsensitivePath = (state, path) => {
|
|
154
|
+
if (path.includes('[') || path.includes('(')) return undefined;
|
|
155
|
+
const segments = path.split('.');
|
|
156
|
+
let current = state;
|
|
157
|
+
for (const seg of segments) {
|
|
158
|
+
if (current == null) return undefined;
|
|
159
|
+
// Direct first — handles proxies (scoped iteration state) and plain objects.
|
|
160
|
+
if (Reflect.has(Object(current), seg)) {
|
|
161
|
+
current = current[seg];
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
if (typeof current !== 'object') return undefined;
|
|
165
|
+
const ci = Object.keys(current).find((k) => k.toLowerCase() === seg.toLowerCase());
|
|
166
|
+
if (!ci) return undefined;
|
|
167
|
+
current = current[ci];
|
|
168
|
+
}
|
|
169
|
+
return current;
|
|
170
|
+
};
|
|
171
|
+
|
|
146
172
|
// Helper to find component ID for an element
|
|
147
173
|
// Walks up DOM tree to find nearest component wrapper
|
|
148
174
|
export const findComponentIdForElement = (element) => {
|
|
149
175
|
if (!element || !element.closest) return null;
|
|
150
176
|
|
|
151
177
|
const wrapper = element.closest('[data-vibe-component-id]');
|
|
152
|
-
|
|
178
|
+
if (wrapper) return wrapper.getAttribute('data-vibe-component-id');
|
|
179
|
+
// Detached fallback: cloned-but-not-yet-attached subtrees (iteration row
|
|
180
|
+
// construction in `initializeBlock` parses + hydrates inside a fresh
|
|
181
|
+
// parseContainer before insertion). Walk back to the root and consult
|
|
182
|
+
// _vibeComponentId, which iteration code can stash on the parseContainer
|
|
183
|
+
// when it knows the row's owning component up front.
|
|
184
|
+
let root = element;
|
|
185
|
+
while (root.parentNode) root = root.parentNode;
|
|
186
|
+
if (root._vibeComponentId) return root._vibeComponentId;
|
|
187
|
+
return null;
|
|
153
188
|
};
|
|
154
189
|
|
|
155
190
|
// Helper to resolve this.property paths to componentId.property
|