@ape-egg/vibe 1.8.0 → 1.9.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/CHANGELOG.md +107 -0
- package/README.md +72 -2
- package/boot.js +5 -1
- package/compiler/native/vibe-compiler-darwin-arm64 +0 -0
- package/compiler/native/vibe-compiler-linux-x64 +0 -0
- package/compiler/src/parser/html.rs +15 -15
- package/index.js +15 -0
- package/llms.txt +151 -84
- package/package.json +3 -2
- package/runtime/_vibe-compiled-iteration-batch.js +18 -9
- package/runtime/affected.js +36 -16
- package/runtime/component.js +237 -86
- package/runtime/conditionals.js +12 -1
- package/runtime/constants.js +17 -3
- package/runtime/hydrate.js +27 -10
- package/runtime/index.js +91 -34
- package/runtime/iterate.js +323 -108
- package/runtime/manifest.js +2 -1
- package/runtime/parse.js +67 -80
- package/runtime/pre-compiled-manifest.js +38 -13
- package/runtime/reconcile.js +621 -0
- package/runtime/state.js +5 -2
- package/runtime/utils.js +77 -2
- package/vibe.css +3 -1
package/runtime/component.js
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { debugLog } from './debug.js';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
PHASE_FETCH,
|
|
4
|
+
DEHYDRATE_CLASS_OR_ATTR,
|
|
5
|
+
BINDING_REGEX,
|
|
6
|
+
THIS_PROP_REGEX,
|
|
7
|
+
STATE_THIS_PROP_REGEX,
|
|
8
|
+
} from './constants.js';
|
|
3
9
|
import { evalInScope } from './utils.js';
|
|
4
10
|
|
|
5
11
|
// Deterministic component counter
|
|
@@ -16,6 +22,42 @@ export const generateComponentId = () => {
|
|
|
16
22
|
// Helper to escape regex special characters
|
|
17
23
|
const escapeRegex = (str) => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
18
24
|
|
|
25
|
+
// Kept as no-ops for API compatibility — path-based tracking was replaced by
|
|
26
|
+
// DOM-scan cleanup in releaseOrphanedComponentState. Manifest paths don't
|
|
27
|
+
// align 1:1 with component ownership across conditional remounts, so scanning
|
|
28
|
+
// the DOM subtree about to be removed is simpler and waterproof.
|
|
29
|
+
export const trackComponentOwnership = () => {};
|
|
30
|
+
|
|
31
|
+
// Given an iterable of DOM nodes that are about to be (or have just been)
|
|
32
|
+
// removed, find every `data-vibe-component-id` inside them and, for each id
|
|
33
|
+
// whose DOM is fully gone from the live document, evict its state.
|
|
34
|
+
//
|
|
35
|
+
// Call this AFTER the nodes have been detached from the document so the
|
|
36
|
+
// `document.querySelector` check sees the post-removal state.
|
|
37
|
+
export const releaseOrphanedComponentState = (collectedIds) => {
|
|
38
|
+
if (!collectedIds || collectedIds.size === 0) return;
|
|
39
|
+
for (const id of collectedIds) {
|
|
40
|
+
if (document.querySelector(`[data-vibe-component-id="${id}"]`)) continue;
|
|
41
|
+
delete window.__vibeComponents?.[id];
|
|
42
|
+
// CLEANUP OF CURRENT STATE
|
|
43
|
+
delete window.$[id];
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// Walk a node subtree (element or node list) and collect all
|
|
48
|
+
// `data-vibe-component-id` values found on the node and its descendants.
|
|
49
|
+
export const collectComponentIds = (node, into = new Set()) => {
|
|
50
|
+
if (!node) return into;
|
|
51
|
+
if (node.nodeType === 1) {
|
|
52
|
+
const id = node.getAttribute('data-vibe-component-id');
|
|
53
|
+
if (id) into.add(id);
|
|
54
|
+
node.querySelectorAll?.('[data-vibe-component-id]').forEach((el) => {
|
|
55
|
+
into.add(el.getAttribute('data-vibe-component-id'));
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
return into;
|
|
59
|
+
};
|
|
60
|
+
|
|
19
61
|
// Track pending fetches to cancel them if element is removed
|
|
20
62
|
const pendingFetches = new WeakMap(); // element → AbortController
|
|
21
63
|
|
|
@@ -43,6 +85,162 @@ const isNestedInUnprocessedComponent = (el, rootElement) => {
|
|
|
43
85
|
return false;
|
|
44
86
|
};
|
|
45
87
|
|
|
88
|
+
// Rewrite `@[this.x...]` and `$.this.x...` inside element's text/attrs to use
|
|
89
|
+
// componentId. Shared between script-execution path and pure-render path.
|
|
90
|
+
//
|
|
91
|
+
// Within `@[...]` bindings we rewrite every `this.X` reference (preserving
|
|
92
|
+
// any trailing `.Y.Z` chain), so `@[this.user.name]` and `@[this.x + 1]` both
|
|
93
|
+
// resolve correctly. Outside bindings — i.e. event handler attribute bodies
|
|
94
|
+
// like `onclick="$.this.mode = 'edit'"` — only `$.this.X` writes are
|
|
95
|
+
// rewritten; bare `this.X` reads in event handlers are handled later by
|
|
96
|
+
// parse.js (which preserves DOM properties like `this.value`).
|
|
97
|
+
const rewriteBindingsInString = (str, componentId) =>
|
|
98
|
+
str.replace(BINDING_REGEX, (match, expr) => {
|
|
99
|
+
const rewritten = expr.replace(THIS_PROP_REGEX, `${componentId}.$1`);
|
|
100
|
+
return rewritten === expr ? match : `@[${rewritten}]`;
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const rewriteThisBindings = (element, componentId) => {
|
|
104
|
+
Array.from(element.childNodes).forEach((node) => {
|
|
105
|
+
if (node.nodeType === Node.TEXT_NODE && node.textContent.includes('@[this.')) {
|
|
106
|
+
node.textContent = rewriteBindingsInString(node.textContent, componentId);
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
Array.from(element.attributes || []).forEach((attr) => {
|
|
111
|
+
if (attr.value.includes('@[this.')) {
|
|
112
|
+
attr.value = rewriteBindingsInString(attr.value, componentId);
|
|
113
|
+
}
|
|
114
|
+
if (attr.value.includes('$.this.')) {
|
|
115
|
+
attr.value = attr.value.replace(STATE_THIS_PROP_REGEX, `$.${componentId}.$1`);
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
Array.from(element.children).forEach((child) => {
|
|
120
|
+
rewriteThisBindings(child, componentId);
|
|
121
|
+
});
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
// Tag siblings of a <script> with componentId and rewrite `this.` bindings.
|
|
125
|
+
// Runs BEFORE script.remove() so nextElementSibling is valid. Shared by
|
|
126
|
+
// processSingle (executing path) and renderComponentTemplate (pure path).
|
|
127
|
+
const tagScriptSiblings = (script, componentId) => {
|
|
128
|
+
let sibling = script.nextElementSibling;
|
|
129
|
+
while (sibling) {
|
|
130
|
+
if (sibling.tagName === 'SCRIPT' && sibling.getAttribute('type') === 'module') {
|
|
131
|
+
break;
|
|
132
|
+
}
|
|
133
|
+
sibling.setAttribute('data-vibe-component-id', componentId);
|
|
134
|
+
rewriteThisBindings(sibling, componentId);
|
|
135
|
+
sibling = sibling.nextElementSibling;
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
// Substitute props + inline slot content into a pre-processed temp container.
|
|
140
|
+
// Returns the final processed HTML string. Shared by processSingle.finalize()
|
|
141
|
+
// (runtime mount) and renderComponentTemplate (surgical HMR).
|
|
142
|
+
const renderPropsAndSlot = (temp, props, slotHtml) => {
|
|
143
|
+
let transformedHtml = temp.innerHTML;
|
|
144
|
+
|
|
145
|
+
const DIRECTIVE_COMMENT_REGEX = /<!--\s*(if|else if|each)\s+([^]*?)\s*-->/g;
|
|
146
|
+
const EVENT_ATTR_REGEX = /\bon(\w+)="([^"]*)"/g;
|
|
147
|
+
|
|
148
|
+
Object.entries(props).forEach(([propName, propValue]) => {
|
|
149
|
+
const bindingMatch = propValue.match(/^@\[(.+)\]$/);
|
|
150
|
+
const exactPattern = new RegExp(`@\\[${escapeRegex(propName)}\\]`, 'g');
|
|
151
|
+
const idRegex = new RegExp(
|
|
152
|
+
`(?<![a-zA-Z0-9_$\\.])${escapeRegex(propName)}(?![a-zA-Z0-9_$])`,
|
|
153
|
+
'g'
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
const substituteInExpr = (expr, replacement) => expr.replace(idRegex, replacement);
|
|
157
|
+
|
|
158
|
+
if (bindingMatch) {
|
|
159
|
+
const path = bindingMatch[1];
|
|
160
|
+
transformedHtml = transformedHtml.replace(exactPattern, `@[${path}]`);
|
|
161
|
+
transformedHtml = transformedHtml.replace(BINDING_REGEX, (match, expr) => {
|
|
162
|
+
const rewritten = substituteInExpr(expr, `(${path})`);
|
|
163
|
+
return rewritten === expr ? match : `@[${rewritten}]`;
|
|
164
|
+
});
|
|
165
|
+
transformedHtml = transformedHtml.replace(
|
|
166
|
+
DIRECTIVE_COMMENT_REGEX,
|
|
167
|
+
(match, kw, expr) => {
|
|
168
|
+
const replacement = kw === 'each' ? path : `(${path})`;
|
|
169
|
+
const rewritten = substituteInExpr(expr, replacement);
|
|
170
|
+
return rewritten === expr ? match : `<!-- ${kw} ${rewritten} -->`;
|
|
171
|
+
}
|
|
172
|
+
);
|
|
173
|
+
const stateRegex = new RegExp(`\\$\\.${escapeRegex(propName)}(?![a-zA-Z0-9_$])`, 'g');
|
|
174
|
+
transformedHtml = transformedHtml.replace(EVENT_ATTR_REGEX, (match, evName, body) => {
|
|
175
|
+
const rewritten = body.replace(stateRegex, `$.${path}`);
|
|
176
|
+
return rewritten === body ? match : `on${evName}="${rewritten}"`;
|
|
177
|
+
});
|
|
178
|
+
} else {
|
|
179
|
+
transformedHtml = transformedHtml.replace(exactPattern, propValue);
|
|
180
|
+
const isNumeric =
|
|
181
|
+
typeof propValue === 'string' && /^-?\d+(\.\d+)?(e[+-]?\d+)?$/i.test(propValue);
|
|
182
|
+
const isBooleanAttr = propValue === '';
|
|
183
|
+
const literal = isBooleanAttr
|
|
184
|
+
? 'true'
|
|
185
|
+
: typeof propValue === 'string' && !isNumeric
|
|
186
|
+
? JSON.stringify(propValue)
|
|
187
|
+
: String(propValue);
|
|
188
|
+
transformedHtml = transformedHtml.replace(BINDING_REGEX, (match, expr) => {
|
|
189
|
+
const rewritten = substituteInExpr(expr, literal);
|
|
190
|
+
return rewritten === expr ? match : `@[${rewritten}]`;
|
|
191
|
+
});
|
|
192
|
+
transformedHtml = transformedHtml.replace(
|
|
193
|
+
DIRECTIVE_COMMENT_REGEX,
|
|
194
|
+
(match, kw, expr) => {
|
|
195
|
+
const rewritten = substituteInExpr(expr, literal);
|
|
196
|
+
return rewritten === expr ? match : `<!-- ${kw} ${rewritten} -->`;
|
|
197
|
+
}
|
|
198
|
+
);
|
|
199
|
+
const stateRegex = new RegExp(`\\$\\.${escapeRegex(propName)}(?![a-zA-Z0-9_$])`, 'g');
|
|
200
|
+
transformedHtml = transformedHtml.replace(EVENT_ATTR_REGEX, (match, evName, body) => {
|
|
201
|
+
const rewritten = body.replace(stateRegex, `$.${literal}`);
|
|
202
|
+
return rewritten === body ? match : `on${evName}="${rewritten}"`;
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
const children = (slotHtml || '').trim();
|
|
208
|
+
if (children) {
|
|
209
|
+
transformedHtml = transformedHtml.replace(
|
|
210
|
+
/<slot(\s[^>]*)?>\s*<\/slot>/g,
|
|
211
|
+
(_, attrs) => `<slot${attrs || ''}>${children}</slot>`
|
|
212
|
+
);
|
|
213
|
+
transformedHtml = transformedHtml.replace(
|
|
214
|
+
/<slot(\s[^>]*)?\/>/g,
|
|
215
|
+
(_, attrs) => `<slot${attrs || ''}>${children}</slot>`
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return transformedHtml;
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
// Pure-render path for surgical HMR. Takes a component's raw template HTML
|
|
223
|
+
// plus the live instance's props, slot, and existing componentIds. Returns
|
|
224
|
+
// the processed HTML string that $.reconcile can diff against the live
|
|
225
|
+
// wrapper's children. Scripts are NOT executed — callers are responsible
|
|
226
|
+
// for deciding whether to preserve the existing state (reuse componentIds)
|
|
227
|
+
// or trigger a full re-mount (new componentIds).
|
|
228
|
+
export const renderComponentTemplate = (rawHtml, props = {}, slotHtml = '', options = {}) => {
|
|
229
|
+
const { componentIds = [] } = options;
|
|
230
|
+
const temp = document.createElement('div');
|
|
231
|
+
temp.innerHTML = rawHtml;
|
|
232
|
+
|
|
233
|
+
const idsToReuse = [...componentIds];
|
|
234
|
+
const moduleScripts = temp.querySelectorAll('script[type="module"]');
|
|
235
|
+
for (const script of moduleScripts) {
|
|
236
|
+
const componentId = idsToReuse.shift() || generateComponentId();
|
|
237
|
+
tagScriptSiblings(script, componentId);
|
|
238
|
+
script.remove();
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return renderPropsAndSlot(temp, props, slotHtml);
|
|
242
|
+
};
|
|
243
|
+
|
|
46
244
|
// Process a single component element: fetch HTML, execute scripts, replace DOM
|
|
47
245
|
const processSingle = (el, debug) => {
|
|
48
246
|
// Skip dehydrated components
|
|
@@ -89,7 +287,10 @@ const processSingle = (el, debug) => {
|
|
|
89
287
|
|
|
90
288
|
// Strip `import component from '...'` — Vibe injects the contextual
|
|
91
289
|
// component() function as a parameter (it needs access to the temp DOM)
|
|
92
|
-
scriptContent = scriptContent.replace(
|
|
290
|
+
scriptContent = scriptContent.replace(
|
|
291
|
+
/import\s+component\s+from\s+['"][^'"]+['"];?\s*/g,
|
|
292
|
+
''
|
|
293
|
+
);
|
|
93
294
|
|
|
94
295
|
// Check for remaining imports that need rewriting
|
|
95
296
|
const hasImports = /import\s/.test(scriptContent);
|
|
@@ -115,40 +316,24 @@ const processSingle = (el, debug) => {
|
|
|
115
316
|
);
|
|
116
317
|
}
|
|
117
318
|
|
|
118
|
-
//
|
|
119
|
-
const
|
|
319
|
+
// Reuse component ID from HMR if available, otherwise generate new
|
|
320
|
+
const reuseIds = el._vibeReuseComponentIds;
|
|
321
|
+
const componentId = reuseIds && reuseIds.length ? reuseIds.shift() : generateComponentId();
|
|
120
322
|
|
|
121
|
-
// Provide a component() function that registers state for this component
|
|
323
|
+
// Provide a component() function that registers state for this component.
|
|
324
|
+
// Sibling tagging is handled below (before script.remove()) so it works
|
|
325
|
+
// for both sync and async scripts.
|
|
122
326
|
const componentFn = (state) => {
|
|
123
|
-
|
|
124
|
-
// 1. __vibeComponents registry (for pre-boot components)
|
|
125
|
-
if (!window.__vibeComponents) {
|
|
126
|
-
window.__vibeComponents = {};
|
|
127
|
-
}
|
|
327
|
+
if (!window.__vibeComponents) window.__vibeComponents = {};
|
|
128
328
|
window.__vibeComponents[componentId] = state;
|
|
129
|
-
|
|
130
|
-
// 2. Directly in window.$ (the reactive proxy) for post-boot components
|
|
131
|
-
if (window.$) {
|
|
132
|
-
window.$[componentId] = state;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
// Tag all siblings (everything after the script in this component)
|
|
136
|
-
let sibling = script.nextElementSibling;
|
|
137
|
-
while (sibling) {
|
|
138
|
-
// Stop if we hit another module script
|
|
139
|
-
if (sibling.tagName === 'SCRIPT' && sibling.getAttribute('type') === 'module') {
|
|
140
|
-
break;
|
|
141
|
-
}
|
|
142
|
-
sibling.setAttribute('data-vibe-component-id', componentId);
|
|
143
|
-
sibling = sibling.nextElementSibling;
|
|
144
|
-
}
|
|
329
|
+
if (window.$) window.$[componentId] = state;
|
|
145
330
|
};
|
|
146
331
|
|
|
147
332
|
// Execute script with component() function in scope
|
|
148
333
|
try {
|
|
149
334
|
if (hasImports) {
|
|
150
335
|
// Async execution for scripts with imports
|
|
151
|
-
const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor;
|
|
336
|
+
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
|
|
152
337
|
asyncTasks.push(new AsyncFunction('component', scriptContent)(componentFn));
|
|
153
338
|
} else {
|
|
154
339
|
// Synchronous execution for scripts without imports (preserves boot timing)
|
|
@@ -159,39 +344,9 @@ const processSingle = (el, debug) => {
|
|
|
159
344
|
console.warn('[vibe] Failed to execute component script:', e);
|
|
160
345
|
}
|
|
161
346
|
|
|
162
|
-
//
|
|
163
|
-
//
|
|
164
|
-
|
|
165
|
-
const thisRegex = /@\[this\.(\w+)\]/g;
|
|
166
|
-
|
|
167
|
-
// Rewrite in text nodes
|
|
168
|
-
Array.from(element.childNodes).forEach((node) => {
|
|
169
|
-
if (node.nodeType === Node.TEXT_NODE && node.textContent.includes('@[this.')) {
|
|
170
|
-
node.textContent = node.textContent.replace(thisRegex, `@[${componentId}.$1]`);
|
|
171
|
-
}
|
|
172
|
-
});
|
|
173
|
-
|
|
174
|
-
// Rewrite in attributes
|
|
175
|
-
Array.from(element.attributes || []).forEach((attr) => {
|
|
176
|
-
if (attr.value.includes('@[this.')) {
|
|
177
|
-
attr.value = attr.value.replace(thisRegex, `@[${componentId}.$1]`);
|
|
178
|
-
}
|
|
179
|
-
});
|
|
180
|
-
|
|
181
|
-
// Recurse into children
|
|
182
|
-
Array.from(element.children).forEach((child) => {
|
|
183
|
-
rewriteThisBindings(child);
|
|
184
|
-
});
|
|
185
|
-
};
|
|
186
|
-
|
|
187
|
-
let sibling = script.nextElementSibling;
|
|
188
|
-
while (sibling) {
|
|
189
|
-
if (sibling.tagName === 'SCRIPT' && sibling.getAttribute('type') === 'module') {
|
|
190
|
-
break;
|
|
191
|
-
}
|
|
192
|
-
rewriteThisBindings(sibling);
|
|
193
|
-
sibling = sibling.nextElementSibling;
|
|
194
|
-
}
|
|
347
|
+
// Tag siblings + rewrite this. bindings using shared helper. Runs
|
|
348
|
+
// BEFORE script.remove() so nextElementSibling is valid.
|
|
349
|
+
tagScriptSiblings(script, componentId);
|
|
195
350
|
|
|
196
351
|
// Remove script from temp (we executed it manually)
|
|
197
352
|
script.remove();
|
|
@@ -199,30 +354,8 @@ const processSingle = (el, debug) => {
|
|
|
199
354
|
|
|
200
355
|
// Finalize: props, slots, DOM replacement
|
|
201
356
|
const finalize = () => {
|
|
202
|
-
//
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
// Replace props
|
|
206
|
-
Object.entries(props).forEach(([propName, propValue]) => {
|
|
207
|
-
const bindingMatch = propValue.match(/^@\[(.+)\]$/);
|
|
208
|
-
|
|
209
|
-
if (bindingMatch) {
|
|
210
|
-
// Reactive prop: replace @[propName] with @[path]
|
|
211
|
-
const path = bindingMatch[1];
|
|
212
|
-
const propPattern = new RegExp(`@\\[${escapeRegex(propName)}\\]`, 'g');
|
|
213
|
-
transformedHtml = transformedHtml.replace(propPattern, `@[${path}]`);
|
|
214
|
-
} else {
|
|
215
|
-
// Static prop: replace @[propName] with literal value
|
|
216
|
-
const propPattern = new RegExp(`@\\[${escapeRegex(propName)}\\]`, 'g');
|
|
217
|
-
transformedHtml = transformedHtml.replace(propPattern, propValue);
|
|
218
|
-
}
|
|
219
|
-
});
|
|
220
|
-
|
|
221
|
-
// Replace <slot></slot> with children
|
|
222
|
-
if (children) {
|
|
223
|
-
transformedHtml = transformedHtml.replace(/<slot><\/slot>/g, children);
|
|
224
|
-
transformedHtml = transformedHtml.replace(/<slot\s*\/>/g, children);
|
|
225
|
-
}
|
|
357
|
+
// Delegate prop substitution + slot inlining to shared helper.
|
|
358
|
+
const transformedHtml = renderPropsAndSlot(temp, props, children);
|
|
226
359
|
|
|
227
360
|
// Clean up pending fetch tracker
|
|
228
361
|
pendingFetches.delete(el);
|
|
@@ -241,6 +374,24 @@ const processSingle = (el, debug) => {
|
|
|
241
374
|
}
|
|
242
375
|
|
|
243
376
|
newWrapper.innerHTML = transformedHtml;
|
|
377
|
+
// Stash the raw source so the HMR plugin can establish a baseline
|
|
378
|
+
// script hash on the very first update — without this, the first
|
|
379
|
+
// save after page load would always fall back to re-mount (since
|
|
380
|
+
// the plugin would have nothing to compare against). Vibe itself
|
|
381
|
+
// never reads this; it's purely for the plugin spy.
|
|
382
|
+
newWrapper._vibeRawSource = html;
|
|
383
|
+
// Transfer iteration-prop registry ownership from the soon-to-be-
|
|
384
|
+
// detached `<component src>` to the new wrapper. The detached element
|
|
385
|
+
// would otherwise trigger releaseOrphanedIterationProps and free the
|
|
386
|
+
// registry slots that the inlined template's bindings still reference,
|
|
387
|
+
// causing every `@[window.__vibeIterProps._pN]` to resolve to undefined
|
|
388
|
+
// on the next hydrate.
|
|
389
|
+
if (el._vibeIterPropIds) {
|
|
390
|
+
newWrapper._vibeIterPropIds = el._vibeIterPropIds;
|
|
391
|
+
newWrapper.setAttribute('data-vibe-iter-prop', '');
|
|
392
|
+
el._vibeIterPropIds = null;
|
|
393
|
+
el.removeAttribute('data-vibe-iter-prop');
|
|
394
|
+
}
|
|
244
395
|
el.replaceWith(newWrapper);
|
|
245
396
|
debugLog(PHASE_FETCH, src, debug);
|
|
246
397
|
|
|
@@ -286,7 +437,7 @@ export const processComponent = (rootElement, onComplete, config = {}) => {
|
|
|
286
437
|
// unprocessed component[src] elements (they're slot content that will
|
|
287
438
|
// be revealed when the parent component finalizes).
|
|
288
439
|
const topLevel = Array.from(allComponents).filter(
|
|
289
|
-
el => !isNestedInUnprocessedComponent(el, rootElement)
|
|
440
|
+
(el) => !isNestedInUnprocessedComponent(el, rootElement)
|
|
290
441
|
);
|
|
291
442
|
|
|
292
443
|
if (topLevel.length === 0) {
|
|
@@ -299,5 +450,5 @@ export const processComponent = (rootElement, onComplete, config = {}) => {
|
|
|
299
450
|
// processed by the MutationObserver → processComponent chain.
|
|
300
451
|
// onComplete is handled by checkCleanup (which fires when no
|
|
301
452
|
// component[src] elements remain).
|
|
302
|
-
topLevel.forEach(el => processSingle(el, debug));
|
|
453
|
+
topLevel.forEach((el) => processSingle(el, debug));
|
|
303
454
|
};
|
package/runtime/conditionals.js
CHANGED
|
@@ -3,6 +3,7 @@ import affected from './affected.js';
|
|
|
3
3
|
import hydrate from './hydrate.js';
|
|
4
4
|
import { createScopedState, renderAllIterations, initializeBlock } from './iterate.js';
|
|
5
5
|
import { evalInScope } from './utils.js';
|
|
6
|
+
import { collectComponentIds, releaseOrphanedComponentState } from './component.js';
|
|
6
7
|
|
|
7
8
|
// Registry of DOM nodes owned by conditional branches.
|
|
8
9
|
// Maps a DOM node to { nodes: array_ref, index: number } so that
|
|
@@ -30,7 +31,9 @@ const findConditionalPath = (node, manifest) => {
|
|
|
30
31
|
|
|
31
32
|
// Register branch tree nodes in the manifest (recursive)
|
|
32
33
|
const addBranchToManifest = (tree, manifest, basePath) => {
|
|
33
|
-
if (tree.element)
|
|
34
|
+
if (tree.element) {
|
|
35
|
+
manifest[basePath] = tree.element;
|
|
36
|
+
}
|
|
34
37
|
if (tree.children) {
|
|
35
38
|
for (const key in tree.children) {
|
|
36
39
|
addBranchToManifest(tree.children[key], manifest, `${basePath}.${key}`);
|
|
@@ -218,6 +221,11 @@ const unmountBranch = (node, manifest) => {
|
|
|
218
221
|
}
|
|
219
222
|
}
|
|
220
223
|
|
|
224
|
+
// Collect componentIds from the subtree BEFORE detaching so we can check
|
|
225
|
+
// after removal whether any live DOM still holds them.
|
|
226
|
+
const ids = new Set();
|
|
227
|
+
activeInstance.nodes.forEach((domNode) => collectComponentIds(domNode, ids));
|
|
228
|
+
|
|
221
229
|
// Remove all nodes from DOM and deregister from branch registry
|
|
222
230
|
activeInstance.nodes.forEach((domNode) => {
|
|
223
231
|
branchNodeRegistry.delete(domNode);
|
|
@@ -226,6 +234,9 @@ const unmountBranch = (node, manifest) => {
|
|
|
226
234
|
}
|
|
227
235
|
});
|
|
228
236
|
|
|
237
|
+
// CLEANUP OF CURRENT STATE
|
|
238
|
+
releaseOrphanedComponentState(ids);
|
|
239
|
+
|
|
229
240
|
// Clear active instance
|
|
230
241
|
node.runtime.activeInstance = null;
|
|
231
242
|
};
|
package/runtime/constants.js
CHANGED
|
@@ -210,9 +210,11 @@ 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 (<!-- each
|
|
214
|
-
//
|
|
215
|
-
|
|
213
|
+
// Regex for parsing iteration comment syntax (<!-- each expression as item, index -->)
|
|
214
|
+
// The array expression can be any JS: a state path (items), a window global
|
|
215
|
+
// (window.fights), a method call (items.filter(x => x.active)), or an inline
|
|
216
|
+
// array literal (['a', 'b']). Parsed via evalInScope at render time.
|
|
217
|
+
export const ITERATION_REGEX = /^each\s+(.+)\s+as\s+(\w+)(?:\s*,\s*(\w+))?\s*$/;
|
|
216
218
|
|
|
217
219
|
// Regex for detecting start of iteration comment
|
|
218
220
|
export const ITERATION_START_REGEX = /^each\s+/;
|
|
@@ -222,3 +224,15 @@ export const CONDITIONAL_REGEX = /^if\s+(.+)$/;
|
|
|
222
224
|
|
|
223
225
|
// Regex for detecting start of conditional comment
|
|
224
226
|
export const CONDITIONAL_START_REGEX = /^if\s+/;
|
|
227
|
+
|
|
228
|
+
// Regex for rewriting component-local `this.X` references to the component's
|
|
229
|
+
// state path. Captures the leading identifier only — any trailing `.Y.Z`
|
|
230
|
+
// chain stays attached after replacement, so `this.user.name` becomes
|
|
231
|
+
// `<componentId>.user.name`. Used in expression bodies (bindings, event
|
|
232
|
+
// handlers, conditional/iteration directives).
|
|
233
|
+
export const THIS_PROP_REGEX = /\bthis\.(\w+)/g;
|
|
234
|
+
|
|
235
|
+
// Regex for rewriting `$.this.X` writes (proxy assignment from event handlers)
|
|
236
|
+
// to the component's write path. Same prefix-only semantics as THIS_PROP_REGEX
|
|
237
|
+
// — `$.this.user.name = x` becomes `$.<componentId>.user.name = x`.
|
|
238
|
+
export const STATE_THIS_PROP_REGEX = /\$\.this\.(\w+)/g;
|
package/runtime/hydrate.js
CHANGED
|
@@ -87,17 +87,24 @@ export default (affected, state, manifest = {}, oldState = {}) => {
|
|
|
87
87
|
// Attribute: Truthful DOM representation, enables compiler snapshots via outerHTML
|
|
88
88
|
const expr = isPureBinding[1];
|
|
89
89
|
const value = evalInScope(expr, effectiveState, element);
|
|
90
|
-
element[attrName] = value;
|
|
90
|
+
if (element[attrName] !== value) element[attrName] = value;
|
|
91
91
|
if (value !== undefined && value !== null) {
|
|
92
|
-
|
|
92
|
+
const str = String(value);
|
|
93
|
+
if (element.getAttribute(attrName) !== str) element.setAttribute(attrName, str);
|
|
93
94
|
}
|
|
94
95
|
} else if (!isValueAttr && isPureBinding) {
|
|
95
|
-
// Boolean-like attributes: add or remove based on truthiness
|
|
96
|
+
// Boolean-like attributes: add or remove based on truthiness.
|
|
97
|
+
// Compare both presence AND value — initial hydration starts with
|
|
98
|
+
// the raw `@[...]` binding text as the attribute value, so
|
|
99
|
+
// `hasAttribute` alone isn't enough to know the canonical state is
|
|
100
|
+
// already set.
|
|
96
101
|
const expr = isPureBinding[1];
|
|
97
102
|
const value = evalInScope(expr, effectiveState, element);
|
|
98
103
|
if (value) {
|
|
99
|
-
element.
|
|
100
|
-
|
|
104
|
+
if (element.getAttribute(attrName) !== '') {
|
|
105
|
+
element.setAttribute(attrName, '');
|
|
106
|
+
}
|
|
107
|
+
} else if (element.hasAttribute(attrName)) {
|
|
101
108
|
element.removeAttribute(attrName);
|
|
102
109
|
}
|
|
103
110
|
} else {
|
|
@@ -105,7 +112,9 @@ export default (affected, state, manifest = {}, oldState = {}) => {
|
|
|
105
112
|
const newValue = attrValue.replace(BINDING_REGEX, (_, expr) => {
|
|
106
113
|
return evalInScope(expr, effectiveState, element);
|
|
107
114
|
});
|
|
108
|
-
element.
|
|
115
|
+
if (element.getAttribute(attrName) !== newValue) {
|
|
116
|
+
element.setAttribute(attrName, newValue);
|
|
117
|
+
}
|
|
109
118
|
}
|
|
110
119
|
} catch (e) {}
|
|
111
120
|
return;
|
|
@@ -128,11 +137,19 @@ export default (affected, state, manifest = {}, oldState = {}) => {
|
|
|
128
137
|
});
|
|
129
138
|
|
|
130
139
|
// If we have a direct reference to the text node, update it specifically
|
|
131
|
-
// This prevents wiping child elements when parent has both text and element children
|
|
140
|
+
// This prevents wiping child elements when parent has both text and element children.
|
|
141
|
+
// Skip the write when the value is already correct — the browser would repaint
|
|
142
|
+
// (and any in-flight CSS transition on the row would jitter) even when no value
|
|
143
|
+
// actually changed. Reactivity coverage is unchanged: the only state changes that
|
|
144
|
+
// hit this path either produce a new value (still applied) or don't (now no-op).
|
|
145
|
+
// Skip the write when the value is already correct — the browser would
|
|
146
|
+
// repaint (and any in-flight CSS transition would jitter) even when no
|
|
147
|
+
// value actually changed. Reactivity coverage is unchanged: state changes
|
|
148
|
+
// that produce a new value still apply; state changes that don't are now
|
|
149
|
+
// proper no-ops at the DOM layer.
|
|
132
150
|
if (textNode && textNode.nodeType === 3) {
|
|
133
|
-
textNode.textContent = toReplace;
|
|
134
|
-
} else {
|
|
135
|
-
// Fallback: element has no children or is just a text container
|
|
151
|
+
if (textNode.textContent !== toReplace) textNode.textContent = toReplace;
|
|
152
|
+
} else if (element.textContent !== toReplace) {
|
|
136
153
|
element.textContent = toReplace;
|
|
137
154
|
}
|
|
138
155
|
} catch (e) {}
|