@ape-egg/vibe 1.9.1 → 1.9.6
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 +63 -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 +125 -8
- package/runtime/conditionals.js +45 -3
- package/runtime/constants.js +12 -5
- package/runtime/hydrate.js +8 -11
- package/runtime/index.js +42 -1
- package/runtime/iterate.js +617 -71
- package/runtime/iteration-utils.js +9 -2
- package/runtime/loop-scope.js +161 -0
- package/runtime/parse.js +94 -20
- package/runtime/pre-compiled-iterations.js +12 -0
- package/runtime/state.js +45 -1
- package/runtime/utils.js +36 -1
package/runtime/affected.js
CHANGED
|
@@ -1,46 +1,75 @@
|
|
|
1
|
-
import { resolvePath, deepEqual } from
|
|
2
|
-
import { extractDependencies } from
|
|
3
|
-
import { BINDING_REGEX } from
|
|
4
|
-
import { evalInScope, resolveThisPath } from
|
|
1
|
+
import { resolvePath, deepEqual } from "./iteration-utils.js";
|
|
2
|
+
import { extractDependencies } from "./conditionals.js";
|
|
3
|
+
import { BINDING_REGEX } from "./constants.js";
|
|
4
|
+
import { evalInScope, resolveThisPath } from "./utils.js";
|
|
5
5
|
|
|
6
6
|
// Evaluate conditional expression
|
|
7
|
-
const evaluateCondition = (expression, state, element = null) =>
|
|
7
|
+
const evaluateCondition = (expression, state, element = null) =>
|
|
8
|
+
!!evalInScope(expression, state, element);
|
|
8
9
|
|
|
9
10
|
// Helper function to check if a match references a specific key.
|
|
10
11
|
// Fast path: exact match or property access (`user.name` matches `user`).
|
|
11
12
|
// Slow path: word-boundary search for complex expressions like `Math.floor(coins / 100)`
|
|
12
13
|
// where the key appears as an identifier anywhere in the expression.
|
|
13
|
-
const isIdentChar = (c) =>
|
|
14
|
+
const isIdentChar = (c) =>
|
|
15
|
+
(c >= "a" && c <= "z") ||
|
|
16
|
+
(c >= "A" && c <= "Z") ||
|
|
17
|
+
(c >= "0" && c <= "9") ||
|
|
18
|
+
c === "_" ||
|
|
19
|
+
c === "$";
|
|
14
20
|
|
|
15
21
|
const matchesKey = (matchStr, key) => {
|
|
16
|
-
if (
|
|
22
|
+
if (
|
|
23
|
+
matchStr === key ||
|
|
24
|
+
matchStr.startsWith(key + ".") ||
|
|
25
|
+
matchStr.startsWith(key + "[")
|
|
26
|
+
)
|
|
27
|
+
return true;
|
|
17
28
|
|
|
18
29
|
// Search for key as a standalone identifier (word boundaries on both sides)
|
|
19
30
|
let i = 0;
|
|
20
31
|
while ((i = matchStr.indexOf(key, i)) !== -1) {
|
|
21
|
-
const before = i === 0 ?
|
|
22
|
-
const after =
|
|
32
|
+
const before = i === 0 ? "" : matchStr[i - 1];
|
|
33
|
+
const after =
|
|
34
|
+
i + key.length >= matchStr.length ? "" : matchStr[i + key.length];
|
|
23
35
|
if (!isIdentChar(before) && !isIdentChar(after)) return true;
|
|
24
36
|
i += key.length;
|
|
25
37
|
}
|
|
26
38
|
return false;
|
|
27
39
|
};
|
|
28
40
|
|
|
29
|
-
const recursive = (
|
|
41
|
+
const recursive = (
|
|
42
|
+
tree,
|
|
43
|
+
state,
|
|
44
|
+
newState,
|
|
45
|
+
affected,
|
|
46
|
+
scopedStateForHydration = null,
|
|
47
|
+
depth = 0,
|
|
48
|
+
) => {
|
|
30
49
|
// Handle iteration nodes specially
|
|
31
|
-
if (tree.type ===
|
|
32
|
-
const resolvedExpr = resolveThisPath(
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
50
|
+
if (tree.type === "iteration") {
|
|
51
|
+
const resolvedExpr = resolveThisPath(
|
|
52
|
+
tree.meta.arrayPath,
|
|
53
|
+
tree.meta.startComment?.parentElement,
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
const oldArray =
|
|
57
|
+
evalInScope(resolvedExpr, state, tree.meta.startComment?.parentElement) ??
|
|
58
|
+
resolvePath(state, resolvedExpr);
|
|
59
|
+
const newArray =
|
|
60
|
+
evalInScope(
|
|
61
|
+
resolvedExpr,
|
|
62
|
+
newState,
|
|
63
|
+
tree.meta.startComment?.parentElement,
|
|
64
|
+
) ?? resolvePath(newState, resolvedExpr);
|
|
36
65
|
|
|
37
66
|
// Fast path: reference comparison (arrays are typically replaced, not mutated)
|
|
38
67
|
// This avoids expensive O(n) deepEqual for large arrays
|
|
39
68
|
if (oldArray !== newArray) {
|
|
40
69
|
affected.push({
|
|
41
|
-
type:
|
|
70
|
+
type: "iteration",
|
|
42
71
|
node: tree,
|
|
43
|
-
changeType:
|
|
72
|
+
changeType: "array",
|
|
44
73
|
});
|
|
45
74
|
return affected;
|
|
46
75
|
}
|
|
@@ -51,16 +80,26 @@ const recursive = (tree, state, newState, affected, scopedStateForHydration = nu
|
|
|
51
80
|
// Compiled iterations have instances without tree/scopedState — the batch function
|
|
52
81
|
// may reference global state keys (e.g., `selectedCategory` in a button binding).
|
|
53
82
|
// If any state key changed, trigger a rebuild so the batch function re-evaluates.
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
83
|
+
// Instances rendered without a per-row parsed tree — compiled (batch
|
|
84
|
+
// fn from the manifest) or runtime batch (fast path for simple
|
|
85
|
+
// templates) — can't be walked by the recursive descent below to
|
|
86
|
+
// detect nested outer-state bindings. Mark the whole iteration as
|
|
87
|
+
// affected so updateIteration runs and rebuilds the rows.
|
|
88
|
+
const hasTreelessInstances =
|
|
89
|
+
tree.runtime.instances.length > 0 &&
|
|
90
|
+
!tree.runtime.instances[0].tree;
|
|
91
|
+
|
|
92
|
+
if (hasTreelessInstances) {
|
|
58
93
|
const isInitialHydration = state === newState;
|
|
59
94
|
if (!isInitialHydration) {
|
|
60
95
|
const stateKeys = Object.keys(state);
|
|
61
|
-
const hasChangedKey = stateKeys.some(k => state[k] !== newState[k]);
|
|
96
|
+
const hasChangedKey = stateKeys.some((k) => state[k] !== newState[k]);
|
|
62
97
|
if (hasChangedKey) {
|
|
63
|
-
affected.push({
|
|
98
|
+
affected.push({
|
|
99
|
+
type: "iteration",
|
|
100
|
+
node: tree,
|
|
101
|
+
changeType: "array",
|
|
102
|
+
});
|
|
64
103
|
return affected;
|
|
65
104
|
}
|
|
66
105
|
}
|
|
@@ -68,15 +107,28 @@ const recursive = (tree, state, newState, affected, scopedStateForHydration = nu
|
|
|
68
107
|
|
|
69
108
|
for (const instance of tree.runtime.instances) {
|
|
70
109
|
if (instance.tree && instance.scopedState) {
|
|
71
|
-
// Build plain-object snapshots for comparison.
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
//
|
|
110
|
+
// Build plain-object snapshots for comparison. Spreading
|
|
111
|
+
// instance.scopedState yields its local vars (item, index) plus
|
|
112
|
+
// whatever globals were captured at scope-creation time; the
|
|
113
|
+
// overlaying spread of state/newState then writes the *current*
|
|
114
|
+
// values from this update cycle. Both merged states are plain
|
|
115
|
+
// objects with up-to-date values.
|
|
76
116
|
const mergedOldState = { ...instance.scopedState, ...state };
|
|
77
117
|
const mergedNewState = { ...instance.scopedState, ...newState };
|
|
78
|
-
//
|
|
79
|
-
|
|
118
|
+
// scopedStateForHydration must reflect the new state so hydrate's
|
|
119
|
+
// bindings inside the row see post-update values. instance.scopedState
|
|
120
|
+
// is frozen against whatever target renderIteration was called with
|
|
121
|
+
// (often the plain initialState snapshot from index.js), so reading
|
|
122
|
+
// outer-state keys through it returns stale values after later
|
|
123
|
+
// mutations. mergedNewState carries the live globals + localVars.
|
|
124
|
+
recursive(
|
|
125
|
+
instance.tree,
|
|
126
|
+
mergedOldState,
|
|
127
|
+
mergedNewState,
|
|
128
|
+
affected,
|
|
129
|
+
mergedNewState,
|
|
130
|
+
depth + 1,
|
|
131
|
+
);
|
|
80
132
|
}
|
|
81
133
|
}
|
|
82
134
|
}
|
|
@@ -85,23 +137,38 @@ const recursive = (tree, state, newState, affected, scopedStateForHydration = nu
|
|
|
85
137
|
}
|
|
86
138
|
|
|
87
139
|
// Handle conditional nodes specially
|
|
88
|
-
if (tree.type ===
|
|
89
|
-
const oldValue = evaluateCondition(
|
|
90
|
-
|
|
140
|
+
if (tree.type === "conditional") {
|
|
141
|
+
const oldValue = evaluateCondition(
|
|
142
|
+
tree.meta.expression,
|
|
143
|
+
state,
|
|
144
|
+
tree.meta.startComment?.parentElement,
|
|
145
|
+
);
|
|
146
|
+
const newValue = evaluateCondition(
|
|
147
|
+
tree.meta.expression,
|
|
148
|
+
newState,
|
|
149
|
+
tree.meta.startComment?.parentElement,
|
|
150
|
+
);
|
|
91
151
|
|
|
92
152
|
// Check if condition result changed
|
|
93
153
|
if (oldValue !== newValue) {
|
|
94
154
|
affected.push({
|
|
95
|
-
type:
|
|
155
|
+
type: "conditional",
|
|
96
156
|
node: tree,
|
|
97
|
-
changeType:
|
|
157
|
+
changeType: "expression",
|
|
98
158
|
});
|
|
99
159
|
return affected;
|
|
100
160
|
}
|
|
101
161
|
|
|
102
162
|
// Condition didn't change, check for affected elements inside active branch
|
|
103
163
|
if (tree.runtime.activeInstance && tree.runtime.activeInstance.parsedTree) {
|
|
104
|
-
return recursive(
|
|
164
|
+
return recursive(
|
|
165
|
+
tree.runtime.activeInstance.parsedTree,
|
|
166
|
+
state,
|
|
167
|
+
newState,
|
|
168
|
+
affected,
|
|
169
|
+
scopedStateForHydration,
|
|
170
|
+
depth + 1,
|
|
171
|
+
);
|
|
105
172
|
}
|
|
106
173
|
|
|
107
174
|
return affected;
|
|
@@ -128,21 +195,29 @@ const recursive = (tree, state, newState, affected, scopedStateForHydration = nu
|
|
|
128
195
|
// Resolve this.property to componentId.property
|
|
129
196
|
const resolvedInner = resolveThisPath(m.inner, tree.element);
|
|
130
197
|
|
|
131
|
-
const noMatch = !shallowState.some((key) =>
|
|
198
|
+
const noMatch = !shallowState.some((key) =>
|
|
199
|
+
matchesKey(resolvedInner, key),
|
|
200
|
+
);
|
|
132
201
|
|
|
133
202
|
let shouldAffect = false;
|
|
134
203
|
let relevantKeys = [];
|
|
135
204
|
|
|
136
205
|
if (isInitialHydration) {
|
|
137
206
|
// Initial hydration: affect all matched keys
|
|
138
|
-
relevantKeys = shallowNewState.filter((key) =>
|
|
207
|
+
relevantKeys = shallowNewState.filter((key) =>
|
|
208
|
+
matchesKey(resolvedInner, key),
|
|
209
|
+
);
|
|
139
210
|
shouldAffect = noMatch || relevantKeys.length > 0;
|
|
140
211
|
} else {
|
|
141
212
|
// Update: only affect if value changed
|
|
142
|
-
const changedKeys = shallowNewState.filter(
|
|
143
|
-
|
|
213
|
+
const changedKeys = shallowNewState.filter(
|
|
214
|
+
(key) =>
|
|
215
|
+
matchesKey(resolvedInner, key) && state[key] !== newState[key],
|
|
144
216
|
);
|
|
145
|
-
relevantKeys =
|
|
217
|
+
relevantKeys =
|
|
218
|
+
changedKeys.length > 0
|
|
219
|
+
? changedKeys
|
|
220
|
+
: shallowState.filter((key) => matchesKey(resolvedInner, key));
|
|
146
221
|
shouldAffect = noMatch || changedKeys.length > 0;
|
|
147
222
|
}
|
|
148
223
|
|
|
@@ -189,25 +264,30 @@ const recursive = (tree, state, newState, affected, scopedStateForHydration = nu
|
|
|
189
264
|
// Resolve this.property to componentId.property
|
|
190
265
|
const resolvedInner = resolveThisPath(m.inner, tree.element);
|
|
191
266
|
|
|
192
|
-
const noMatch = !shallowState.some((key) =>
|
|
267
|
+
const noMatch = !shallowState.some((key) =>
|
|
268
|
+
matchesKey(resolvedInner, key),
|
|
269
|
+
);
|
|
193
270
|
|
|
194
271
|
let shouldAffect = false;
|
|
195
272
|
|
|
196
273
|
if (isInitialHydration) {
|
|
197
274
|
// Initial hydration: affect all matched keys
|
|
198
|
-
const newMatches = shallowNewState.filter((key) =>
|
|
275
|
+
const newMatches = shallowNewState.filter((key) =>
|
|
276
|
+
matchesKey(resolvedInner, key),
|
|
277
|
+
);
|
|
199
278
|
shouldAffect = noMatch || newMatches.length > 0;
|
|
200
279
|
} else {
|
|
201
280
|
// Update: only affect if value changed
|
|
202
|
-
const changedKeys = shallowNewState.filter(
|
|
203
|
-
|
|
281
|
+
const changedKeys = shallowNewState.filter(
|
|
282
|
+
(key) =>
|
|
283
|
+
matchesKey(resolvedInner, key) && state[key] !== newState[key],
|
|
204
284
|
);
|
|
205
285
|
shouldAffect = noMatch || changedKeys.length > 0;
|
|
206
286
|
}
|
|
207
287
|
|
|
208
288
|
if (shouldAffect) {
|
|
209
289
|
affected.push({
|
|
210
|
-
type:
|
|
290
|
+
type: "attribute",
|
|
211
291
|
attrName,
|
|
212
292
|
attrValue,
|
|
213
293
|
matchOuter: m.outer,
|
|
@@ -239,7 +319,9 @@ const recursive = (tree, state, newState, affected, scopedStateForHydration = nu
|
|
|
239
319
|
const resolvedInner = resolveThisPath(m.inner, tree.element);
|
|
240
320
|
// HTML lowercases attribute names, so expression may be lowercase while state
|
|
241
321
|
// keys are camelCase. Match case-insensitively by trying both direct and lowercased.
|
|
242
|
-
const matchKey = (key) =>
|
|
322
|
+
const matchKey = (key) =>
|
|
323
|
+
matchesKey(resolvedInner, key) ||
|
|
324
|
+
matchesKey(resolvedInner, key.toLowerCase());
|
|
243
325
|
|
|
244
326
|
const noMatch = !shallowState.some(matchKey);
|
|
245
327
|
|
|
@@ -249,15 +331,15 @@ const recursive = (tree, state, newState, affected, scopedStateForHydration = nu
|
|
|
249
331
|
const newMatches = shallowNewState.filter(matchKey);
|
|
250
332
|
shouldAffect = noMatch || newMatches.length > 0;
|
|
251
333
|
} else {
|
|
252
|
-
const changedKeys = shallowNewState.filter(
|
|
253
|
-
matchKey(key) && state[key] !== newState[key]
|
|
334
|
+
const changedKeys = shallowNewState.filter(
|
|
335
|
+
(key) => matchKey(key) && state[key] !== newState[key],
|
|
254
336
|
);
|
|
255
337
|
shouldAffect = noMatch || changedKeys.length > 0;
|
|
256
338
|
}
|
|
257
339
|
|
|
258
340
|
if (shouldAffect) {
|
|
259
341
|
affected.push({
|
|
260
|
-
type:
|
|
342
|
+
type: "nameBinding",
|
|
261
343
|
nameBinding,
|
|
262
344
|
matchOuter: m.outer,
|
|
263
345
|
matchInner: m.inner, // Keep original, evalInScope will resolve this.
|
|
@@ -274,8 +356,15 @@ const recursive = (tree, state, newState, affected, scopedStateForHydration = nu
|
|
|
274
356
|
if (children) {
|
|
275
357
|
for (const key in children) {
|
|
276
358
|
const child = children[key];
|
|
277
|
-
if (child && typeof child ===
|
|
278
|
-
recursive(
|
|
359
|
+
if (child && typeof child === "object") {
|
|
360
|
+
recursive(
|
|
361
|
+
child,
|
|
362
|
+
state,
|
|
363
|
+
newState,
|
|
364
|
+
affected,
|
|
365
|
+
scopedStateForHydration,
|
|
366
|
+
depth + 1,
|
|
367
|
+
);
|
|
279
368
|
}
|
|
280
369
|
}
|
|
281
370
|
}
|
package/runtime/component.js
CHANGED
|
@@ -41,9 +41,49 @@ export const releaseOrphanedComponentState = (collectedIds) => {
|
|
|
41
41
|
delete window.__vibeComponents?.[id];
|
|
42
42
|
// CLEANUP OF CURRENT STATE
|
|
43
43
|
delete window.$[id];
|
|
44
|
+
runComponentCleanups(id);
|
|
44
45
|
}
|
|
45
46
|
};
|
|
46
47
|
|
|
48
|
+
// Listener registry: maps componentId → array of unsubscribe callbacks
|
|
49
|
+
// returned from `$.on(...)` calls inside the component's `<script>`.
|
|
50
|
+
// Re-running a script for the same id (HMR remount with reused ids) or
|
|
51
|
+
// unmounting the component fires the callbacks so the previous evaluation's
|
|
52
|
+
// listeners don't accumulate alongside fresh registrations.
|
|
53
|
+
export const runComponentCleanups = (componentId) => {
|
|
54
|
+
const cleanups = window.__vibeComponentCleanups?.[componentId];
|
|
55
|
+
if (!cleanups) return;
|
|
56
|
+
for (let i = 0; i < cleanups.length; i++) cleanups[i]();
|
|
57
|
+
delete window.__vibeComponentCleanups[componentId];
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
// Per-script `$` Proxy. Bare `$` references in a component's <script> resolve
|
|
61
|
+
// to this Proxy (the function parameter shadows the global), so every
|
|
62
|
+
// `$.on(...)` registration is automatically attributed to `componentId` via
|
|
63
|
+
// the closure — no global flag, async-safe across `await` boundaries because
|
|
64
|
+
// the closure binds the id, not a shared variable.
|
|
65
|
+
const createScopedDollar = (componentId) => {
|
|
66
|
+
const dollar = window.$;
|
|
67
|
+
if (!dollar) return dollar;
|
|
68
|
+
return new Proxy(dollar, {
|
|
69
|
+
get(target, prop, receiver) {
|
|
70
|
+
if (prop === 'on') {
|
|
71
|
+
return (event, callback) => {
|
|
72
|
+
const unsub = target.on(event, callback);
|
|
73
|
+
if (!window.__vibeComponentCleanups) window.__vibeComponentCleanups = {};
|
|
74
|
+
const slot = window.__vibeComponentCleanups[componentId] || (window.__vibeComponentCleanups[componentId] = []);
|
|
75
|
+
slot.push(unsub);
|
|
76
|
+
return unsub;
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
return Reflect.get(target, prop, receiver);
|
|
80
|
+
},
|
|
81
|
+
set(target, prop, value, receiver) {
|
|
82
|
+
return Reflect.set(target, prop, value, receiver);
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
};
|
|
86
|
+
|
|
47
87
|
// Walk a node subtree (element or node list) and collect all
|
|
48
88
|
// `data-vibe-component-id` values found on the node and its descendants.
|
|
49
89
|
export const collectComponentIds = (node, into = new Set()) => {
|
|
@@ -145,12 +185,18 @@ const renderPropsAndSlot = (temp, props, slotHtml) => {
|
|
|
145
185
|
const DIRECTIVE_COMMENT_REGEX = /<!--\s*(if|else if|each)\s+([^]*?)\s*-->/g;
|
|
146
186
|
const EVENT_ATTR_REGEX = /\bon(\w+)="([^"]*)"/g;
|
|
147
187
|
|
|
188
|
+
// HTML's parser lowercases attribute names, so a consumer-written
|
|
189
|
+
// `<component dndDisabled>` arrives here with propName `dnddisabled` while
|
|
190
|
+
// the component template author wrote `dndDisabled`. Match identifiers and
|
|
191
|
+
// bindings case-insensitively so both sides line up. Word-boundary
|
|
192
|
+
// lookbehind/lookahead still hold (they're case-agnostic), so
|
|
193
|
+
// `dnddisabled` won't bleed into `dndDisabledAlt`.
|
|
148
194
|
Object.entries(props).forEach(([propName, propValue]) => {
|
|
149
195
|
const bindingMatch = propValue.match(/^@\[(.+)\]$/);
|
|
150
|
-
const exactPattern = new RegExp(`@\\[${escapeRegex(propName)}\\]`, '
|
|
196
|
+
const exactPattern = new RegExp(`@\\[${escapeRegex(propName)}\\]`, 'gi');
|
|
151
197
|
const idRegex = new RegExp(
|
|
152
198
|
`(?<![a-zA-Z0-9_$\\.])${escapeRegex(propName)}(?![a-zA-Z0-9_$])`,
|
|
153
|
-
'
|
|
199
|
+
'gi'
|
|
154
200
|
);
|
|
155
201
|
|
|
156
202
|
const substituteInExpr = (expr, replacement) => expr.replace(idRegex, replacement);
|
|
@@ -170,7 +216,7 @@ const renderPropsAndSlot = (temp, props, slotHtml) => {
|
|
|
170
216
|
return rewritten === expr ? match : `<!-- ${kw} ${rewritten} -->`;
|
|
171
217
|
}
|
|
172
218
|
);
|
|
173
|
-
const stateRegex = new RegExp(`\\$\\.${escapeRegex(propName)}(?![a-zA-Z0-9_$])`, '
|
|
219
|
+
const stateRegex = new RegExp(`\\$\\.${escapeRegex(propName)}(?![a-zA-Z0-9_$])`, 'gi');
|
|
174
220
|
transformedHtml = transformedHtml.replace(EVENT_ATTR_REGEX, (match, evName, body) => {
|
|
175
221
|
const rewritten = body.replace(stateRegex, `$.${path}`);
|
|
176
222
|
return rewritten === body ? match : `on${evName}="${rewritten}"`;
|
|
@@ -196,7 +242,7 @@ const renderPropsAndSlot = (temp, props, slotHtml) => {
|
|
|
196
242
|
return rewritten === expr ? match : `<!-- ${kw} ${rewritten} -->`;
|
|
197
243
|
}
|
|
198
244
|
);
|
|
199
|
-
const stateRegex = new RegExp(`\\$\\.${escapeRegex(propName)}(?![a-zA-Z0-9_$])`, '
|
|
245
|
+
const stateRegex = new RegExp(`\\$\\.${escapeRegex(propName)}(?![a-zA-Z0-9_$])`, 'gi');
|
|
200
246
|
transformedHtml = transformedHtml.replace(EVENT_ATTR_REGEX, (match, evName, body) => {
|
|
201
247
|
const rewritten = body.replace(stateRegex, `$.${literal}`);
|
|
202
248
|
return rewritten === body ? match : `on${evName}="${rewritten}"`;
|
|
@@ -281,6 +327,21 @@ const processSingle = (el, debug) => {
|
|
|
281
327
|
// Process each script — collect async tasks if any have imports
|
|
282
328
|
const asyncTasks = [];
|
|
283
329
|
|
|
330
|
+
// Track every componentId registered during this fetch. If the host
|
|
331
|
+
// element is detached before finalize runs (e.g. a conditional unmounted
|
|
332
|
+
// mid-fetch, or the user navigated away), we release these state
|
|
333
|
+
// buckets — otherwise component({...}) leaks state to `$` for DOM that
|
|
334
|
+
// never reaches the document.
|
|
335
|
+
const registeredComponentIds = [];
|
|
336
|
+
|
|
337
|
+
// First componentId encountered — applied to the wrapper itself so
|
|
338
|
+
// directives living between top-level sibling roots (e.g. a comment
|
|
339
|
+
// marker for `<!-- if this.X -->`) can resolve component scope via
|
|
340
|
+
// closest('[data-vibe-component-id]'). Without this, multi-root
|
|
341
|
+
// templates have scope-less wrappers and top-level `this.` references
|
|
342
|
+
// fall through to global state.
|
|
343
|
+
let firstComponentId = null;
|
|
344
|
+
|
|
284
345
|
for (const script of moduleScripts) {
|
|
285
346
|
let scriptContent = script.textContent?.trim() || '';
|
|
286
347
|
if (!scriptContent) continue;
|
|
@@ -297,7 +358,11 @@ const processSingle = (el, debug) => {
|
|
|
297
358
|
|
|
298
359
|
if (hasImports) {
|
|
299
360
|
// Rewrite remaining imports to dynamic await import()
|
|
300
|
-
// Order matters: default → named → namespace → side-effect (most specific first)
|
|
361
|
+
// Order matters: combined → default → named → namespace → side-effect (most specific first)
|
|
362
|
+
scriptContent = scriptContent.replace(
|
|
363
|
+
/import\s+(\w+)\s*,\s*\{([^}]+)\}\s+from\s+(['"][^'"]+['"])\s*;?/g,
|
|
364
|
+
'const __m_$1 = await import($3); const $1 = __m_$1.default; const {$2} = __m_$1;'
|
|
365
|
+
);
|
|
301
366
|
scriptContent = scriptContent.replace(
|
|
302
367
|
/import\s+(\w+)\s+from\s+(['"][^'"]+['"])\s*;?/g,
|
|
303
368
|
'const $1 = (await import($2)).default;'
|
|
@@ -320,6 +385,8 @@ const processSingle = (el, debug) => {
|
|
|
320
385
|
const reuseIds = el._vibeReuseComponentIds;
|
|
321
386
|
const componentId = reuseIds && reuseIds.length ? reuseIds.shift() : generateComponentId();
|
|
322
387
|
|
|
388
|
+
if (firstComponentId === null) firstComponentId = componentId;
|
|
389
|
+
|
|
323
390
|
// Provide a component() function that registers state for this component.
|
|
324
391
|
// Sibling tagging is handled below (before script.remove()) so it works
|
|
325
392
|
// for both sync and async scripts.
|
|
@@ -327,18 +394,40 @@ const processSingle = (el, debug) => {
|
|
|
327
394
|
if (!window.__vibeComponents) window.__vibeComponents = {};
|
|
328
395
|
window.__vibeComponents[componentId] = state;
|
|
329
396
|
if (window.$) window.$[componentId] = state;
|
|
397
|
+
if (!registeredComponentIds.includes(componentId)) {
|
|
398
|
+
registeredComponentIds.push(componentId);
|
|
399
|
+
}
|
|
400
|
+
// Return the id so consumers can reach their reactive state via
|
|
401
|
+
// `$[id]` — matches the public component.js contract. Without this,
|
|
402
|
+
// `const id = component(state)` is undefined for src-fetched
|
|
403
|
+
// components and `$[id]` silently resolves to nothing.
|
|
404
|
+
return componentId;
|
|
330
405
|
};
|
|
331
406
|
|
|
407
|
+
// Re-running the script for a reused componentId (HMR remount) must
|
|
408
|
+
// tear down listeners from the previous evaluation before the fresh
|
|
409
|
+
// script registers new ones. Without this, every cycle stacks another
|
|
410
|
+
// listener on top of the stale closures and a single reactive tick
|
|
411
|
+
// fires N callbacks instead of one.
|
|
412
|
+
runComponentCleanups(componentId);
|
|
413
|
+
|
|
414
|
+
// Provide a per-script `$` whose `.on(...)` registers cleanups under
|
|
415
|
+
// this componentId. Bare `$` in the script body resolves to this
|
|
416
|
+
// Proxy (the function parameter shadows the global), so listener
|
|
417
|
+
// registrations are auto-tracked across `await` boundaries via the
|
|
418
|
+
// closure — no opt-in required.
|
|
419
|
+
const scopedDollar = createScopedDollar(componentId);
|
|
420
|
+
|
|
332
421
|
// Execute script with component() function in scope
|
|
333
422
|
try {
|
|
334
423
|
if (hasImports) {
|
|
335
424
|
// Async execution for scripts with imports
|
|
336
425
|
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
|
|
337
|
-
asyncTasks.push(new AsyncFunction('component', scriptContent)(componentFn));
|
|
426
|
+
asyncTasks.push(new AsyncFunction('$', 'component', scriptContent)(scopedDollar, componentFn));
|
|
338
427
|
} else {
|
|
339
428
|
// Synchronous execution for scripts without imports (preserves boot timing)
|
|
340
|
-
const executeFn = new Function('component', scriptContent);
|
|
341
|
-
executeFn(componentFn);
|
|
429
|
+
const executeFn = new Function('$', 'component', scriptContent);
|
|
430
|
+
executeFn(scopedDollar, componentFn);
|
|
342
431
|
}
|
|
343
432
|
} catch (e) {
|
|
344
433
|
console.warn('[vibe] Failed to execute component script:', e);
|
|
@@ -374,6 +463,9 @@ const processSingle = (el, debug) => {
|
|
|
374
463
|
}
|
|
375
464
|
|
|
376
465
|
newWrapper.innerHTML = transformedHtml;
|
|
466
|
+
if (firstComponentId !== null) {
|
|
467
|
+
newWrapper.setAttribute('data-vibe-component-id', firstComponentId);
|
|
468
|
+
}
|
|
377
469
|
// Stash the raw source so the HMR plugin can establish a baseline
|
|
378
470
|
// script hash on the very first update — without this, the first
|
|
379
471
|
// save after page load would always fall back to re-mount (since
|
|
@@ -392,12 +484,37 @@ const processSingle = (el, debug) => {
|
|
|
392
484
|
el._vibeIterPropIds = null;
|
|
393
485
|
el.removeAttribute('data-vibe-iter-prop');
|
|
394
486
|
}
|
|
487
|
+
// Transfer the original prop expressions too, so the iteration's
|
|
488
|
+
// update path can re-evaluate them against the row's new scope and
|
|
489
|
+
// refresh the registry slots in place — letting the inlined
|
|
490
|
+
// component's bindings react without rebuilding the row's DOM.
|
|
491
|
+
if (el._vibeIterPropExprs) {
|
|
492
|
+
newWrapper._vibeIterPropExprs = el._vibeIterPropExprs;
|
|
493
|
+
el._vibeIterPropExprs = null;
|
|
494
|
+
}
|
|
495
|
+
// Back-pointer from the soon-to-be-detached `<component src>` to
|
|
496
|
+
// the new wrapper. The iteration's `instance.clonedNodes` still
|
|
497
|
+
// references the original element; follow this link to reach the
|
|
498
|
+
// live wrapper when refreshing registry slots / re-hydrating.
|
|
499
|
+
// The wrapper's `_vibeIterTree` (set later by processMutations after
|
|
500
|
+
// renderAllConditionals/Iterations populated runtime data) is what
|
|
501
|
+
// iterate.js's update path uses to re-evaluate inlined bindings on
|
|
502
|
+
// each row update.
|
|
503
|
+
el._vibeReplacedBy = newWrapper;
|
|
395
504
|
el.replaceWith(newWrapper);
|
|
396
505
|
debugLog(PHASE_FETCH, src, debug);
|
|
397
506
|
|
|
398
507
|
// MutationObserver handles parsing and hydrating the new content.
|
|
399
508
|
// Branch nodes are registered in the manifest by mountBranch,
|
|
400
509
|
// so the observer can find parents even inside conditional branches.
|
|
510
|
+
} else {
|
|
511
|
+
// Element was detached before finalize ran (conditional unmounted
|
|
512
|
+
// during fetch, parent removed, etc). Release any state component()
|
|
513
|
+
// calls registered — otherwise it leaks on `$` forever.
|
|
514
|
+
for (const id of registeredComponentIds) {
|
|
515
|
+
delete window.__vibeComponents?.[id];
|
|
516
|
+
if (window.$) delete window.$[id];
|
|
517
|
+
}
|
|
401
518
|
}
|
|
402
519
|
};
|
|
403
520
|
|
package/runtime/conditionals.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import parse from './parse.js';
|
|
2
2
|
import affected from './affected.js';
|
|
3
3
|
import hydrate from './hydrate.js';
|
|
4
|
-
import { createScopedState, renderAllIterations, initializeBlock } from './iterate.js';
|
|
4
|
+
import { createScopedState, renderAllIterations, initializeBlock, resolveIterationComponentProps } from './iterate.js';
|
|
5
5
|
import { evalInScope } from './utils.js';
|
|
6
6
|
import { collectComponentIds, releaseOrphanedComponentState } from './component.js';
|
|
7
7
|
|
|
@@ -98,6 +98,16 @@ export const renderAllConditionals = (tree, state, manifest, parentScope = {}) =
|
|
|
98
98
|
export const renderConditional = (node, state, manifest, parentScope = {}) => {
|
|
99
99
|
const { expression, startComment, endComment, branches } = node.meta;
|
|
100
100
|
|
|
101
|
+
// Mark conditionals that live inside an iteration row so update-time branch
|
|
102
|
+
// flips can route `<component src>` props through the iteration-prop
|
|
103
|
+
// registry. Skipping this for top-level conditionals keeps their props as
|
|
104
|
+
// live `@[stateKey]` bindings — which is what global state-change reactivity
|
|
105
|
+
// depends on (the registry path snapshots a value and doesn't react to
|
|
106
|
+
// global state changes on its own).
|
|
107
|
+
if (Object.keys(parentScope).length > 0) {
|
|
108
|
+
node.runtime.inIteration = true;
|
|
109
|
+
}
|
|
110
|
+
|
|
101
111
|
// Check if already rendered (using marker on comment node)
|
|
102
112
|
// @ts-ignore - adding custom property to comment node
|
|
103
113
|
if (startComment.__vibeRendered) {
|
|
@@ -158,12 +168,29 @@ const mountBranch = (node, branchData, state, manifest, parentScope) => {
|
|
|
158
168
|
const scopedState =
|
|
159
169
|
Object.keys(parentScope).length > 0 ? createScopedState(state, parentScope) : state;
|
|
160
170
|
|
|
161
|
-
// Initialize block (clone, parse, hydrate)
|
|
171
|
+
// Initialize block (clone, parse, hydrate). Pass the enclosing loop aliases so
|
|
172
|
+
// the re-parse rewrites loop-scoped handlers in this branch — including ones
|
|
173
|
+
// nested deeper in further conditionals, which the parser reaches by carrying
|
|
174
|
+
// the alias set down. `scopeAliases` is set at parse time and persists, so this
|
|
175
|
+
// works on both the initial-render and update (hydrate) mount paths.
|
|
176
|
+
const aliasSet = node.meta.scopeAliases?.length ? new Set(node.meta.scopeAliases) : undefined;
|
|
162
177
|
const {
|
|
163
178
|
element: firstElement,
|
|
164
179
|
tree: branchTree,
|
|
165
180
|
clonedNodes,
|
|
166
|
-
} = initializeBlock(templateContent, scopedState);
|
|
181
|
+
} = initializeBlock(templateContent, scopedState, null, null, aliasSet);
|
|
182
|
+
|
|
183
|
+
// For conditionals living inside an iteration, route any `<component src>`
|
|
184
|
+
// props through the iteration-prop registry against the active scopedState
|
|
185
|
+
// (which carries the iteration's local vars). Without this, processSingle
|
|
186
|
+
// would inline `@[item.x]` bindings into the component template, where
|
|
187
|
+
// `item` isn't reachable in global scope and props resolve to undefined.
|
|
188
|
+
// Skipped for top-level conditionals because their props reference live
|
|
189
|
+
// global-state bindings — going through the registry would snapshot the
|
|
190
|
+
// value and break reactivity.
|
|
191
|
+
if (node.runtime.inIteration) {
|
|
192
|
+
resolveIterationComponentProps(clonedNodes, scopedState);
|
|
193
|
+
}
|
|
167
194
|
|
|
168
195
|
// Insert cloned nodes into DOM and register in branch registry
|
|
169
196
|
clonedNodes.forEach((clonedNode, i) => {
|
|
@@ -172,6 +199,21 @@ const mountBranch = (node, branchData, state, manifest, parentScope) => {
|
|
|
172
199
|
if (clonedNode.nodeType === 1) managedNodes.add(clonedNode);
|
|
173
200
|
});
|
|
174
201
|
|
|
202
|
+
// Branch content is mounted outside the enclosing iteration instance's
|
|
203
|
+
// clonedNodes, so it doesn't inherit the instance's `__vibeScope` stamp by DOM
|
|
204
|
+
// ancestry. When this conditional lives inside a loop, stamp `scopedState` —
|
|
205
|
+
// the same scope that hydrates the branch's `@[alias.x]` bindings — onto the
|
|
206
|
+
// branch's root elements so loop-scoped `$scope(this,'alias')` handlers resolve.
|
|
207
|
+
// Gate on `scopeAliases` (set at parse time, persists) rather than the runtime
|
|
208
|
+
// parentScope/inIteration, because the update path (hydrate -> updateConditional)
|
|
209
|
+
// and deeper-nested conditionals mount with an empty parentScope yet still flow
|
|
210
|
+
// the iteration's scoped state in as `state`.
|
|
211
|
+
if (node.meta.scopeAliases?.length) {
|
|
212
|
+
for (let i = 0; i < clonedNodes.length; i++) {
|
|
213
|
+
if (clonedNodes[i].nodeType === 1) clonedNodes[i].__vibeScope = scopedState;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
175
217
|
// Integrate branch tree into the conditional node's children and manifest.
|
|
176
218
|
// This makes branch content visible to the main update loop (hydrate,
|
|
177
219
|
// renderAllConditionals, renderAllIterations) and to MutationObserver
|
package/runtime/constants.js
CHANGED
|
@@ -210,11 +210,18 @@ export const BINDING_REGEX = new RegExp(String.raw`\@\[(${BINDING_INNER})\]`, 'g
|
|
|
210
210
|
// Regex for detecting a pure binding (entire value is just @[expression])
|
|
211
211
|
export const PURE_BINDING_REGEX = new RegExp(String.raw`^\@\[(${BINDING_INNER})\]$`);
|
|
212
212
|
|
|
213
|
-
// Regex for parsing iteration comment syntax
|
|
214
|
-
//
|
|
215
|
-
//
|
|
216
|
-
//
|
|
217
|
-
|
|
213
|
+
// Regex for parsing iteration comment syntax. Supported forms:
|
|
214
|
+
// <!-- each items as item -->
|
|
215
|
+
// <!-- each items as item, index -->
|
|
216
|
+
// <!-- each items as item (item.id) --> // explicit key
|
|
217
|
+
// <!-- each items as item (item.id), index --> // key + index
|
|
218
|
+
// Capture groups: arrayPath, itemAlias, keyExpr (optional), indexAlias (optional).
|
|
219
|
+
// The array expression can be any JS: a state path, a window global, a method
|
|
220
|
+
// call, or an inline literal. The optional key expression is evaluated per
|
|
221
|
+
// item against scoped state to produce a stable identity for diffing — this
|
|
222
|
+
// keeps survivors stable when earlier items are removed (otherwise the
|
|
223
|
+
// fallback hash key embeds the index and triggers bulk re-render).
|
|
224
|
+
export const ITERATION_REGEX = /^each\s+(.+)\s+as\s+(\w+)(?:\s*\(\s*([^)]+?)\s*\))?(?:\s*,\s*(\w+))?\s*$/;
|
|
218
225
|
|
|
219
226
|
// Regex for detecting start of iteration comment
|
|
220
227
|
export const ITERATION_START_REGEX = /^each\s+/;
|