@ape-egg/vibe 2.0.5 → 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 +19 -0
- package/README.md +21 -0
- package/llms.txt +1 -1
- package/package.json +1 -1
- package/runtime/component-cache.js +94 -0
- package/runtime/component.js +9 -3
- package/runtime/constants.js +2 -1
- package/runtime/debug.js +2 -1
- package/runtime/index.js +15 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [2.1.1] - 2026-06-18
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **`[Fet(ca)ched]` debug event for cache hits** (`runtime/debug.js`, `runtime/constants.js`, `runtime/component.js`, `runtime/component-cache.js`) — in debug mode, a `<component src>` served from the runtime template cache now logs `[Fet(ca)ched]` instead of `[Fetched]`, so a real network fetch and a cache hit are visually distinct at a glance (both share the same purple). `component-cache.js` exposes `isComponentCached(src)`, captured before the fetch so the debug layer can tell the two apart. The phase-bracket padding widened 13 → 14 to keep `[Fet(ca)ched]` column-aligned with the other events.
|
|
8
|
+
|
|
9
|
+
## [2.1.0] - 2026-06-17
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- **Component template cache** (`runtime/component-cache.js`, new) — Vibe now caches each fetched `<component src>` template by `src` instead of refetching it per instance. A page that mounts the same component many times, or an SPA that re-mounts components on navigation, previously issued one network request per instance; it now issues **one per unique template**. Two mechanisms in one small module:
|
|
14
|
+
- **In-flight coalescing** — the fetch *promise* is cached synchronously before its first `await`, so a burst of same-tick mounts of the same `src` shares a single request instead of stampeding the network. This is something a browser HTTP cache structurally cannot do (a cold cache can't dedupe concurrent requests for the same URL).
|
|
15
|
+
- **Session-lived reuse** — later mounts, including after SPA navigation, resolve from memory with no network request at all.
|
|
16
|
+
|
|
17
|
+
The cache is content-busted, never time-based: in production component templates are immutable for the life of the page, so there is nothing to invalidate and no staleness window. Per-instance props, slots, and component-local state are unaffected — only the template text is shared; each instance hydrates independently. Verified on a real page: a view that fetched 253 component files (41 unique) now fetches 41, with zero duplicate requests.
|
|
18
|
+
- New config flag `noCache: true` (`vibe(state, { noCache })`) disables it entirely.
|
|
19
|
+
- New public method `$.clearComponentCache(path?)` invalidates one entry (query string ignored) or all. `@ape-egg/vite-plugin-vibe` calls it on hot update so edits are reflected for freshly-mounted instances; the runtime stays free of dev-server coupling.
|
|
20
|
+
- Unit coverage: `tests/unit/component-cache.test.js` (coalescing, caching, eviction, `noCache`, non-ok/rejected responses not persisted).
|
|
21
|
+
|
|
3
22
|
## [2.0.5] - 2026-06-16
|
|
4
23
|
|
|
5
24
|
### Fixed
|
package/README.md
CHANGED
|
@@ -45,6 +45,7 @@ window.$ = vibe(state, config?, targetSelector?);
|
|
|
45
45
|
|
|
46
46
|
- **`config`** *(object, optional)* — runtime configuration. Currently supported keys:
|
|
47
47
|
- `debug` *(boolean, default `false`)* — colored console logs for every lifecycle phase (parse, hydrate, iterate, mutate, …). Useful for debugging reactivity issues.
|
|
48
|
+
- `noCache` *(boolean, default `false`)* — disable the component template cache (see **Component Template Caching** below). When set, every `<component src>` mount refetches its template.
|
|
48
49
|
|
|
49
50
|
- **`targetSelector`** *(string, optional)* — CSS selector for the root element vibe attaches to. **Defaults to `document.body`.** Vibe parses, hydrates, and observes mutations only inside this root — anything outside (e.g. `<head>`, sibling `<aside>` elements) is ignored. If the selector matches nothing, vibe silently falls back to `document.body`. Pass `'html'` to include `<head>` (e.g. for binding `<title>@[pageTitle]</title>`).
|
|
50
51
|
|
|
@@ -216,6 +217,26 @@ How it works:
|
|
|
216
217
|
|
|
217
218
|
Multiple drop-in blocks on the same page each get their own state bucket. They can read each other's state via global `$['_c0'].count` if they need to coordinate, but in most drop-in cases they're independent.
|
|
218
219
|
|
|
220
|
+
### Component Template Caching
|
|
221
|
+
|
|
222
|
+
Vibe loads a `<component src="...">` by fetching its HTML template. A page often mounts the same component many times (a list of cards, a row of stat bars), and an SPA re-mounts components on every navigation. By default Vibe caches each fetched template by `src`, so:
|
|
223
|
+
|
|
224
|
+
- **Concurrent mounts coalesce.** Twenty `<component src="/components/Bar.html">` in the same render share **one** in-flight request instead of stampeding the network with twenty.
|
|
225
|
+
- **Repeat mounts are free.** Later mounts — including after an SPA navigation away and back — resolve the template from memory with **no network request at all**. This is the one win a browser HTTP cache can't give you: it revalidates per request and never coalesces concurrent ones.
|
|
226
|
+
|
|
227
|
+
The cache is **session-lived and content-busted, never time-based**. In production a component template is immutable for the life of the page (it only changes on redeploy, which is a new session), so there is nothing to invalidate and no staleness window. Per-instance props, slots, and component-local state are unaffected — only the fetched template text is shared; each instance still hydrates independently.
|
|
228
|
+
|
|
229
|
+
Turn it off with `vibe(state, { noCache: true })` — useful if you serve component HTML that genuinely changes within a session.
|
|
230
|
+
|
|
231
|
+
Manual invalidation (rarely needed):
|
|
232
|
+
|
|
233
|
+
```javascript
|
|
234
|
+
$.clearComponentCache('/components/Card.html'); // drop one template (query string ignored)
|
|
235
|
+
$.clearComponentCache(); // drop all cached templates
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
> Dev note: `@ape-egg/vite-plugin-vibe` calls `$.clearComponentCache(path)` on hot update, so editing a component file is reflected immediately for both live and freshly-mounted instances — the runtime itself stays free of any dev-server coupling.
|
|
239
|
+
|
|
219
240
|
### Lifecycle Hooks
|
|
220
241
|
|
|
221
242
|
```javascript
|
package/llms.txt
CHANGED
|
@@ -289,7 +289,7 @@ window.$ = vibe(
|
|
|
289
289
|
|
|
290
290
|
**Parameters:**
|
|
291
291
|
- `initialState` — object containing initial state values
|
|
292
|
-
- `config` — optional object.
|
|
292
|
+
- `config` — optional object. Supports `{ debug: boolean, noCache: boolean }` (`noCache` disables the component template cache — components are otherwise fetched once per `src` and reused across instances/navigation). A third positional argument can pass a target selector (defaults to `body`).
|
|
293
293
|
|
|
294
294
|
**Returns:** Reactive proxy. Assign it to `window.$` so inline event handlers and bindings can find it.
|
|
295
295
|
|
package/package.json
CHANGED
|
@@ -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;
|
|
@@ -432,12 +434,16 @@ const processSingle = (el, debug) => {
|
|
|
432
434
|
}
|
|
433
435
|
});
|
|
434
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
|
+
|
|
435
442
|
// Create AbortController to cancel fetch if element is removed
|
|
436
443
|
const controller = new AbortController();
|
|
437
444
|
pendingFetches.set(el, controller);
|
|
438
445
|
|
|
439
|
-
return
|
|
440
|
-
.then((r) => r.text())
|
|
446
|
+
return fetchComponentTemplate(src, controller.signal)
|
|
441
447
|
.then((html) => {
|
|
442
448
|
// Parse HTML in temporary container to process component scripts
|
|
443
449
|
const temp = createDetached('div');
|
|
@@ -589,7 +595,7 @@ const processSingle = (el, debug) => {
|
|
|
589
595
|
// each row update.
|
|
590
596
|
el._vibeReplacedBy = newWrapper;
|
|
591
597
|
el.replaceWith(newWrapper);
|
|
592
|
-
debugLog(PHASE_FETCH, src, debug);
|
|
598
|
+
debugLog(fromCache ? PHASE_FETCH_CACHED : PHASE_FETCH, src, debug);
|
|
593
599
|
|
|
594
600
|
// MutationObserver handles parsing and hydrating the new content.
|
|
595
601
|
// Branch nodes are registered in the manifest by mountBranch,
|
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/index.js
CHANGED
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
DEHYDRATE_CLASS_OR_ATTR,
|
|
25
25
|
} from './constants.js';
|
|
26
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
|