@ape-egg/vibe 1.9.0 → 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 +71 -0
- package/README.md +90 -97
- 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 +175 -83
- package/package.json +2 -1
- package/runtime/affected.js +141 -52
- package/runtime/component.js +302 -148
- package/runtime/conditionals.js +45 -3
- package/runtime/constants.js +24 -5
- package/runtime/hydrate.js +35 -21
- package/runtime/index.js +50 -3
- package/runtime/iterate.js +598 -56
- package/runtime/iteration-utils.js +9 -2
- package/runtime/loop-scope.js +157 -0
- package/runtime/parse.js +95 -20
- package/runtime/pre-compiled-iterations.js +12 -0
- package/runtime/state.js +18 -1
- package/runtime/utils.js +39 -2
|
@@ -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
|
@@ -6,7 +6,9 @@ import {
|
|
|
6
6
|
CONDITIONAL_REGEX,
|
|
7
7
|
DOM_ELEMENT_PROPERTIES,
|
|
8
8
|
DEHYDRATE_CLASS_OR_ATTR,
|
|
9
|
+
THIS_PROP_REGEX,
|
|
9
10
|
} from './constants.js';
|
|
11
|
+
import { rewriteHandlerAliases } from './loop-scope.js';
|
|
10
12
|
|
|
11
13
|
// Walks up the DOM for the nearest component wrapper tagged by component.js.
|
|
12
14
|
// Used to rewrite `this.property` in event handlers to the component's state path.
|
|
@@ -22,7 +24,7 @@ const findComponentIdForElement = (element) => {
|
|
|
22
24
|
// `<div class="component" src>`) returns nulls — its attributes are props
|
|
23
25
|
// owned by processComponent and must stay raw; hydrating them would coerce
|
|
24
26
|
// objects to "[object Object]" or strip boolean-like attrs to empty.
|
|
25
|
-
const captureAttributeBindings = (element) => {
|
|
27
|
+
const captureAttributeBindings = (element, aliasSet) => {
|
|
26
28
|
const nodeName = element.nodeName;
|
|
27
29
|
const isFetchedComponent =
|
|
28
30
|
(nodeName === 'COMPONENT' || (nodeName === 'DIV' && element.classList?.contains('component'))) &&
|
|
@@ -45,15 +47,25 @@ const captureAttributeBindings = (element) => {
|
|
|
45
47
|
continue;
|
|
46
48
|
}
|
|
47
49
|
|
|
48
|
-
//
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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);
|
|
56
67
|
}
|
|
68
|
+
if (v !== attr.value) element.setAttribute(attr.name, v);
|
|
57
69
|
}
|
|
58
70
|
|
|
59
71
|
BINDING_REGEX.lastIndex = 0;
|
|
@@ -68,6 +80,29 @@ const captureAttributeBindings = (element) => {
|
|
|
68
80
|
};
|
|
69
81
|
};
|
|
70
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
|
+
|
|
71
106
|
const parseHTML = (children, rootKey = undefined) =>
|
|
72
107
|
children.reduce((s, element, i) => {
|
|
73
108
|
const { nodeName, textContent } = element;
|
|
@@ -80,7 +115,7 @@ const parseHTML = (children, rootKey = undefined) =>
|
|
|
80
115
|
return rootKey || `${s}${`\$[${innerNodeIdentifier}]`}`;
|
|
81
116
|
}, '');
|
|
82
117
|
|
|
83
|
-
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()) => {
|
|
84
119
|
let result = {};
|
|
85
120
|
|
|
86
121
|
for (let i = 0; i < children.length; i++) {
|
|
@@ -102,7 +137,7 @@ const recursive = (children, rootKey = undefined, skipIndices = new Set(), stats
|
|
|
102
137
|
const iterationMatch = textContent.trim().match(ITERATION_REGEX);
|
|
103
138
|
|
|
104
139
|
if (iterationMatch) {
|
|
105
|
-
const [_, arrayPath, itemAlias, indexAlias] = iterationMatch;
|
|
140
|
+
const [_, arrayPath, itemAlias, keyExpr, indexAlias] = iterationMatch;
|
|
106
141
|
|
|
107
142
|
try {
|
|
108
143
|
// Find matching end comment
|
|
@@ -117,8 +152,35 @@ const recursive = (children, rootKey = undefined, skipIndices = new Set(), stats
|
|
|
117
152
|
templateContainer.appendChild(node.cloneNode(true));
|
|
118
153
|
});
|
|
119
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
|
+
|
|
120
162
|
// Parse the template recursively
|
|
121
|
-
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];
|
|
122
184
|
|
|
123
185
|
// Store iteration metadata (use index for deterministic keys)
|
|
124
186
|
const iterationKey = `iteration_${i}`;
|
|
@@ -128,6 +190,9 @@ const recursive = (children, rootKey = undefined, skipIndices = new Set(), stats
|
|
|
128
190
|
arrayPath,
|
|
129
191
|
itemAlias,
|
|
130
192
|
indexAlias: indexAlias || 'index',
|
|
193
|
+
keyExpr: keyExpr || null,
|
|
194
|
+
hasScopedHandlers,
|
|
195
|
+
scopeAliases,
|
|
131
196
|
startComment: element,
|
|
132
197
|
endComment: children[endIndex],
|
|
133
198
|
template: {
|
|
@@ -182,8 +247,9 @@ const recursive = (children, rootKey = undefined, skipIndices = new Set(), stats
|
|
|
182
247
|
trueBranchContainer.appendChild(node.cloneNode(true));
|
|
183
248
|
});
|
|
184
249
|
|
|
185
|
-
// Parse the true branch recursively
|
|
186
|
-
|
|
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);
|
|
187
253
|
|
|
188
254
|
// Extract false branch nodes if else exists
|
|
189
255
|
let falseBranchParsed = null;
|
|
@@ -194,7 +260,7 @@ const recursive = (children, rootKey = undefined, skipIndices = new Set(), stats
|
|
|
194
260
|
falseBranchNodes.forEach((node) => {
|
|
195
261
|
falseBranchContainer.appendChild(node.cloneNode(true));
|
|
196
262
|
});
|
|
197
|
-
falseBranchParsed = recursive([...falseBranchContainer.childNodes], undefined, new Set(), stats);
|
|
263
|
+
falseBranchParsed = recursive([...falseBranchContainer.childNodes], undefined, new Set(), stats, aliasSet);
|
|
198
264
|
}
|
|
199
265
|
|
|
200
266
|
// Store conditional metadata (use index for deterministic keys)
|
|
@@ -204,6 +270,10 @@ const recursive = (children, rootKey = undefined, skipIndices = new Set(), stats
|
|
|
204
270
|
type: 'conditional',
|
|
205
271
|
meta: {
|
|
206
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],
|
|
207
277
|
startComment: element,
|
|
208
278
|
elseComment: elseIndex !== null ? children[elseIndex] : null,
|
|
209
279
|
endComment: children[endIndex],
|
|
@@ -256,7 +326,7 @@ const recursive = (children, rootKey = undefined, skipIndices = new Set(), stats
|
|
|
256
326
|
const elementForBindings = isTextNode ? element.parentElement : element;
|
|
257
327
|
const textNodeRef = isTextNode ? element : null; // Store reference to actual text node
|
|
258
328
|
|
|
259
|
-
const { attributes, nameBindings } = captureAttributeBindings(element);
|
|
329
|
+
const { attributes, nameBindings } = captureAttributeBindings(element, aliasSet);
|
|
260
330
|
const hasChildren = childNodes.length;
|
|
261
331
|
|
|
262
332
|
if (hasChildren) {
|
|
@@ -266,7 +336,7 @@ const recursive = (children, rootKey = undefined, skipIndices = new Set(), stats
|
|
|
266
336
|
result[rootKey || nodeIdentifier] = {
|
|
267
337
|
parsed,
|
|
268
338
|
element: elementForBindings,
|
|
269
|
-
children: recursive(iteratableChildren, undefined, new Set(), stats),
|
|
339
|
+
children: recursive(iteratableChildren, undefined, new Set(), stats, aliasSet),
|
|
270
340
|
...(attributes && { attributes }),
|
|
271
341
|
...(nameBindings && { nameBindings }),
|
|
272
342
|
...(textNodeRef && { textNode: textNodeRef }),
|
|
@@ -286,17 +356,22 @@ const recursive = (children, rootKey = undefined, skipIndices = new Set(), stats
|
|
|
286
356
|
return result;
|
|
287
357
|
};
|
|
288
358
|
|
|
289
|
-
|
|
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()) => {
|
|
290
365
|
const { childNodes } = root;
|
|
291
366
|
const stats = { skipped: 0 };
|
|
292
367
|
|
|
293
|
-
const { attributes, nameBindings } = captureAttributeBindings(root);
|
|
368
|
+
const { attributes, nameBindings } = captureAttributeBindings(root, aliasSet);
|
|
294
369
|
|
|
295
370
|
return {
|
|
296
371
|
// html: root.outerHTML,
|
|
297
372
|
parsed: parseHTML([...childNodes], rootKey),
|
|
298
373
|
element: root,
|
|
299
|
-
children: recursive(Array.from(childNodes), rootKey, new Set(), stats),
|
|
374
|
+
children: recursive(Array.from(childNodes), rootKey, new Set(), stats, aliasSet),
|
|
300
375
|
...(attributes && { attributes }),
|
|
301
376
|
...(nameBindings && { nameBindings }),
|
|
302
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
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { THIS_PROP_REGEX } from './constants.js';
|
|
2
|
+
|
|
1
3
|
// Fast incrementing counter instead of expensive random hash
|
|
2
4
|
let hashCounter = 0;
|
|
3
5
|
export const hash = () => `_${hashCounter++}`;
|
|
@@ -43,7 +45,7 @@ export const evalInScope = (expr, state, element = null) => {
|
|
|
43
45
|
const componentId = findComponentIdForElement(element);
|
|
44
46
|
if (componentId) {
|
|
45
47
|
// Replace this.property with $['componentId'].property
|
|
46
|
-
normalized = normalized.replace(
|
|
48
|
+
normalized = normalized.replace(THIS_PROP_REGEX, `$['${componentId}'].$1`);
|
|
47
49
|
}
|
|
48
50
|
}
|
|
49
51
|
|
|
@@ -141,13 +143,48 @@ export const evalInScope = (expr, state, element = null) => {
|
|
|
141
143
|
}
|
|
142
144
|
};
|
|
143
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
|
+
|
|
144
172
|
// Helper to find component ID for an element
|
|
145
173
|
// Walks up DOM tree to find nearest component wrapper
|
|
146
174
|
export const findComponentIdForElement = (element) => {
|
|
147
175
|
if (!element || !element.closest) return null;
|
|
148
176
|
|
|
149
177
|
const wrapper = element.closest('[data-vibe-component-id]');
|
|
150
|
-
|
|
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;
|
|
151
188
|
};
|
|
152
189
|
|
|
153
190
|
// Helper to resolve this.property paths to componentId.property
|