@ape-egg/vibe 1.7.2 → 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 +139 -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/Cargo.lock +85 -496
- package/compiler/src/Cargo.toml +1 -1
- package/compiler/src/compiler/compile.rs +3 -11
- 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 +54 -16
- package/runtime/component.js +287 -110
- package/runtime/conditionals.js +99 -7
- package/runtime/constants.js +10 -6
- package/runtime/index.js +142 -47
- package/runtime/iterate.js +364 -142
- package/runtime/iteration-utils.js +5 -1
- package/runtime/manifest.js +2 -1
- package/runtime/parse.js +67 -80
- package/runtime/pre-compiled-iterations.js +34 -21
- package/runtime/pre-compiled-manifest.js +38 -13
- package/runtime/reconcile.js +621 -0
- package/runtime/state.js +26 -7
- package/runtime/utils.js +97 -5
- 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
|
|
|
@@ -28,36 +64,31 @@ export const abortComponentFetch = (element) => {
|
|
|
28
64
|
}
|
|
29
65
|
};
|
|
30
66
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
return;
|
|
67
|
+
// Check if an element is nested inside another unprocessed component[src]
|
|
68
|
+
const isNestedInUnprocessedComponent = (el, rootElement) => {
|
|
69
|
+
let parent = el.parentElement;
|
|
70
|
+
while (parent && parent !== rootElement) {
|
|
71
|
+
if (
|
|
72
|
+
(parent.tagName === 'COMPONENT' || parent.classList?.contains('component')) &&
|
|
73
|
+
parent.hasAttribute('src')
|
|
74
|
+
) {
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
parent = parent.parentElement;
|
|
43
78
|
}
|
|
79
|
+
return false;
|
|
80
|
+
};
|
|
44
81
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
// Skip if component is dehydrated (vibe-dehydrate attribute or class)
|
|
82
|
+
// Process a single component element: fetch HTML, execute scripts, replace DOM
|
|
83
|
+
const processSingle = (el, debug) => {
|
|
84
|
+
// Skip dehydrated components
|
|
49
85
|
if (el.hasAttribute(DEHYDRATE_CLASS_OR_ATTR) || el.classList.contains(DEHYDRATE_CLASS_OR_ATTR)) {
|
|
50
|
-
|
|
51
|
-
if (componentElements.length > 1) {
|
|
52
|
-
// Process next component
|
|
53
|
-
processComponent(rootElement, onComplete, config);
|
|
54
|
-
} else {
|
|
55
|
-
// Defer onComplete to give user code a chance to register listeners
|
|
56
|
-
if (onComplete) queueMicrotask(() => onComplete());
|
|
57
|
-
}
|
|
58
|
-
return;
|
|
86
|
+
return Promise.resolve();
|
|
59
87
|
}
|
|
60
88
|
|
|
89
|
+
// Skip if fetch already in flight for this element
|
|
90
|
+
if (pendingFetches.has(el)) return Promise.resolve();
|
|
91
|
+
|
|
61
92
|
const src = el.getAttribute('src');
|
|
62
93
|
|
|
63
94
|
// Use pre-hydration slot content if available (saved by index.js before hydration ran),
|
|
@@ -75,7 +106,7 @@ export const processComponent = (rootElement, onComplete, config = {}) => {
|
|
|
75
106
|
const controller = new AbortController();
|
|
76
107
|
pendingFetches.set(el, controller);
|
|
77
108
|
|
|
78
|
-
fetch(src, { signal: controller.signal })
|
|
109
|
+
return fetch(src, { signal: controller.signal })
|
|
79
110
|
.then((r) => r.text())
|
|
80
111
|
.then((html) => {
|
|
81
112
|
// Parse HTML in temporary container to process component scripts
|
|
@@ -84,68 +115,94 @@ export const processComponent = (rootElement, onComplete, config = {}) => {
|
|
|
84
115
|
|
|
85
116
|
// Process any <script type="module"> elements
|
|
86
117
|
const moduleScripts = temp.querySelectorAll('script[type="module"]');
|
|
87
|
-
moduleScripts.forEach((script) => {
|
|
88
|
-
let scriptContent = script.textContent?.trim() || '';
|
|
89
|
-
if (!scriptContent) return;
|
|
90
118
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
119
|
+
// Process each script — collect async tasks if any have imports
|
|
120
|
+
const asyncTasks = [];
|
|
121
|
+
|
|
122
|
+
for (const script of moduleScripts) {
|
|
123
|
+
let scriptContent = script.textContent?.trim() || '';
|
|
124
|
+
if (!scriptContent) continue;
|
|
125
|
+
|
|
126
|
+
// Strip `import component from '...'` — Vibe injects the contextual
|
|
127
|
+
// component() function as a parameter (it needs access to the temp DOM)
|
|
128
|
+
scriptContent = scriptContent.replace(
|
|
129
|
+
/import\s+component\s+from\s+['"][^'"]+['"];?\s*/g,
|
|
130
|
+
''
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
// Check for remaining imports that need rewriting
|
|
134
|
+
const hasImports = /import\s/.test(scriptContent);
|
|
135
|
+
|
|
136
|
+
if (hasImports) {
|
|
137
|
+
// Rewrite remaining imports to dynamic await import()
|
|
138
|
+
// Order matters: default → named → namespace → side-effect (most specific first)
|
|
139
|
+
scriptContent = scriptContent.replace(
|
|
140
|
+
/import\s+(\w+)\s+from\s+(['"][^'"]+['"])\s*;?/g,
|
|
141
|
+
'const $1 = (await import($2)).default;'
|
|
142
|
+
);
|
|
143
|
+
scriptContent = scriptContent.replace(
|
|
144
|
+
/import\s+\{([^}]+)\}\s+from\s+(['"][^'"]+['"])\s*;?/g,
|
|
145
|
+
'const {$1} = await import($2);'
|
|
146
|
+
);
|
|
147
|
+
scriptContent = scriptContent.replace(
|
|
148
|
+
/import\s+\*\s+as\s+(\w+)\s+from\s+(['"][^'"]+['"])\s*;?/g,
|
|
149
|
+
'const $1 = await import($2);'
|
|
150
|
+
);
|
|
151
|
+
scriptContent = scriptContent.replace(
|
|
152
|
+
/import\s+(['"][^'"]+['"])\s*;?/g,
|
|
153
|
+
'await import($1);'
|
|
154
|
+
);
|
|
155
|
+
}
|
|
94
156
|
|
|
95
|
-
//
|
|
96
|
-
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();
|
|
97
160
|
|
|
98
|
-
// 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.
|
|
99
164
|
const componentFn = (state) => {
|
|
100
|
-
|
|
101
|
-
// 1. __vibeComponents registry (for pre-boot components)
|
|
102
|
-
if (!window.__vibeComponents) {
|
|
103
|
-
window.__vibeComponents = {};
|
|
104
|
-
}
|
|
165
|
+
if (!window.__vibeComponents) window.__vibeComponents = {};
|
|
105
166
|
window.__vibeComponents[componentId] = state;
|
|
106
|
-
|
|
107
|
-
// 2. Directly in window.$ (the reactive proxy) for post-boot components
|
|
108
|
-
if (window.$) {
|
|
109
|
-
window.$[componentId] = state;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
// Tag all siblings (everything after the script in this component)
|
|
113
|
-
let sibling = script.nextElementSibling;
|
|
114
|
-
while (sibling) {
|
|
115
|
-
// Stop if we hit another module script
|
|
116
|
-
if (sibling.tagName === 'SCRIPT' && sibling.getAttribute('type') === 'module') {
|
|
117
|
-
break;
|
|
118
|
-
}
|
|
119
|
-
sibling.setAttribute('data-vibe-component-id', componentId);
|
|
120
|
-
sibling = sibling.nextElementSibling;
|
|
121
|
-
}
|
|
167
|
+
if (window.$) window.$[componentId] = state;
|
|
122
168
|
};
|
|
123
169
|
|
|
124
170
|
// Execute script with component() function in scope
|
|
125
|
-
// Use Function constructor to provide 'component' as a parameter
|
|
126
171
|
try {
|
|
127
|
-
|
|
128
|
-
|
|
172
|
+
if (hasImports) {
|
|
173
|
+
// Async execution for scripts with imports
|
|
174
|
+
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
|
|
175
|
+
asyncTasks.push(new AsyncFunction('component', scriptContent)(componentFn));
|
|
176
|
+
} else {
|
|
177
|
+
// Synchronous execution for scripts without imports (preserves boot timing)
|
|
178
|
+
const executeFn = new Function('component', scriptContent);
|
|
179
|
+
executeFn(componentFn);
|
|
180
|
+
}
|
|
129
181
|
} catch (e) {
|
|
130
182
|
console.warn('[vibe] Failed to execute component script:', e);
|
|
131
183
|
}
|
|
132
184
|
|
|
133
|
-
// Rewrite this.property to componentId.property in siblings
|
|
134
|
-
//
|
|
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.
|
|
135
188
|
const rewriteThisBindings = (element) => {
|
|
136
|
-
const
|
|
189
|
+
const bindingRegex = /@\[this\.(\w+)\]/g;
|
|
190
|
+
const writeRegex = /\$\.this\.(\w+)/g;
|
|
137
191
|
|
|
138
192
|
// Rewrite in text nodes
|
|
139
193
|
Array.from(element.childNodes).forEach((node) => {
|
|
140
194
|
if (node.nodeType === Node.TEXT_NODE && node.textContent.includes('@[this.')) {
|
|
141
|
-
node.textContent = node.textContent.replace(
|
|
195
|
+
node.textContent = node.textContent.replace(bindingRegex, `@[${componentId}.$1]`);
|
|
142
196
|
}
|
|
143
197
|
});
|
|
144
198
|
|
|
145
|
-
// Rewrite in attributes
|
|
199
|
+
// Rewrite in attributes — both @[this.x] bindings and $.this.x in event handlers
|
|
146
200
|
Array.from(element.attributes || []).forEach((attr) => {
|
|
147
201
|
if (attr.value.includes('@[this.')) {
|
|
148
|
-
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`);
|
|
149
206
|
}
|
|
150
207
|
});
|
|
151
208
|
|
|
@@ -155,67 +212,159 @@ export const processComponent = (rootElement, onComplete, config = {}) => {
|
|
|
155
212
|
});
|
|
156
213
|
};
|
|
157
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.
|
|
158
219
|
let sibling = script.nextElementSibling;
|
|
159
220
|
while (sibling) {
|
|
160
221
|
if (sibling.tagName === 'SCRIPT' && sibling.getAttribute('type') === 'module') {
|
|
161
222
|
break;
|
|
162
223
|
}
|
|
224
|
+
sibling.setAttribute('data-vibe-component-id', componentId);
|
|
163
225
|
rewriteThisBindings(sibling);
|
|
164
226
|
sibling = sibling.nextElementSibling;
|
|
165
227
|
}
|
|
166
228
|
|
|
167
229
|
// Remove script from temp (we executed it manually)
|
|
168
230
|
script.remove();
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
//
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
if
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Finalize: props, slots, DOM replacement
|
|
234
|
+
const finalize = () => {
|
|
235
|
+
// Get transformed HTML from temp container (scripts removed)
|
|
236
|
+
let transformedHtml = temp.innerHTML;
|
|
237
|
+
|
|
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
|
+
|
|
251
|
+
Object.entries(props).forEach(([propName, propValue]) => {
|
|
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);
|
|
264
|
+
|
|
265
|
+
if (bindingMatch) {
|
|
266
|
+
// Reactive prop: @[propName] → @[path], identifiers inside expressions → (path)
|
|
267
|
+
const path = bindingMatch[1];
|
|
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
|
+
});
|
|
289
|
+
} else {
|
|
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
|
+
});
|
|
318
|
+
}
|
|
319
|
+
});
|
|
320
|
+
|
|
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>).
|
|
325
|
+
if (children) {
|
|
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
|
+
);
|
|
187
334
|
}
|
|
188
|
-
});
|
|
189
335
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
transformedHtml = transformedHtml.replace(/<slot><\/slot>/g, children);
|
|
193
|
-
transformedHtml = transformedHtml.replace(/<slot\s*\/>/g, children);
|
|
194
|
-
}
|
|
336
|
+
// Clean up pending fetch tracker
|
|
337
|
+
pendingFetches.delete(el);
|
|
195
338
|
|
|
196
|
-
|
|
197
|
-
|
|
339
|
+
// Replace with clean component wrapper (no src, no props)
|
|
340
|
+
// Check if element still has a parent (might have been removed during fetch)
|
|
341
|
+
if (el.parentNode) {
|
|
342
|
+
// Create clean wrapper element (preserve tag type: component or div.component)
|
|
343
|
+
const newWrapper =
|
|
344
|
+
el.tagName === 'DIV'
|
|
345
|
+
? document.createElement('div')
|
|
346
|
+
: document.createElement('component');
|
|
198
347
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
// Create clean wrapper element (preserve tag type: component or div.component)
|
|
203
|
-
const newWrapper =
|
|
204
|
-
el.tagName === 'DIV'
|
|
205
|
-
? document.createElement('div')
|
|
206
|
-
: document.createElement('component');
|
|
207
|
-
|
|
208
|
-
if (el.tagName === 'DIV') {
|
|
209
|
-
newWrapper.className = 'component';
|
|
210
|
-
}
|
|
348
|
+
if (el.tagName === 'DIV') {
|
|
349
|
+
newWrapper.className = 'component';
|
|
350
|
+
}
|
|
211
351
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
352
|
+
newWrapper.innerHTML = transformedHtml;
|
|
353
|
+
el.replaceWith(newWrapper);
|
|
354
|
+
debugLog(PHASE_FETCH, src, debug);
|
|
215
355
|
|
|
216
|
-
|
|
217
|
-
|
|
356
|
+
// MutationObserver handles parsing and hydrating the new content.
|
|
357
|
+
// Branch nodes are registered in the manifest by mountBranch,
|
|
358
|
+
// so the observer can find parents even inside conditional branches.
|
|
359
|
+
}
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
// If any scripts had async imports, wait for them before finalizing.
|
|
363
|
+
// Otherwise finalize synchronously (preserves original boot timing).
|
|
364
|
+
if (asyncTasks.length > 0) {
|
|
365
|
+
return Promise.all(asyncTasks).then(finalize);
|
|
218
366
|
}
|
|
367
|
+
finalize();
|
|
219
368
|
})
|
|
220
369
|
.catch((error) => {
|
|
221
370
|
// Clean up pending fetch tracker
|
|
@@ -230,6 +379,34 @@ export const processComponent = (rootElement, onComplete, config = {}) => {
|
|
|
230
379
|
if (el.parentNode) {
|
|
231
380
|
el.remove();
|
|
232
381
|
}
|
|
233
|
-
// Don't recursively call - let MutationObserver handle it
|
|
234
382
|
});
|
|
235
383
|
};
|
|
384
|
+
|
|
385
|
+
export const processComponent = (rootElement, onComplete, config = {}) => {
|
|
386
|
+
const debug = !!config?.debug;
|
|
387
|
+
const allComponents = rootElement.querySelectorAll('component[src], div.component[src]');
|
|
388
|
+
|
|
389
|
+
if (allComponents.length === 0) {
|
|
390
|
+
if (onComplete) queueMicrotask(() => onComplete());
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// Only process top-level components — skip those nested inside other
|
|
395
|
+
// unprocessed component[src] elements (they're slot content that will
|
|
396
|
+
// be revealed when the parent component finalizes).
|
|
397
|
+
const topLevel = Array.from(allComponents).filter(
|
|
398
|
+
(el) => !isNestedInUnprocessedComponent(el, rootElement)
|
|
399
|
+
);
|
|
400
|
+
|
|
401
|
+
if (topLevel.length === 0) {
|
|
402
|
+
if (onComplete) queueMicrotask(() => onComplete());
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// Fetch and process all top-level components in parallel.
|
|
407
|
+
// Nested components (inside finalized content) are discovered and
|
|
408
|
+
// processed by the MutationObserver → processComponent chain.
|
|
409
|
+
// onComplete is handled by checkCleanup (which fires when no
|
|
410
|
+
// component[src] elements remain).
|
|
411
|
+
topLevel.forEach((el) => processSingle(el, debug));
|
|
412
|
+
};
|
package/runtime/conditionals.js
CHANGED
|
@@ -3,6 +3,53 @@ 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';
|
|
7
|
+
|
|
8
|
+
// Registry of DOM nodes owned by conditional branches.
|
|
9
|
+
// Maps a DOM node to { nodes: array_ref, index: number } so that
|
|
10
|
+
// processMutations can update the reference when processComponent
|
|
11
|
+
// replaces the node (el.replaceWith). Keeps conditional state in
|
|
12
|
+
// sync with actual DOM without sweeps or special properties.
|
|
13
|
+
export const branchNodeRegistry = new WeakMap();
|
|
14
|
+
|
|
15
|
+
// Nodes that have been processed by mountBranch or renderIteration.
|
|
16
|
+
// processMutations checks this to avoid re-processing already-handled content.
|
|
17
|
+
export const managedNodes = new WeakSet();
|
|
18
|
+
|
|
19
|
+
// Find the dot path of a conditional node in the manifest.
|
|
20
|
+
// Searches for the parent element, then appends the conditional's key.
|
|
21
|
+
const findConditionalPath = (node, manifest) => {
|
|
22
|
+
const parent = node.meta.startComment.parentNode;
|
|
23
|
+
const parentEntry = Object.entries(manifest).find(([_, el]) => el === parent);
|
|
24
|
+
if (!parentEntry) return null;
|
|
25
|
+
|
|
26
|
+
// Find which key this conditional has in the parent's children
|
|
27
|
+
// by matching the startComment reference
|
|
28
|
+
const [parentPath] = parentEntry;
|
|
29
|
+
return `${parentPath}.${node._key}`;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// Register branch tree nodes in the manifest (recursive)
|
|
33
|
+
const addBranchToManifest = (tree, manifest, basePath) => {
|
|
34
|
+
if (tree.element) {
|
|
35
|
+
manifest[basePath] = tree.element;
|
|
36
|
+
}
|
|
37
|
+
if (tree.children) {
|
|
38
|
+
for (const key in tree.children) {
|
|
39
|
+
addBranchToManifest(tree.children[key], manifest, `${basePath}.${key}`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// Remove branch tree nodes from the manifest (recursive)
|
|
45
|
+
const removeBranchFromManifest = (tree, manifest, basePath) => {
|
|
46
|
+
delete manifest[basePath];
|
|
47
|
+
if (tree.children) {
|
|
48
|
+
for (const key in tree.children) {
|
|
49
|
+
removeBranchFromManifest(tree.children[key], manifest, `${basePath}.${key}`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
};
|
|
6
53
|
|
|
7
54
|
// Evaluate conditional expression in state context
|
|
8
55
|
const evaluateCondition = (expression, state, element = null) => !!evalInScope(expression, state, element);
|
|
@@ -96,12 +143,12 @@ const mountBranch = (node, branchData, state, manifest, parentScope) => {
|
|
|
96
143
|
|
|
97
144
|
// If branch doesn't exist (no else clause), just unmount current
|
|
98
145
|
if (!branchData) {
|
|
99
|
-
unmountBranch(node);
|
|
146
|
+
unmountBranch(node, manifest);
|
|
100
147
|
return;
|
|
101
148
|
}
|
|
102
149
|
|
|
103
150
|
// Unmount current branch first (if any)
|
|
104
|
-
unmountBranch(node);
|
|
151
|
+
unmountBranch(node, manifest);
|
|
105
152
|
|
|
106
153
|
// Clone the template element
|
|
107
154
|
const templateContent = branchData.element.childNodes;
|
|
@@ -118,8 +165,29 @@ const mountBranch = (node, branchData, state, manifest, parentScope) => {
|
|
|
118
165
|
clonedNodes,
|
|
119
166
|
} = initializeBlock(templateContent, scopedState);
|
|
120
167
|
|
|
121
|
-
// Insert cloned nodes into DOM
|
|
122
|
-
clonedNodes.forEach((clonedNode) =>
|
|
168
|
+
// Insert cloned nodes into DOM and register in branch registry
|
|
169
|
+
clonedNodes.forEach((clonedNode, i) => {
|
|
170
|
+
parent.insertBefore(clonedNode, endComment);
|
|
171
|
+
branchNodeRegistry.set(clonedNode, { nodes: clonedNodes, index: i });
|
|
172
|
+
if (clonedNode.nodeType === 1) managedNodes.add(clonedNode);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// Integrate branch tree into the conditional node's children and manifest.
|
|
176
|
+
// This makes branch content visible to the main update loop (hydrate,
|
|
177
|
+
// renderAllConditionals, renderAllIterations) and to MutationObserver
|
|
178
|
+
// (which looks up parents in the manifest).
|
|
179
|
+
if (branchTree?.children) {
|
|
180
|
+
for (const key in branchTree.children) {
|
|
181
|
+
node.children[key] = branchTree.children[key];
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const condPath = findConditionalPath(node, manifest);
|
|
185
|
+
if (condPath) {
|
|
186
|
+
for (const key in branchTree.children) {
|
|
187
|
+
addBranchToManifest(branchTree.children[key], manifest, `${condPath}.${key}`);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
123
191
|
|
|
124
192
|
// Recursively render any nested iterations and conditionals
|
|
125
193
|
if (branchTree) {
|
|
@@ -136,18 +204,39 @@ const mountBranch = (node, branchData, state, manifest, parentScope) => {
|
|
|
136
204
|
};
|
|
137
205
|
|
|
138
206
|
// Unmount currently active branch
|
|
139
|
-
const unmountBranch = (node) => {
|
|
207
|
+
const unmountBranch = (node, manifest) => {
|
|
140
208
|
const { activeInstance } = node.runtime;
|
|
141
209
|
|
|
142
210
|
if (!activeInstance) return;
|
|
143
211
|
|
|
144
|
-
// Remove
|
|
212
|
+
// Remove branch children from the conditional node's tree and manifest
|
|
213
|
+
if (activeInstance.parsedTree?.children) {
|
|
214
|
+
const condPath = manifest ? findConditionalPath(node, manifest) : null;
|
|
215
|
+
|
|
216
|
+
for (const key in activeInstance.parsedTree.children) {
|
|
217
|
+
delete node.children[key];
|
|
218
|
+
if (condPath) {
|
|
219
|
+
removeBranchFromManifest(activeInstance.parsedTree.children[key], manifest, `${condPath}.${key}`);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
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
|
+
|
|
229
|
+
// Remove all nodes from DOM and deregister from branch registry
|
|
145
230
|
activeInstance.nodes.forEach((domNode) => {
|
|
231
|
+
branchNodeRegistry.delete(domNode);
|
|
146
232
|
if (domNode.parentNode) {
|
|
147
233
|
domNode.parentNode.removeChild(domNode);
|
|
148
234
|
}
|
|
149
235
|
});
|
|
150
236
|
|
|
237
|
+
// CLEANUP OF CURRENT STATE
|
|
238
|
+
releaseOrphanedComponentState(ids);
|
|
239
|
+
|
|
151
240
|
// Clear active instance
|
|
152
241
|
node.runtime.activeInstance = null;
|
|
153
242
|
};
|
|
@@ -173,7 +262,8 @@ export const updateConditional = (node, newState, oldState, manifest, parentScop
|
|
|
173
262
|
mountBranch(node, newBranchData, newState, manifest, parentScope);
|
|
174
263
|
node.runtime.activeBranch = newBranchData;
|
|
175
264
|
} else {
|
|
176
|
-
// Same branch, but state might have changed
|
|
265
|
+
// Same branch, but state might have changed — rehydrate bindings
|
|
266
|
+
// and update nested conditionals/iterations
|
|
177
267
|
const { activeInstance } = node.runtime;
|
|
178
268
|
|
|
179
269
|
if (activeInstance && activeInstance.parsedTree) {
|
|
@@ -182,6 +272,8 @@ export const updateConditional = (node, newState, oldState, manifest, parentScop
|
|
|
182
272
|
|
|
183
273
|
const affectedElements = affected(activeInstance.parsedTree, oldState, scopedState);
|
|
184
274
|
hydrate(affectedElements, scopedState);
|
|
275
|
+
renderAllConditionals(activeInstance.parsedTree, scopedState, manifest, parentScope);
|
|
276
|
+
renderAllIterations(activeInstance.parsedTree, scopedState, manifest, parentScope);
|
|
185
277
|
}
|
|
186
278
|
}
|
|
187
279
|
};
|