@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ape-egg/vibe",
3
- "version": "2.0.0",
3
+ "version": "2.1.1",
4
4
  "type": "module",
5
5
  "description": "Runtime-first reactivity with optional compiler",
6
6
  "main": "index.js",
@@ -1,5 +1,10 @@
1
1
  import { debugLog } from './debug.js';
2
- import { PHASE_READY, FOUC_CLASS_OR_ATTR, DEHYDRATE_CLASS_OR_ATTR } from './constants.js';
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 elements)
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
+ };
@@ -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 = document.createElement('div');
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 fetch(src, { signal: controller.signal })
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 = document.createElement('div');
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
- let scriptContent = script.textContent?.trim() || '';
347
- if (!scriptContent) continue;
348
-
349
- // Strip `import component from '...'` Vibe injects the contextual
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.__vibeIterProps._pN]` to resolve to undefined
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,
@@ -263,18 +263,25 @@ const unmountBranch = (node, manifest) => {
263
263
  }
264
264
  }
265
265
 
266
- // Collect componentIds from the subtree BEFORE detaching so we can check
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
- activeInstance.nodes.forEach((domNode) => collectComponentIds(domNode, ids));
270
-
271
- // Remove all nodes from DOM and deregister from branch registry
272
- activeInstance.nodes.forEach((domNode) => {
273
- branchNodeRegistry.delete(domNode);
274
- if (domNode.parentNode) {
275
- domNode.parentNode.removeChild(domNode);
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);
@@ -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(13, ' '); // Pad to 13 chars (longest is "[Manifested] ")
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
 
@@ -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 (value !== undefined && value !== null) {
96
- const str = String(value);
97
- if (element.getAttribute(attrName) !== str) element.setAttribute(attrName, str);
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