@ape-egg/vibe 2.0.0 → 2.1.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 +97 -0
- package/README.md +22 -1
- package/ROADMAP.md +45 -0
- package/compiler/native/vibe-compiler-darwin-arm64 +0 -0
- package/compiler/native/vibe-compiler-linux-x64 +0 -0
- package/compiler/src/Cargo.lock +1 -1
- package/compiler/src/Cargo.toml +1 -1
- package/compiler/src/compiler/compile.rs +100 -13
- package/compiler/src/compiler/iteration_optimizer.rs +188 -112
- package/compiler/src/compiler/manifest_builder.rs +5 -9
- package/compiler/src/parser/html.rs +194 -31
- package/llms.txt +1 -1
- package/package.json +1 -1
- package/runtime/cleanup.js +12 -3
- package/runtime/component-cache.js +94 -0
- package/runtime/component.js +139 -46
- package/runtime/conditionals.js +16 -9
- package/runtime/constants.js +2 -1
- package/runtime/debug.js +2 -1
- package/runtime/hydrate.js +12 -3
- package/runtime/index.js +38 -2
- package/runtime/iterate.js +103 -22
- package/runtime/loop-scope.js +49 -3
- package/runtime/parse.js +8 -0
- package/runtime/pre-compiled-manifest.js +56 -155
- package/runtime/utils.js +12 -2
package/package.json
CHANGED
package/runtime/cleanup.js
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { debugLog } from './debug.js';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
PHASE_READY,
|
|
4
|
+
FOUC_CLASS_OR_ATTR,
|
|
5
|
+
DEHYDRATE_CLASS_OR_ATTR,
|
|
6
|
+
NON_REACTIVE_ELEMENTS,
|
|
7
|
+
} from './constants.js';
|
|
3
8
|
|
|
4
9
|
/**
|
|
5
10
|
* Check if all Vibe processing is complete and cleanup can run
|
|
@@ -14,12 +19,16 @@ export const shouldCleanup = (rootElement) => {
|
|
|
14
19
|
return false;
|
|
15
20
|
}
|
|
16
21
|
|
|
17
|
-
// 2. Check for unhydrated bindings (literal @[...] in DOM, excluding dehydrated
|
|
22
|
+
// 2. Check for unhydrated bindings (literal @[...] in DOM, excluding dehydrated
|
|
23
|
+
// and non-reactive elements — hydrate never touches those, so a literal inside
|
|
24
|
+
// them is final content, not pending work)
|
|
18
25
|
const walker = document.createTreeWalker(rootElement, NodeFilter.SHOW_TEXT, {
|
|
19
26
|
acceptNode(node) {
|
|
20
|
-
// Check if this text node is inside a dehydrated element
|
|
21
27
|
let parent = node.parentElement;
|
|
22
28
|
while (parent && parent !== rootElement) {
|
|
29
|
+
if (NON_REACTIVE_ELEMENTS.includes(parent.nodeName)) {
|
|
30
|
+
return NodeFilter.FILTER_REJECT;
|
|
31
|
+
}
|
|
23
32
|
if (parent.hasAttribute(DEHYDRATE_CLASS_OR_ATTR) || parent.classList?.contains(DEHYDRATE_CLASS_OR_ATTR)) {
|
|
24
33
|
return NodeFilter.FILTER_REJECT; // Skip dehydrated content
|
|
25
34
|
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Component template cache.
|
|
2
|
+
//
|
|
3
|
+
// Vibe loads each `<component src="...">` by fetching its HTML template. A page
|
|
4
|
+
// commonly mounts the same component many times (a list of cards, a row of
|
|
5
|
+
// stat bars), and an SPA re-mounts components on every navigation. Without a
|
|
6
|
+
// cache, each instance — and each revisit — refetches an identical template,
|
|
7
|
+
// and a burst of same-tick mounts stampedes the network with N concurrent
|
|
8
|
+
// requests for one file.
|
|
9
|
+
//
|
|
10
|
+
// This module dedupes those fetches by `src`:
|
|
11
|
+
// - concurrent mounts in the same tick share one in-flight request, because
|
|
12
|
+
// the PROMISE (not the resolved text) is cached synchronously before the
|
|
13
|
+
// first await — so callers coalesce onto it instead of each starting their own
|
|
14
|
+
// - later mounts (including after SPA navigation) resolve from memory, with
|
|
15
|
+
// no network request at all — the one win a browser HTTP cache cannot
|
|
16
|
+
// provide, since it revalidates per request and never coalesces concurrent ones
|
|
17
|
+
//
|
|
18
|
+
// The cache is session-lived and content-busted, never time-busted. In
|
|
19
|
+
// production a component template is immutable for the life of the page (it
|
|
20
|
+
// only changes on redeploy, which is a new session anyway), so there is nothing
|
|
21
|
+
// to invalidate. In development, tooling busts entries on file change via
|
|
22
|
+
// `clearComponentCache(path)` — which keeps this module free of any dev/HMR
|
|
23
|
+
// coupling; it never references the dev server or its events.
|
|
24
|
+
//
|
|
25
|
+
// Disable entirely with `vibe(state, { noCache: true })`.
|
|
26
|
+
|
|
27
|
+
let enabled = true;
|
|
28
|
+
|
|
29
|
+
// src -> Promise<string> (raw template HTML). Stores the in-flight promise so
|
|
30
|
+
// concurrent callers coalesce; the resolved value is held by the promise, so a
|
|
31
|
+
// settled entry is an instant cache hit on every later read.
|
|
32
|
+
const templates = new Map();
|
|
33
|
+
|
|
34
|
+
// Configure from the runtime config (`{ noCache }`). Called once at boot. When
|
|
35
|
+
// caching is turned off we also drop anything already cached, so toggling at
|
|
36
|
+
// runtime (e.g. between test cases) can't serve a stale hit.
|
|
37
|
+
export const configureComponentCache = (config = {}) => {
|
|
38
|
+
enabled = !config?.noCache;
|
|
39
|
+
if (!enabled) templates.clear();
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export const isComponentCacheEnabled = () => enabled;
|
|
43
|
+
|
|
44
|
+
// True when `src` will resolve without a new network request — either a settled
|
|
45
|
+
// template or an in-flight request this mount coalesces onto. Callers capture
|
|
46
|
+
// this BEFORE fetchComponentTemplate so the debug layer can distinguish a real
|
|
47
|
+
// network fetch from a cache hit.
|
|
48
|
+
export const isComponentCached = (src) => enabled && templates.has(src);
|
|
49
|
+
|
|
50
|
+
// Fetch a component template, deduped by `src`. Returns a Promise<string>.
|
|
51
|
+
//
|
|
52
|
+
// `signal` aborts the request when the host element is removed. It is honored
|
|
53
|
+
// only on the uncached path: a shared cached fetch must NOT be aborted by one
|
|
54
|
+
// element unmounting while other elements still await the same template. The
|
|
55
|
+
// caller already re-checks `el.parentNode` after the fetch settles, so dropping
|
|
56
|
+
// the abort on the shared path costs nothing but a tiny, redundant download.
|
|
57
|
+
export const fetchComponentTemplate = (src, signal) => {
|
|
58
|
+
if (!enabled) {
|
|
59
|
+
return fetch(src, { signal }).then((response) => response.text());
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
let entry = templates.get(src);
|
|
63
|
+
if (!entry) {
|
|
64
|
+
entry = fetch(src).then(async (response) => {
|
|
65
|
+
const text = await response.text();
|
|
66
|
+
// Never persist a failed response — the immediate caller still gets the
|
|
67
|
+
// body (parity with the uncached path), but the next mount may retry.
|
|
68
|
+
if (!response.ok) templates.delete(src);
|
|
69
|
+
return text;
|
|
70
|
+
});
|
|
71
|
+
// Cache synchronously, before the first await, so same-tick concurrent
|
|
72
|
+
// mounts find this pending entry and coalesce onto it.
|
|
73
|
+
templates.set(src, entry);
|
|
74
|
+
// Drop the entry if the fetch rejects, so a transient network error isn't
|
|
75
|
+
// sticky for the rest of the session.
|
|
76
|
+
entry.catch(() => templates.delete(src));
|
|
77
|
+
}
|
|
78
|
+
return entry;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
// Invalidate cached templates. With a `path`, drops that one entry (the query
|
|
82
|
+
// string is ignored when matching, so `/components/Foo.html` also clears a
|
|
83
|
+
// versioned `/components/Foo.html?v=…`); with no argument, clears everything.
|
|
84
|
+
// Exposed publicly as `$.clearComponentCache` for tooling to call on change.
|
|
85
|
+
export const clearComponentCache = (path) => {
|
|
86
|
+
if (!path) {
|
|
87
|
+
templates.clear();
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
const base = path.split('?')[0];
|
|
91
|
+
for (const key of templates.keys()) {
|
|
92
|
+
if (key.split('?')[0] === base) templates.delete(key);
|
|
93
|
+
}
|
|
94
|
+
};
|
package/runtime/component.js
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { debugLog } from './debug.js';
|
|
2
2
|
import {
|
|
3
3
|
PHASE_FETCH,
|
|
4
|
+
PHASE_FETCH_CACHED,
|
|
4
5
|
DEHYDRATE_CLASS_OR_ATTR,
|
|
5
6
|
BINDING_REGEX,
|
|
6
7
|
THIS_PROP_REGEX,
|
|
7
8
|
STATE_THIS_PROP_REGEX,
|
|
8
9
|
} from './constants.js';
|
|
9
10
|
import { evalInScope } from './utils.js';
|
|
11
|
+
import { fetchComponentTemplate, isComponentCached } from './component-cache.js';
|
|
10
12
|
|
|
11
13
|
// Deterministic component counter
|
|
12
14
|
let componentCounter = 0;
|
|
@@ -98,9 +100,131 @@ export const collectComponentIds = (node, into = new Set()) => {
|
|
|
98
100
|
return into;
|
|
99
101
|
};
|
|
100
102
|
|
|
103
|
+
// Prepare a component <script> body for execution through the injected
|
|
104
|
+
// component() path: strip the `import component from '...'` line (the
|
|
105
|
+
// function is passed in as a parameter) and rewrite any remaining static
|
|
106
|
+
// imports to awaited dynamic imports. Shared by the fetch path
|
|
107
|
+
// (processSingle) and the compiled-page path (executeCompiledComponentScripts).
|
|
108
|
+
const transformScriptContent = (rawContent) => {
|
|
109
|
+
// Strip `import component from '...'` — Vibe injects the contextual
|
|
110
|
+
// component() function as a parameter (it needs access to the temp DOM)
|
|
111
|
+
let content = rawContent.replace(
|
|
112
|
+
/import\s+component\s+from\s+['"][^'"]+['"];?\s*/g,
|
|
113
|
+
''
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
// Check for remaining imports that need rewriting
|
|
117
|
+
const hasImports = /import\s/.test(content);
|
|
118
|
+
|
|
119
|
+
if (hasImports) {
|
|
120
|
+
// Rewrite remaining imports to dynamic await import()
|
|
121
|
+
// Order matters: combined → default → named → namespace → side-effect (most specific first)
|
|
122
|
+
content = content.replace(
|
|
123
|
+
/import\s+(\w+)\s*,\s*\{([^}]+)\}\s+from\s+(['"][^'"]+['"])\s*;?/g,
|
|
124
|
+
'const __m_$1 = await import($3); const $1 = __m_$1.default; const {$2} = __m_$1;'
|
|
125
|
+
);
|
|
126
|
+
content = content.replace(
|
|
127
|
+
/import\s+(\w+)\s+from\s+(['"][^'"]+['"])\s*;?/g,
|
|
128
|
+
'const $1 = (await import($2)).default;'
|
|
129
|
+
);
|
|
130
|
+
content = content.replace(
|
|
131
|
+
/import\s+\{([^}]+)\}\s+from\s+(['"][^'"]+['"])\s*;?/g,
|
|
132
|
+
'const {$1} = await import($2);'
|
|
133
|
+
);
|
|
134
|
+
content = content.replace(
|
|
135
|
+
/import\s+\*\s+as\s+(\w+)\s+from\s+(['"][^'"]+['"])\s*;?/g,
|
|
136
|
+
'const $1 = await import($2);'
|
|
137
|
+
);
|
|
138
|
+
content = content.replace(
|
|
139
|
+
/import\s+(['"][^'"]+['"])\s*;?/g,
|
|
140
|
+
'await import($1);'
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return { content, hasImports };
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
// Compiled pages inline `<component src>` content at build time, and the
|
|
148
|
+
// compiler neuters each component script to type="vibe-module" so the browser
|
|
149
|
+
// does NOT execute it as a native page module — native timing is wrong
|
|
150
|
+
// (pre-boot: `$` is the placeholder, state registered after boot never merges
|
|
151
|
+
// into the live proxy). This executes those scripts through the same
|
|
152
|
+
// injected-component() path processSingle uses for fetched scripts: one
|
|
153
|
+
// pipeline, identical semantics in both modes. Scripts stay in the DOM
|
|
154
|
+
// (inert) so the manifest's childNodes indices keep matching the page.
|
|
155
|
+
//
|
|
156
|
+
// Returns a Promise when any script is async (has imports) — the caller
|
|
157
|
+
// gates `ready` on it — or null when everything ran synchronously.
|
|
158
|
+
export const executeCompiledComponentScripts = () => {
|
|
159
|
+
const scripts = document.querySelectorAll('script[type="vibe-module"]');
|
|
160
|
+
if (!scripts.length) return null;
|
|
161
|
+
|
|
162
|
+
// Build-time tagging already assigned _cN ids to wrappers; advance the
|
|
163
|
+
// runtime counter past them so freshly generated ids never collide.
|
|
164
|
+
document.querySelectorAll('[data-vibe-component-id]').forEach((el) => {
|
|
165
|
+
const m = el.getAttribute('data-vibe-component-id')?.match(/^_c(\d+)$/);
|
|
166
|
+
if (m) componentCounter = Math.max(componentCounter, Number(m[1]) + 1);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
const asyncTasks = [];
|
|
170
|
+
const claimed = new Set();
|
|
171
|
+
|
|
172
|
+
for (const script of scripts) {
|
|
173
|
+
// Parity with the fetch path: dehydrated components never execute
|
|
174
|
+
if (script.closest(`[${DEHYDRATE_CLASS_OR_ATTR}], .${DEHYDRATE_CLASS_OR_ATTR}`)) continue;
|
|
175
|
+
|
|
176
|
+
const rawContent = script.textContent?.trim() || '';
|
|
177
|
+
if (!rawContent) continue;
|
|
178
|
+
|
|
179
|
+
// The build tagged each component wrapper with its deterministic id —
|
|
180
|
+
// the same id the stamped bindings reference. First script in a wrapper
|
|
181
|
+
// claims it; additional scripts get fresh ids (mirrors the per-script
|
|
182
|
+
// ids of the fetch path).
|
|
183
|
+
const wrapper = script.closest('[data-vibe-component-id]');
|
|
184
|
+
let componentId = wrapper?.getAttribute('data-vibe-component-id');
|
|
185
|
+
if (!componentId || claimed.has(componentId)) componentId = generateComponentId();
|
|
186
|
+
claimed.add(componentId);
|
|
187
|
+
|
|
188
|
+
const { content, hasImports } = transformScriptContent(rawContent);
|
|
189
|
+
|
|
190
|
+
const componentFn = (state) => {
|
|
191
|
+
if (!window.__vibeComponents) window.__vibeComponents = {};
|
|
192
|
+
window.__vibeComponents[componentId] = state;
|
|
193
|
+
if (window.$) window.$[componentId] = state;
|
|
194
|
+
return componentId;
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
runComponentCleanups(componentId);
|
|
198
|
+
const scopedDollar = createScopedDollar(componentId);
|
|
199
|
+
|
|
200
|
+
try {
|
|
201
|
+
if (hasImports) {
|
|
202
|
+
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
|
|
203
|
+
asyncTasks.push(new AsyncFunction('$', 'component', content)(scopedDollar, componentFn));
|
|
204
|
+
} else {
|
|
205
|
+
new Function('$', 'component', content)(scopedDollar, componentFn);
|
|
206
|
+
}
|
|
207
|
+
} catch (e) {
|
|
208
|
+
console.warn('[vibe] Failed to execute component script:', e);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return asyncTasks.length ? Promise.all(asyncTasks) : null;
|
|
213
|
+
};
|
|
214
|
+
|
|
101
215
|
// Track pending fetches to cancel them if element is removed
|
|
102
216
|
const pendingFetches = new WeakMap(); // element → AbortController
|
|
103
217
|
|
|
218
|
+
// Component HTML is parsed (and prop/slot-transformed) while its @[...]
|
|
219
|
+
// bindings are still literal text. Parsing in the live document lets the
|
|
220
|
+
// browser act on those literals mid-parse — Chrome logs "The specified value
|
|
221
|
+
// ... cannot be parsed" for a typed input's value="@[...]". An inert document
|
|
222
|
+
// (no browsing context) parses identical DOM without a console to complain to;
|
|
223
|
+
// nodes are auto-adopted into the live document on insertion.
|
|
224
|
+
let inertDocument;
|
|
225
|
+
const createDetached = (tagName) =>
|
|
226
|
+
(inertDocument ??= document.implementation.createHTMLDocument('')).createElement(tagName);
|
|
227
|
+
|
|
104
228
|
// Cancel a pending fetch for a component element
|
|
105
229
|
export const abortComponentFetch = (element) => {
|
|
106
230
|
const controller = pendingFetches.get(element);
|
|
@@ -273,7 +397,7 @@ const renderPropsAndSlot = (temp, props, slotHtml) => {
|
|
|
273
397
|
// or trigger a full re-mount (new componentIds).
|
|
274
398
|
export const renderComponentTemplate = (rawHtml, props = {}, slotHtml = '', options = {}) => {
|
|
275
399
|
const { componentIds = [] } = options;
|
|
276
|
-
const temp =
|
|
400
|
+
const temp = createDetached('div');
|
|
277
401
|
temp.innerHTML = rawHtml;
|
|
278
402
|
|
|
279
403
|
const idsToReuse = [...componentIds];
|
|
@@ -310,15 +434,19 @@ const processSingle = (el, debug) => {
|
|
|
310
434
|
}
|
|
311
435
|
});
|
|
312
436
|
|
|
437
|
+
// Capture cache state before the fetch so the debug layer can tell a real
|
|
438
|
+
// network fetch from a runtime-cache hit (the call below would make them
|
|
439
|
+
// indistinguishable — both just resolve a promise).
|
|
440
|
+
const fromCache = isComponentCached(src);
|
|
441
|
+
|
|
313
442
|
// Create AbortController to cancel fetch if element is removed
|
|
314
443
|
const controller = new AbortController();
|
|
315
444
|
pendingFetches.set(el, controller);
|
|
316
445
|
|
|
317
|
-
return
|
|
318
|
-
.then((r) => r.text())
|
|
446
|
+
return fetchComponentTemplate(src, controller.signal)
|
|
319
447
|
.then((html) => {
|
|
320
448
|
// Parse HTML in temporary container to process component scripts
|
|
321
|
-
const temp =
|
|
449
|
+
const temp = createDetached('div');
|
|
322
450
|
temp.innerHTML = html;
|
|
323
451
|
|
|
324
452
|
// Process any <script type="module"> elements
|
|
@@ -343,43 +471,10 @@ const processSingle = (el, debug) => {
|
|
|
343
471
|
let firstComponentId = null;
|
|
344
472
|
|
|
345
473
|
for (const script of moduleScripts) {
|
|
346
|
-
|
|
347
|
-
if (!
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
// component() function as a parameter (it needs access to the temp DOM)
|
|
351
|
-
scriptContent = scriptContent.replace(
|
|
352
|
-
/import\s+component\s+from\s+['"][^'"]+['"];?\s*/g,
|
|
353
|
-
''
|
|
354
|
-
);
|
|
355
|
-
|
|
356
|
-
// Check for remaining imports that need rewriting
|
|
357
|
-
const hasImports = /import\s/.test(scriptContent);
|
|
358
|
-
|
|
359
|
-
if (hasImports) {
|
|
360
|
-
// Rewrite remaining imports to dynamic await import()
|
|
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
|
-
);
|
|
366
|
-
scriptContent = scriptContent.replace(
|
|
367
|
-
/import\s+(\w+)\s+from\s+(['"][^'"]+['"])\s*;?/g,
|
|
368
|
-
'const $1 = (await import($2)).default;'
|
|
369
|
-
);
|
|
370
|
-
scriptContent = scriptContent.replace(
|
|
371
|
-
/import\s+\{([^}]+)\}\s+from\s+(['"][^'"]+['"])\s*;?/g,
|
|
372
|
-
'const {$1} = await import($2);'
|
|
373
|
-
);
|
|
374
|
-
scriptContent = scriptContent.replace(
|
|
375
|
-
/import\s+\*\s+as\s+(\w+)\s+from\s+(['"][^'"]+['"])\s*;?/g,
|
|
376
|
-
'const $1 = await import($2);'
|
|
377
|
-
);
|
|
378
|
-
scriptContent = scriptContent.replace(
|
|
379
|
-
/import\s+(['"][^'"]+['"])\s*;?/g,
|
|
380
|
-
'await import($1);'
|
|
381
|
-
);
|
|
382
|
-
}
|
|
474
|
+
const rawContent = script.textContent?.trim() || '';
|
|
475
|
+
if (!rawContent) continue;
|
|
476
|
+
|
|
477
|
+
const { content: scriptContent, hasImports } = transformScriptContent(rawContent);
|
|
383
478
|
|
|
384
479
|
// Reuse component ID from HMR if available, otherwise generate new
|
|
385
480
|
const reuseIds = el._vibeReuseComponentIds;
|
|
@@ -454,9 +549,7 @@ const processSingle = (el, debug) => {
|
|
|
454
549
|
if (el.parentNode) {
|
|
455
550
|
// Create clean wrapper element (preserve tag type: component or div.component)
|
|
456
551
|
const newWrapper =
|
|
457
|
-
el.tagName === 'DIV'
|
|
458
|
-
? document.createElement('div')
|
|
459
|
-
: document.createElement('component');
|
|
552
|
+
el.tagName === 'DIV' ? createDetached('div') : createDetached('component');
|
|
460
553
|
|
|
461
554
|
if (el.tagName === 'DIV') {
|
|
462
555
|
newWrapper.className = 'component';
|
|
@@ -476,7 +569,7 @@ const processSingle = (el, debug) => {
|
|
|
476
569
|
// detached `<component src>` to the new wrapper. The detached element
|
|
477
570
|
// would otherwise trigger releaseOrphanedIterationProps and free the
|
|
478
571
|
// registry slots that the inlined template's bindings still reference,
|
|
479
|
-
// causing every `@[window.
|
|
572
|
+
// causing every `@[window.__vibeiterprops._pN]` to resolve to undefined
|
|
480
573
|
// on the next hydrate.
|
|
481
574
|
if (el._vibeIterPropIds) {
|
|
482
575
|
newWrapper._vibeIterPropIds = el._vibeIterPropIds;
|
|
@@ -502,7 +595,7 @@ const processSingle = (el, debug) => {
|
|
|
502
595
|
// each row update.
|
|
503
596
|
el._vibeReplacedBy = newWrapper;
|
|
504
597
|
el.replaceWith(newWrapper);
|
|
505
|
-
debugLog(PHASE_FETCH, src, debug);
|
|
598
|
+
debugLog(fromCache ? PHASE_FETCH_CACHED : PHASE_FETCH, src, debug);
|
|
506
599
|
|
|
507
600
|
// MutationObserver handles parsing and hydrating the new content.
|
|
508
601
|
// Branch nodes are registered in the manifest by mountBranch,
|
package/runtime/conditionals.js
CHANGED
|
@@ -263,18 +263,25 @@ const unmountBranch = (node, manifest) => {
|
|
|
263
263
|
}
|
|
264
264
|
}
|
|
265
265
|
|
|
266
|
-
//
|
|
266
|
+
// The conditional owns everything between its comments. That's more than
|
|
267
|
+
// activeInstance.nodes: nested directives at the branch's top level (each
|
|
268
|
+
// rows, deeper if branches) insert their rendered output between their own
|
|
269
|
+
// comments after the branch mounts, so it never appears in the original
|
|
270
|
+
// clonedNodes list. Sweep the live range (same pattern as iteration
|
|
271
|
+
// teardown), collecting componentIds BEFORE detaching so we can check
|
|
267
272
|
// after removal whether any live DOM still holds them.
|
|
273
|
+
const { startComment, endComment } = node.meta;
|
|
268
274
|
const ids = new Set();
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
branchNodeRegistry.delete(
|
|
274
|
-
if (
|
|
275
|
-
|
|
275
|
+
let current = startComment.nextSibling;
|
|
276
|
+
while (current && current !== endComment) {
|
|
277
|
+
const next = current.nextSibling;
|
|
278
|
+
collectComponentIds(current, ids);
|
|
279
|
+
branchNodeRegistry.delete(current);
|
|
280
|
+
if (current.parentNode) {
|
|
281
|
+
current.parentNode.removeChild(current);
|
|
276
282
|
}
|
|
277
|
-
|
|
283
|
+
current = next;
|
|
284
|
+
}
|
|
278
285
|
|
|
279
286
|
// CLEANUP OF CURRENT STATE
|
|
280
287
|
releaseOrphanedComponentState(ids);
|
package/runtime/constants.js
CHANGED
|
@@ -15,7 +15,8 @@ export const PHASE_PARSE = 'Parsed'; // Reads DOM structure (parse.js)
|
|
|
15
15
|
export const PHASE_HYDRATE = 'Hydrated'; // Replaces @[...] with values (hydrate.js)
|
|
16
16
|
export const PHASE_ITERATE = 'Iterated'; // Renders <!-- each --> blocks (iterate.js)
|
|
17
17
|
export const PHASE_CONDITION = 'Evaluated'; // Renders <!-- if --> blocks (conditionals.js)
|
|
18
|
-
export const PHASE_FETCH = 'Fetched'; // Loads <component> content (component.js)
|
|
18
|
+
export const PHASE_FETCH = 'Fetched'; // Loads <component> content over the network (component.js)
|
|
19
|
+
export const PHASE_FETCH_CACHED = 'Fet(ca)ched'; // Loads <component> content from the runtime template cache — a Fetch served from memory (component.js)
|
|
19
20
|
export const PHASE_UPDATE = 'Proxy'; // State changes trigger re-hydration (index.js)
|
|
20
21
|
export const PHASE_MUTATE = 'Mutation'; // DOM mutations detected by observer (index.js)
|
|
21
22
|
export const PHASE_HYPERSPEED = 'Hyperspeed'; // Restores @[...] markers from pre-compiled manifest (pre-compiled-manifest.js)
|
package/runtime/debug.js
CHANGED
|
@@ -12,6 +12,7 @@ const PHASE_COLORS = {
|
|
|
12
12
|
Iterated: 'oklch(0.58 0.11 142)', // comment green (baseline)
|
|
13
13
|
Evaluated: 'oklch(0.58 0.11 142)', // comment green (baseline)
|
|
14
14
|
Fetched: 'oklch(0.59 0.12 307)', // muted purple
|
|
15
|
+
'Fet(ca)ched': 'oklch(0.59 0.12 307)', // same muted purple — a Fetch served from the runtime cache
|
|
15
16
|
Mutation: 'oklch(0.60 0.11 240)', // muted blue
|
|
16
17
|
Proxy: 'oklch(0.60 0.11 240)', // muted blue
|
|
17
18
|
Hyperspeed: 'oklch(0.60 0.11 180)', // muted cyan
|
|
@@ -39,7 +40,7 @@ const COLORS = {
|
|
|
39
40
|
export const debugLog = (phase, message, debug = false, indent = 0, element = null) => {
|
|
40
41
|
if (!debug) return;
|
|
41
42
|
|
|
42
|
-
const phaseBracket = `[${phase}] `.padEnd(
|
|
43
|
+
const phaseBracket = `[${phase}] `.padEnd(14, ' '); // Pad to 14 chars (longest is "[Fet(ca)ched] ")
|
|
43
44
|
const indentStr = indent > 0 ? ' '.repeat(indent) + '├─ ' : '';
|
|
44
45
|
const phaseColor = PHASE_COLORS[phase] || 'oklch(0.55 0.02 250)';
|
|
45
46
|
|
package/runtime/hydrate.js
CHANGED
|
@@ -92,9 +92,18 @@ export default (affected, state, manifest = {}, oldState = {}) => {
|
|
|
92
92
|
const expr = isPureBinding[1];
|
|
93
93
|
const value = evalInScope(expr, effectiveState, element);
|
|
94
94
|
if (element[attrName] !== value) element[attrName] = value;
|
|
95
|
-
if (
|
|
96
|
-
|
|
97
|
-
|
|
95
|
+
if (attrName === 'value') {
|
|
96
|
+
if (value !== undefined && value !== null) {
|
|
97
|
+
const str = String(value);
|
|
98
|
+
if (element.getAttribute(attrName) !== str) element.setAttribute(attrName, str);
|
|
99
|
+
}
|
|
100
|
+
} else if (value) {
|
|
101
|
+
// checked/selected are boolean — the truthful attribute form is
|
|
102
|
+
// presence (empty) when truthy, absence when falsy. Stringifying
|
|
103
|
+
// would leave checked="false", which is "checked" to CSS and HTML.
|
|
104
|
+
if (element.getAttribute(attrName) !== '') element.setAttribute(attrName, '');
|
|
105
|
+
} else if (element.hasAttribute(attrName)) {
|
|
106
|
+
element.removeAttribute(attrName);
|
|
98
107
|
}
|
|
99
108
|
} else if (!isValueAttr && isPureBinding) {
|
|
100
109
|
// Boolean-like attributes: add or remove based on truthiness.
|
package/runtime/index.js
CHANGED
|
@@ -23,7 +23,8 @@ import {
|
|
|
23
23
|
PHASE_READY,
|
|
24
24
|
DEHYDRATE_CLASS_OR_ATTR,
|
|
25
25
|
} from './constants.js';
|
|
26
|
-
import { processComponent, abortComponentFetch, collectComponentIds, releaseOrphanedComponentState, renderComponentTemplate } from './component.js';
|
|
26
|
+
import { processComponent, abortComponentFetch, collectComponentIds, releaseOrphanedComponentState, renderComponentTemplate, executeCompiledComponentScripts } from './component.js';
|
|
27
|
+
import { configureComponentCache, clearComponentCache } from './component-cache.js';
|
|
27
28
|
import { debugLog } from './debug.js';
|
|
28
29
|
import { shouldCleanup, cleanup } from './cleanup.js';
|
|
29
30
|
import { reconcile } from './reconcile.js';
|
|
@@ -331,6 +332,9 @@ const main = (s, config = {}, stringSelector = '') => {
|
|
|
331
332
|
const verbose = !!config?.verbose;
|
|
332
333
|
globalThis.__vibeDebug = debug;
|
|
333
334
|
|
|
335
|
+
// Enable/disable the component template cache from config (`{ noCache }`).
|
|
336
|
+
configureComponentCache(config);
|
|
337
|
+
|
|
334
338
|
// Expose the global `$scope` resolver used by loop-scoped `on*` handlers.
|
|
335
339
|
installScopeResolver();
|
|
336
340
|
|
|
@@ -676,6 +680,17 @@ const main = (s, config = {}, stringSelector = '') => {
|
|
|
676
680
|
enumerable: false,
|
|
677
681
|
});
|
|
678
682
|
|
|
683
|
+
// Invalidate cached component templates. `$.clearComponentCache(path)` drops
|
|
684
|
+
// one entry, `$.clearComponentCache()` drops all. Templates are immutable in
|
|
685
|
+
// production (nothing to clear), so this exists for tooling that swaps a
|
|
686
|
+
// template under a live session — e.g. the dev server busts the changed file
|
|
687
|
+
// on hot update. Non-enumerable so it never leaks into state snapshots.
|
|
688
|
+
Object.defineProperty($, 'clearComponentCache', {
|
|
689
|
+
value: clearComponentCache,
|
|
690
|
+
enumerable: false,
|
|
691
|
+
configurable: true,
|
|
692
|
+
});
|
|
693
|
+
|
|
679
694
|
// Expose the live reactive proxy to the iteration stamper so loop-scoped
|
|
680
695
|
// `on*` handlers (`$scope`) resolve the SAME object identity the app sees via
|
|
681
696
|
// `$`, instead of the plain diff-snapshot clones iterations render against
|
|
@@ -706,6 +721,18 @@ const main = (s, config = {}, stringSelector = '') => {
|
|
|
706
721
|
// first conditional/binding eval.
|
|
707
722
|
if (typeof window !== 'undefined') window.$ = $;
|
|
708
723
|
|
|
724
|
+
// Compiled pages: execute build-inlined component scripts (neutered to
|
|
725
|
+
// type="vibe-module" by the compiler) through the runtime's component-script
|
|
726
|
+
// pipeline — same injected component(), same scoped `$`, same import
|
|
727
|
+
// rewriting as fetched scripts. Runs after `window.$` is live so
|
|
728
|
+
// `const id = component(state); $[id].x = ...` captures the reactive proxy,
|
|
729
|
+
// and before initial hydration so synchronous scripts' state is already
|
|
730
|
+
// registered when `this.` bindings first evaluate. Async scripts gate
|
|
731
|
+
// `ready` via compiledScriptsDone below.
|
|
732
|
+
let compiledScriptsDone = true;
|
|
733
|
+
const compiledScriptsPending = executeCompiledComponentScripts();
|
|
734
|
+
if (compiledScriptsPending) compiledScriptsDone = false;
|
|
735
|
+
|
|
709
736
|
// Initial hydration - pass plain values so iteration can do reference comparison
|
|
710
737
|
const initialState = extractPlainValue($);
|
|
711
738
|
const affectedElements = affected(parsedTree, initialState, initialState);
|
|
@@ -1125,7 +1152,7 @@ const main = (s, config = {}, stringSelector = '') => {
|
|
|
1125
1152
|
|
|
1126
1153
|
// Check if cleanup should run
|
|
1127
1154
|
const checkCleanup = () => {
|
|
1128
|
-
if (cleanupExecuted || isCompiling) return;
|
|
1155
|
+
if (cleanupExecuted || isCompiling || !compiledScriptsDone) return;
|
|
1129
1156
|
|
|
1130
1157
|
// Check for pending mutations first
|
|
1131
1158
|
const pendingMutations = observer ? observer.takeRecords() : [];
|
|
@@ -1157,6 +1184,15 @@ const main = (s, config = {}, stringSelector = '') => {
|
|
|
1157
1184
|
// Register hook to check for cleanup readiness after each mutation batch
|
|
1158
1185
|
hooks.afterDomMutation.push(checkCleanup);
|
|
1159
1186
|
|
|
1187
|
+
// Async compiled component scripts (imports) finish after boot — unlock the
|
|
1188
|
+
// ready gate and re-check once their state has merged into `$`.
|
|
1189
|
+
if (compiledScriptsPending) {
|
|
1190
|
+
compiledScriptsPending.then(() => {
|
|
1191
|
+
compiledScriptsDone = true;
|
|
1192
|
+
checkCleanup();
|
|
1193
|
+
});
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1160
1196
|
// Process component elements after initialization - MutationObserver will handle hydration
|
|
1161
1197
|
componentProcessingStarted = true;
|
|
1162
1198
|
|