@ape-egg/vibe 1.8.0 → 1.9.0
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 +88 -0
- package/README.md +28 -0
- 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/package.json +2 -2
- package/runtime/_vibe-compiled-iteration-batch.js +18 -9
- package/runtime/affected.js +36 -16
- package/runtime/component.js +154 -45
- package/runtime/conditionals.js +12 -1
- package/runtime/constants.js +5 -3
- package/runtime/index.js +77 -33
- package/runtime/iterate.js +270 -108
- package/runtime/manifest.js +2 -1
- package/runtime/parse.js +66 -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 +74 -1
- package/vibe.css +3 -1
package/runtime/component.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { debugLog } from './debug.js';
|
|
2
|
-
import { PHASE_FETCH, DEHYDRATE_CLASS_OR_ATTR } from './constants.js';
|
|
2
|
+
import { PHASE_FETCH, DEHYDRATE_CLASS_OR_ATTR, BINDING_REGEX } from './constants.js';
|
|
3
3
|
import { evalInScope } from './utils.js';
|
|
4
4
|
|
|
5
5
|
// Deterministic component counter
|
|
@@ -16,6 +16,42 @@ export const generateComponentId = () => {
|
|
|
16
16
|
// Helper to escape regex special characters
|
|
17
17
|
const escapeRegex = (str) => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
18
18
|
|
|
19
|
+
// Kept as no-ops for API compatibility — path-based tracking was replaced by
|
|
20
|
+
// DOM-scan cleanup in releaseOrphanedComponentState. Manifest paths don't
|
|
21
|
+
// align 1:1 with component ownership across conditional remounts, so scanning
|
|
22
|
+
// the DOM subtree about to be removed is simpler and waterproof.
|
|
23
|
+
export const trackComponentOwnership = () => {};
|
|
24
|
+
|
|
25
|
+
// Given an iterable of DOM nodes that are about to be (or have just been)
|
|
26
|
+
// removed, find every `data-vibe-component-id` inside them and, for each id
|
|
27
|
+
// whose DOM is fully gone from the live document, evict its state.
|
|
28
|
+
//
|
|
29
|
+
// Call this AFTER the nodes have been detached from the document so the
|
|
30
|
+
// `document.querySelector` check sees the post-removal state.
|
|
31
|
+
export const releaseOrphanedComponentState = (collectedIds) => {
|
|
32
|
+
if (!collectedIds || collectedIds.size === 0) return;
|
|
33
|
+
for (const id of collectedIds) {
|
|
34
|
+
if (document.querySelector(`[data-vibe-component-id="${id}"]`)) continue;
|
|
35
|
+
delete window.__vibeComponents?.[id];
|
|
36
|
+
// CLEANUP OF CURRENT STATE
|
|
37
|
+
delete window.$[id];
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// Walk a node subtree (element or node list) and collect all
|
|
42
|
+
// `data-vibe-component-id` values found on the node and its descendants.
|
|
43
|
+
export const collectComponentIds = (node, into = new Set()) => {
|
|
44
|
+
if (!node) return into;
|
|
45
|
+
if (node.nodeType === 1) {
|
|
46
|
+
const id = node.getAttribute('data-vibe-component-id');
|
|
47
|
+
if (id) into.add(id);
|
|
48
|
+
node.querySelectorAll?.('[data-vibe-component-id]').forEach((el) => {
|
|
49
|
+
into.add(el.getAttribute('data-vibe-component-id'));
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
return into;
|
|
53
|
+
};
|
|
54
|
+
|
|
19
55
|
// Track pending fetches to cancel them if element is removed
|
|
20
56
|
const pendingFetches = new WeakMap(); // element → AbortController
|
|
21
57
|
|
|
@@ -89,7 +125,10 @@ const processSingle = (el, debug) => {
|
|
|
89
125
|
|
|
90
126
|
// Strip `import component from '...'` — Vibe injects the contextual
|
|
91
127
|
// component() function as a parameter (it needs access to the temp DOM)
|
|
92
|
-
scriptContent = scriptContent.replace(
|
|
128
|
+
scriptContent = scriptContent.replace(
|
|
129
|
+
/import\s+component\s+from\s+['"][^'"]+['"];?\s*/g,
|
|
130
|
+
''
|
|
131
|
+
);
|
|
93
132
|
|
|
94
133
|
// Check for remaining imports that need rewriting
|
|
95
134
|
const hasImports = /import\s/.test(scriptContent);
|
|
@@ -115,40 +154,24 @@ const processSingle = (el, debug) => {
|
|
|
115
154
|
);
|
|
116
155
|
}
|
|
117
156
|
|
|
118
|
-
//
|
|
119
|
-
const
|
|
157
|
+
// Reuse component ID from HMR if available, otherwise generate new
|
|
158
|
+
const reuseIds = el._vibeReuseComponentIds;
|
|
159
|
+
const componentId = reuseIds && reuseIds.length ? reuseIds.shift() : generateComponentId();
|
|
120
160
|
|
|
121
|
-
// Provide a component() function that registers state for this component
|
|
161
|
+
// Provide a component() function that registers state for this component.
|
|
162
|
+
// Sibling tagging is handled below (before script.remove()) so it works
|
|
163
|
+
// for both sync and async scripts.
|
|
122
164
|
const componentFn = (state) => {
|
|
123
|
-
|
|
124
|
-
// 1. __vibeComponents registry (for pre-boot components)
|
|
125
|
-
if (!window.__vibeComponents) {
|
|
126
|
-
window.__vibeComponents = {};
|
|
127
|
-
}
|
|
165
|
+
if (!window.__vibeComponents) window.__vibeComponents = {};
|
|
128
166
|
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
|
-
}
|
|
167
|
+
if (window.$) window.$[componentId] = state;
|
|
145
168
|
};
|
|
146
169
|
|
|
147
170
|
// Execute script with component() function in scope
|
|
148
171
|
try {
|
|
149
172
|
if (hasImports) {
|
|
150
173
|
// Async execution for scripts with imports
|
|
151
|
-
const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor;
|
|
174
|
+
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
|
|
152
175
|
asyncTasks.push(new AsyncFunction('component', scriptContent)(componentFn));
|
|
153
176
|
} else {
|
|
154
177
|
// Synchronous execution for scripts without imports (preserves boot timing)
|
|
@@ -159,22 +182,27 @@ const processSingle = (el, debug) => {
|
|
|
159
182
|
console.warn('[vibe] Failed to execute component script:', e);
|
|
160
183
|
}
|
|
161
184
|
|
|
162
|
-
// Rewrite this.property to componentId.property in siblings
|
|
163
|
-
//
|
|
185
|
+
// Rewrite this.property to componentId.property in siblings.
|
|
186
|
+
// Handles both reads (@[this.x]) and writes ($.this.x in event handlers)
|
|
187
|
+
// so component() state is fully accessible from the component's own template.
|
|
164
188
|
const rewriteThisBindings = (element) => {
|
|
165
|
-
const
|
|
189
|
+
const bindingRegex = /@\[this\.(\w+)\]/g;
|
|
190
|
+
const writeRegex = /\$\.this\.(\w+)/g;
|
|
166
191
|
|
|
167
192
|
// Rewrite in text nodes
|
|
168
193
|
Array.from(element.childNodes).forEach((node) => {
|
|
169
194
|
if (node.nodeType === Node.TEXT_NODE && node.textContent.includes('@[this.')) {
|
|
170
|
-
node.textContent = node.textContent.replace(
|
|
195
|
+
node.textContent = node.textContent.replace(bindingRegex, `@[${componentId}.$1]`);
|
|
171
196
|
}
|
|
172
197
|
});
|
|
173
198
|
|
|
174
|
-
// Rewrite in attributes
|
|
199
|
+
// Rewrite in attributes — both @[this.x] bindings and $.this.x in event handlers
|
|
175
200
|
Array.from(element.attributes || []).forEach((attr) => {
|
|
176
201
|
if (attr.value.includes('@[this.')) {
|
|
177
|
-
attr.value = attr.value.replace(
|
|
202
|
+
attr.value = attr.value.replace(bindingRegex, `@[${componentId}.$1]`);
|
|
203
|
+
}
|
|
204
|
+
if (attr.value.includes('$.this.')) {
|
|
205
|
+
attr.value = attr.value.replace(writeRegex, `$.${componentId}.$1`);
|
|
178
206
|
}
|
|
179
207
|
});
|
|
180
208
|
|
|
@@ -184,11 +212,16 @@ const processSingle = (el, debug) => {
|
|
|
184
212
|
});
|
|
185
213
|
};
|
|
186
214
|
|
|
215
|
+
// Tag siblings with componentId and rewrite this. bindings.
|
|
216
|
+
// This runs BEFORE script.remove() so nextElementSibling is valid.
|
|
217
|
+
// For async scripts, componentFn runs later (after removal) and can't
|
|
218
|
+
// find siblings — so we tag here instead.
|
|
187
219
|
let sibling = script.nextElementSibling;
|
|
188
220
|
while (sibling) {
|
|
189
221
|
if (sibling.tagName === 'SCRIPT' && sibling.getAttribute('type') === 'module') {
|
|
190
222
|
break;
|
|
191
223
|
}
|
|
224
|
+
sibling.setAttribute('data-vibe-component-id', componentId);
|
|
192
225
|
rewriteThisBindings(sibling);
|
|
193
226
|
sibling = sibling.nextElementSibling;
|
|
194
227
|
}
|
|
@@ -202,26 +235,102 @@ const processSingle = (el, debug) => {
|
|
|
202
235
|
// Get transformed HTML from temp container (scripts removed)
|
|
203
236
|
let transformedHtml = temp.innerHTML;
|
|
204
237
|
|
|
205
|
-
// Replace props
|
|
238
|
+
// Replace props — both exact @[propName] bindings and identifiers
|
|
239
|
+
// appearing inside larger expressions like @[Math.floor(propName / 100)]
|
|
240
|
+
// or conditional/iteration comment expressions like <!-- if propName -->.
|
|
241
|
+
// Matches any <!-- if ... -->, <!-- else if ... -->, or <!-- each ... -->
|
|
242
|
+
// so prop identifiers resolve there the same way they do inside @[...].
|
|
243
|
+
const DIRECTIVE_COMMENT_REGEX = /<!--\s*(if|else if|each)\s+([^]*?)\s*-->/g;
|
|
244
|
+
// Event handler attributes (onclick, oninput, onchange, …). Prop
|
|
245
|
+
// identifiers inside these are JS expressions that read/write state —
|
|
246
|
+
// substituting them gives components natural two-way binding:
|
|
247
|
+
// child writes `$.value = x`, parent passed `value="@[email]"`,
|
|
248
|
+
// substitution turns it into `$.email = x`.
|
|
249
|
+
const EVENT_ATTR_REGEX = /\bon(\w+)="([^"]*)"/g;
|
|
250
|
+
|
|
206
251
|
Object.entries(props).forEach(([propName, propValue]) => {
|
|
207
252
|
const bindingMatch = propValue.match(/^@\[(.+)\]$/);
|
|
253
|
+
const exactPattern = new RegExp(`@\\[${escapeRegex(propName)}\\]`, 'g');
|
|
254
|
+
// Word-boundary identifier match for substitution inside expressions.
|
|
255
|
+
// The lookbehind also excludes `.` so property accesses like `_c0.email`
|
|
256
|
+
// aren't double-substituted when the prop name is `email` — only
|
|
257
|
+
// standalone identifiers match, not property tails.
|
|
258
|
+
const idRegex = new RegExp(
|
|
259
|
+
`(?<![a-zA-Z0-9_$\\.])${escapeRegex(propName)}(?![a-zA-Z0-9_$])`,
|
|
260
|
+
'g'
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
const substituteInExpr = (expr, replacement) => expr.replace(idRegex, replacement);
|
|
208
264
|
|
|
209
265
|
if (bindingMatch) {
|
|
210
|
-
// Reactive prop:
|
|
266
|
+
// Reactive prop: @[propName] → @[path], identifiers inside expressions → (path)
|
|
211
267
|
const path = bindingMatch[1];
|
|
212
|
-
|
|
213
|
-
transformedHtml = transformedHtml.replace(
|
|
268
|
+
transformedHtml = transformedHtml.replace(exactPattern, `@[${path}]`);
|
|
269
|
+
transformedHtml = transformedHtml.replace(BINDING_REGEX, (match, expr) => {
|
|
270
|
+
const rewritten = substituteInExpr(expr, `(${path})`);
|
|
271
|
+
return rewritten === expr ? match : `@[${rewritten}]`;
|
|
272
|
+
});
|
|
273
|
+
transformedHtml = transformedHtml.replace(
|
|
274
|
+
DIRECTIVE_COMMENT_REGEX,
|
|
275
|
+
(match, kw, expr) => {
|
|
276
|
+
const replacement = kw === 'each' ? path : `(${path})`;
|
|
277
|
+
const rewritten = substituteInExpr(expr, replacement);
|
|
278
|
+
return rewritten === expr ? match : `<!-- ${kw} ${rewritten} -->`;
|
|
279
|
+
}
|
|
280
|
+
);
|
|
281
|
+
// Event handlers: rewrite `$.propName` → `$.path` for two-way
|
|
282
|
+
// binding. Only targets state access (`$.xxx`) so DOM properties
|
|
283
|
+
// like `this.value` stay untouched.
|
|
284
|
+
const stateRegex = new RegExp(`\\$\\.${escapeRegex(propName)}(?![a-zA-Z0-9_$])`, 'g');
|
|
285
|
+
transformedHtml = transformedHtml.replace(EVENT_ATTR_REGEX, (match, evName, body) => {
|
|
286
|
+
const rewritten = body.replace(stateRegex, `$.${path}`);
|
|
287
|
+
return rewritten === body ? match : `on${evName}="${rewritten}"`;
|
|
288
|
+
});
|
|
214
289
|
} else {
|
|
215
|
-
// Static prop:
|
|
216
|
-
|
|
217
|
-
transformedHtml = transformedHtml.replace(
|
|
290
|
+
// Static prop: @[propName] → literal value (raw),
|
|
291
|
+
// identifiers inside expressions → JS literal.
|
|
292
|
+
transformedHtml = transformedHtml.replace(exactPattern, propValue);
|
|
293
|
+
const isNumeric =
|
|
294
|
+
typeof propValue === 'string' && /^-?\d+(\.\d+)?(e[+-]?\d+)?$/i.test(propValue);
|
|
295
|
+
const isBooleanAttr = propValue === '';
|
|
296
|
+
const literal = isBooleanAttr
|
|
297
|
+
? 'true'
|
|
298
|
+
: typeof propValue === 'string' && !isNumeric
|
|
299
|
+
? JSON.stringify(propValue)
|
|
300
|
+
: String(propValue);
|
|
301
|
+
transformedHtml = transformedHtml.replace(BINDING_REGEX, (match, expr) => {
|
|
302
|
+
const rewritten = substituteInExpr(expr, literal);
|
|
303
|
+
return rewritten === expr ? match : `@[${rewritten}]`;
|
|
304
|
+
});
|
|
305
|
+
transformedHtml = transformedHtml.replace(
|
|
306
|
+
DIRECTIVE_COMMENT_REGEX,
|
|
307
|
+
(match, kw, expr) => {
|
|
308
|
+
const rewritten = substituteInExpr(expr, literal);
|
|
309
|
+
return rewritten === expr ? match : `<!-- ${kw} ${rewritten} -->`;
|
|
310
|
+
}
|
|
311
|
+
);
|
|
312
|
+
// Event handlers: rewrite `$.propName` → `$.literal` for static props
|
|
313
|
+
const stateRegex = new RegExp(`\\$\\.${escapeRegex(propName)}(?![a-zA-Z0-9_$])`, 'g');
|
|
314
|
+
transformedHtml = transformedHtml.replace(EVENT_ATTR_REGEX, (match, evName, body) => {
|
|
315
|
+
const rewritten = body.replace(stateRegex, `$.${literal}`);
|
|
316
|
+
return rewritten === body ? match : `on${evName}="${rewritten}"`;
|
|
317
|
+
});
|
|
218
318
|
}
|
|
219
319
|
});
|
|
220
320
|
|
|
221
|
-
// Replace <slot
|
|
321
|
+
// Replace <slot> with children wrapped in <slot> boundary.
|
|
322
|
+
// Allow attributes on the slot (e.g. data-vibe-component-id added by
|
|
323
|
+
// sibling tagging when a <slot> happens to be a direct sibling of the
|
|
324
|
+
// component's <script>).
|
|
222
325
|
if (children) {
|
|
223
|
-
transformedHtml = transformedHtml.replace(
|
|
224
|
-
|
|
326
|
+
transformedHtml = transformedHtml.replace(
|
|
327
|
+
/<slot(\s[^>]*)?>\s*<\/slot>/g,
|
|
328
|
+
(_, attrs) => `<slot${attrs || ''}>${children}</slot>`
|
|
329
|
+
);
|
|
330
|
+
transformedHtml = transformedHtml.replace(
|
|
331
|
+
/<slot(\s[^>]*)?\/>/g,
|
|
332
|
+
(_, attrs) => `<slot${attrs || ''}>${children}</slot>`
|
|
333
|
+
);
|
|
225
334
|
}
|
|
226
335
|
|
|
227
336
|
// Clean up pending fetch tracker
|
|
@@ -286,7 +395,7 @@ export const processComponent = (rootElement, onComplete, config = {}) => {
|
|
|
286
395
|
// unprocessed component[src] elements (they're slot content that will
|
|
287
396
|
// be revealed when the parent component finalizes).
|
|
288
397
|
const topLevel = Array.from(allComponents).filter(
|
|
289
|
-
el => !isNestedInUnprocessedComponent(el, rootElement)
|
|
398
|
+
(el) => !isNestedInUnprocessedComponent(el, rootElement)
|
|
290
399
|
);
|
|
291
400
|
|
|
292
401
|
if (topLevel.length === 0) {
|
|
@@ -299,5 +408,5 @@ export const processComponent = (rootElement, onComplete, config = {}) => {
|
|
|
299
408
|
// processed by the MutationObserver → processComponent chain.
|
|
300
409
|
// onComplete is handled by checkCleanup (which fires when no
|
|
301
410
|
// component[src] elements remain).
|
|
302
|
-
topLevel.forEach(el => processSingle(el, debug));
|
|
411
|
+
topLevel.forEach((el) => processSingle(el, debug));
|
|
303
412
|
};
|
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+/;
|
package/runtime/index.js
CHANGED
|
@@ -21,9 +21,10 @@ import {
|
|
|
21
21
|
PHASE_READY,
|
|
22
22
|
DEHYDRATE_CLASS_OR_ATTR,
|
|
23
23
|
} from './constants.js';
|
|
24
|
-
import { processComponent, abortComponentFetch } from './component.js';
|
|
24
|
+
import { processComponent, abortComponentFetch, collectComponentIds, releaseOrphanedComponentState } from './component.js';
|
|
25
25
|
import { debugLog } from './debug.js';
|
|
26
26
|
import { shouldCleanup, cleanup } from './cleanup.js';
|
|
27
|
+
import { reconcile } from './reconcile.js';
|
|
27
28
|
import {
|
|
28
29
|
buildHyperspeedManifest,
|
|
29
30
|
hyperspeedManifest,
|
|
@@ -617,6 +618,22 @@ const main = (s, config = {}, stringSelector = '') => {
|
|
|
617
618
|
enumerable: false,
|
|
618
619
|
});
|
|
619
620
|
|
|
621
|
+
// Promise that resolves after the ready hook fires and all ready callbacks
|
|
622
|
+
// have run. Lets late subscribers await readiness without missing the event:
|
|
623
|
+
// `await $.ready`. Non-enumerable so it won't leak into state snapshots.
|
|
624
|
+
let resolveReady;
|
|
625
|
+
Object.defineProperty($, 'ready', {
|
|
626
|
+
value: new Promise((resolve) => { resolveReady = resolve; }),
|
|
627
|
+
enumerable: false,
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
// Reconcile a managed subtree against new source HTML. Opt-in entry point;
|
|
631
|
+
// dormant unless called (so hot paths and benchmarks are unaffected).
|
|
632
|
+
Object.defineProperty($, 'reconcile', {
|
|
633
|
+
value: reconcile,
|
|
634
|
+
enumerable: false,
|
|
635
|
+
});
|
|
636
|
+
|
|
620
637
|
// Initial hydration - pass plain values so iteration can do reference comparison
|
|
621
638
|
const initialState = extractPlainValue($);
|
|
622
639
|
const affectedElements = affected(parsedTree, initialState, initialState);
|
|
@@ -696,6 +713,14 @@ const main = (s, config = {}, stringSelector = '') => {
|
|
|
696
713
|
let evaluatedCount = 0;
|
|
697
714
|
let addedElementsList = []; // Track all added elements for verbose output
|
|
698
715
|
|
|
716
|
+
// Collect data-vibe-component-id values across ALL removed subtrees in this
|
|
717
|
+
// batch before doing any per-node work, so we can evict their state after
|
|
718
|
+
// the DOM mutations have been applied.
|
|
719
|
+
const removedComponentIds = new Set();
|
|
720
|
+
mutations.forEach(({ removedNodes: removedNodesList }) => {
|
|
721
|
+
removedNodesList.forEach((node) => collectComponentIds(node, removedComponentIds));
|
|
722
|
+
});
|
|
723
|
+
|
|
699
724
|
mutations.forEach(({ addedNodes: addedNodesList, removedNodes: removedNodesList, target }) => {
|
|
700
725
|
removedNodesList.forEach((node) => {
|
|
701
726
|
// If this is a component element with pending fetch, abort it
|
|
@@ -761,29 +786,20 @@ const main = (s, config = {}, stringSelector = '') => {
|
|
|
761
786
|
return;
|
|
762
787
|
}
|
|
763
788
|
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
//
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
if (!picked) return;
|
|
774
|
-
|
|
775
|
-
// If parent has no element reference, re-parse from the actual DOM element
|
|
776
|
-
if (!picked.element) {
|
|
777
|
-
picked.element = target;
|
|
789
|
+
// Capture raw slot content of nested <component src> elements BEFORE parse runs.
|
|
790
|
+
// Parse creates conditional nodes from <!-- if --> comments, and renderConditional
|
|
791
|
+
// later removes the template nodes between the comments. Without capturing slot
|
|
792
|
+
// content first, conditionals inside a component's slot content lose their branch
|
|
793
|
+
// templates, breaking reactive updates.
|
|
794
|
+
if (node.nodeType === 1) {
|
|
795
|
+
node.querySelectorAll('component[src], div.component[src]').forEach((el) => {
|
|
796
|
+
if (el._vibeSlotContent === undefined) el._vibeSlotContent = el.innerHTML;
|
|
797
|
+
});
|
|
778
798
|
}
|
|
779
799
|
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
picked._nextChildIndex = Object.keys(picked.children).length;
|
|
784
|
-
}
|
|
785
|
-
const name = `${node.nodeName.toLowerCase()}_${picked._nextChildIndex}`;
|
|
786
|
-
picked._nextChildIndex++; // Always increment, never decrement
|
|
800
|
+
const entry = Object.entries(manifest).find(([_, element]) => element === target);
|
|
801
|
+
|
|
802
|
+
// Parse the newly added node
|
|
787
803
|
const parsedNode = parse(node);
|
|
788
804
|
|
|
789
805
|
// Accumulate skipped stats
|
|
@@ -791,19 +807,42 @@ const main = (s, config = {}, stringSelector = '') => {
|
|
|
791
807
|
totalSkipped += parsedNode.stats.skipped;
|
|
792
808
|
}
|
|
793
809
|
|
|
794
|
-
//
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
810
|
+
// If parent is tracked in the manifest, register this new node in the parsed tree.
|
|
811
|
+
// (If not — e.g. mutations inside an iteration instance whose rows aren't in the
|
|
812
|
+
// global manifest — we still hydrate the node below; we just skip tree/manifest
|
|
813
|
+
// registration since there's no tree branch to attach to.)
|
|
814
|
+
if (entry) {
|
|
815
|
+
const [dotAnnotation] = entry;
|
|
816
|
+
const picked = navigateTree(parsedTree, dotAnnotation);
|
|
817
|
+
|
|
818
|
+
if (picked) {
|
|
819
|
+
if (!picked.element) {
|
|
820
|
+
picked.element = target;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
// Parse the newly added node (use monotonically increasing counter for deterministic key)
|
|
824
|
+
// Initialize counter if it doesn't exist
|
|
825
|
+
if (!picked._nextChildIndex) {
|
|
826
|
+
picked._nextChildIndex = Object.keys(picked.children).length;
|
|
827
|
+
}
|
|
828
|
+
const name = `${node.nodeName.toLowerCase()}_${picked._nextChildIndex}`;
|
|
829
|
+
picked._nextChildIndex++; // Always increment, never decrement
|
|
830
|
+
|
|
831
|
+
// Update parent's parsed HTML (only once per parent)
|
|
832
|
+
if (!parsedParents) parsedParents = new Set();
|
|
833
|
+
if (!parsedParents.has(picked)) {
|
|
834
|
+
const { parsed } = parse(picked.element);
|
|
835
|
+
picked.parsed = parsed;
|
|
836
|
+
parsedParents.add(picked);
|
|
837
|
+
}
|
|
801
838
|
|
|
802
|
-
|
|
803
|
-
|
|
839
|
+
// Add the parsed node to parent's children
|
|
840
|
+
picked.children[name] = parsedNode;
|
|
804
841
|
|
|
805
|
-
|
|
806
|
-
|
|
842
|
+
// Recursively add node and all descendants to manifest
|
|
843
|
+
addToManifest(parsedNode, manifest, `${dotAnnotation}.${name}`);
|
|
844
|
+
}
|
|
845
|
+
}
|
|
807
846
|
|
|
808
847
|
// Run core loop for the new node (parse already done, hydrate → conditionals → iterate)
|
|
809
848
|
const counts = processCoreLoop(node, parsedNode, $, manifest, true, debug);
|
|
@@ -823,6 +862,9 @@ const main = (s, config = {}, stringSelector = '') => {
|
|
|
823
862
|
});
|
|
824
863
|
});
|
|
825
864
|
|
|
865
|
+
// CLEANUP OF CURRENT STATE
|
|
866
|
+
releaseOrphanedComponentState(removedComponentIds);
|
|
867
|
+
|
|
826
868
|
// Fire hooks once after all mutations are processed (not per-node)
|
|
827
869
|
if (hadChanges) {
|
|
828
870
|
if (addedElements > 0 || addedNodes > 0 || removedElements > 0 || removedNodes > 0) {
|
|
@@ -1019,6 +1061,8 @@ const main = (s, config = {}, stringSelector = '') => {
|
|
|
1019
1061
|
console.error('[vibe] Error in ready hook:', error);
|
|
1020
1062
|
}
|
|
1021
1063
|
});
|
|
1064
|
+
// Resolve $.ready promise after all ready callbacks have run
|
|
1065
|
+
resolveReady();
|
|
1022
1066
|
}
|
|
1023
1067
|
};
|
|
1024
1068
|
|