@ape-egg/vibe 1.9.1 → 1.9.6

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 CHANGED
@@ -1,5 +1,68 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.9.6] - 2026-05-25
4
+
5
+ ### Fixed
6
+
7
+ - **Loop item lost reference identity across mutations** (`runtime/state.js`, `runtime/iterate.js`, `runtime/index.js`, `runtime/loop-scope.js`) — the object a loop handed its `on*` handlers was no longer `=== $.arr[i]` after a `splice`/`push`, so identity-based code (`$.arr.indexOf(item)`, `item === $.arr[i]`) targeted the wrong row — e.g. removing two items by identity in a row would delete the wrong second row. Two compounding causes:
8
+ - **Proxy double-wrapping** (`state.js`) — the deep proxy's `get` handed back a fresh proxy for nested elements, and array methods/assignments wrote that proxy back into the tree; the next read wrapped it *again*, minting a new proxy identity for the same underlying object (and making change detection compare a stored proxy against a raw, never equal). Added a `RAW` symbol so any of our proxies can expose its raw target, plus an `unwrap` helper used in `set` (never store a proxy in the raw tree) and at `createDeepProxy` entry (collapse a proxy that slipped in nested inside an assigned object literal). The cache now always returns the single canonical proxy per object.
9
+ - **Loop var was a diff-snapshot clone** (`iterate.js`, `index.js`, `loop-scope.js`) — iterations render against `extractPlainValue($)` plain clones, which are never reference-identical to the proxy elements the app sees through `$`. The runtime now exposes the live proxy as `manifest.__live`; `resolveLiveArray` re-resolves the loop's array against it and each instance carries a `liveItem` that `stampInstanceScopes` prefers, so `$scope(this,'alias')` handlers (and nested conditionals stamping `__vibeScope`) receive live identity. Diffing still keys off the plain `inst.item`; only the handler-facing value is live. Falls back to the plain item when the live array can't be resolved (no worse than before).
10
+ - Test: `tests/e2e/iteration-item-identity.spec.js` (handler receives the exact state-array element; identity-based removal targets the right item twice in a row across splices)
11
+
12
+ ---
13
+
14
+ ## [1.9.5] - 2026-05-23
15
+
16
+ ### Added
17
+
18
+ - **Loop-scoped `on*` event handlers** (`runtime/loop-scope.js` (new), `runtime/parse.js`, `runtime/iterate.js`, `runtime/conditionals.js`, `runtime/pre-compiled-iterations.js`, `runtime/index.js`) — inside a `<!-- each X as alias -->` loop, an event handler can reference the bare loop variable and receive the **live object** at fire time: `onclick="pick(ability)"` instead of flattening fields into `@[ability.id]`/`@[ability.ticks]`/…. The handler stays a visible native `on*` attribute (no hidden `addEventListener`), preserving the truthful-DOM / `outerHTML`-snapshot model.
19
+ - At parse time a token-aware scanner rewrites a bare alias `ability` → `$scope(this,'ability')`, skipping `@[…]` binding spans, string literals, and member access (`foo.ability`). `$`, `this`, `event`, and bare function names are left untouched, so `$.state` and plain native handlers are never affected; `@[…]` handlers keep their existing stringifying behavior (backward compatible).
20
+ - A global `$scope(el, name)` resolver (installed by the runtime) walks up the DOM to the nearest `__vibeScope` stamp and returns the live item — resolution is by **identity**, not array position, so it works for derived-source loops (`.filter` / `.map` / `(x || [])`) and survives keyed reorders.
21
+ - Resolves recursively through **arbitrary-depth, arbitrary-combination `each` / `if` nesting** (e.g. `each > if > each > if`), including wrapper-less loops and handlers that first mount via a later state change (the update path). Achieved by persisting the full *accumulated* enclosing alias set on each iteration/conditional node (`meta.scopeAliases`) and threading it back into the render-time re-parse, and by stamping each instance/branch with the accumulated in-scope loop vars.
22
+ - The Rust AOT compiler needs no changes: iterations carrying loop-scoped handlers bypass the manifest `batchFn` (which predates the rewrite) and use the runtime path that reads the rewritten template, so behavior is identical in runtime and compiled modes.
23
+ - Test: `tests/unit/loop-scope.test.js` (rewriter, resolver, stamper) and `tests/e2e/loop-scoped-handlers.spec.js` (live object passed, derived source, nested aliases, keyed reorder, conditional-in-loop, deep `each/if/each/if`, wrapper-less loops, update-path mount, `$`/native handlers unaffected — runtime + compiled)
24
+
25
+ ---
26
+
27
+ ## [1.9.4] - 2026-05-22
28
+
29
+ ### Fixed
30
+
31
+ - **Binding-less `<!-- each -->` rendered only one item** (`runtime/iterate.js`) — an iteration whose repeated template contained no reactive binding referencing the loop scope (e.g. `<coin></coin>`) was never expanded: the runtime left the authored template untouched and rendered exactly one copy regardless of array length, and one copy even for an empty array. Adding any loop-scope binding (`@[i]`, `@[item.foo]`, …) made it work, which is why it stayed hidden — every working `each` happened to interpolate the loop var.
32
+ - Root cause was `renderIteration`'s fallback "already rendered" check, which concluded a region was rendered when no element between the comments contained `@[...]` syntax. That heuristic conflates *already-rendered output* (no bindings left to hydrate) with *a binding-less authored template* (never had bindings to begin with), so binding-less templates were always skipped.
33
+ - The fallback now keys off `managedNodes` — Vibe's authoritative DOM-ownership signal (a node lands there only when a render path produced it) — instead of the absence of `@[...]`. Detection is binding-agnostic, so a binding-less template expands to N clones (and 0 for an empty array) in both runtime and compiled modes, while the original "comment markers replaced by component re-processing" case it guarded still bails correctly.
34
+ - Test: `tests/e2e/binding-less-iteration.spec.js` (binding-less body renders N, empty renders 0, bound body still renders N, reactivity grows both lists)
35
+
36
+ ---
37
+
38
+ ## [1.9.3] - 2026-05-18
39
+
40
+ ### Fixed
41
+
42
+ - **Compiler stack overflow on self-referencing components** (`compiler/src/compiler/compile.rs`, `compiler/src/parser/html.rs`) — `bun vibe:compile` aborted with `fatal runtime error: stack overflow` when any component referenced itself (e.g. `recursive-tree-node.html` containing a `<component src="/components/recursive-tree-node.html">` for runtime-bounded tree rendering). Two compounding bugs: `fetch_component_recursive` had no cycle guard so direct or transitive cycles (A → A, A → B → A) recursed until the stack overflowed; and even if recursion had been bounded, the cached content still carried the self-reference so `inline_component_elements`'s `src=` regex would keep re-expanding it forever, growing the document on every loop iteration.
43
+ - `fetch_component_recursive` now takes a `visiting: &mut HashSet<String>` representing the live fetch chain. The src is inserted before recursing into nested components and removed after, so the set tracks "what's currently being resolved," not "everything ever fetched." When a recursive call sees its own src already in `visiting`, it returns `None` and the caller leaves the `<component src>` tag intact for runtime to handle.
44
+ - Before caching, any `<component src="X">` in the resolved content whose `X` points back into the chain (or to the component itself) has its `src=` renamed to `data-vibe-recursive-src=` by a new module-level helper `escape_recursive_src`. The inliner's regex doesn't match the renamed attribute, so it stops re-expanding. `process_html_with_cache` restores the attribute to plain `src=` at the very end of compilation so the runtime fetches the recursion normally — escape is a compile-time-only mechanism.
45
+
46
+ ---
47
+
48
+ ## [1.9.2] - 2026-05-08
49
+
50
+ ### Fixed
51
+
52
+ - **Batch iteration path divergences from clone+hydrate** (`runtime/iterate.js`) — `renderBatch`'s template-literal compiler now produces DOM identical to the clone path for every supported binding form. Previously, simple-template iterations (single root, no nested directives, no `<component src>`) routed through batch but four binding shapes diverged silently from clone:
53
+ - **`this.X` rewriting** — pre-resolved at compile time using the iteration's anchor element. Previously `this.X` was inlined literally into the `new Function()` body where `this` is `globalThis`, so component-scoped expressions evaluated to `undefined`.
54
+ - **Boolean-coerced attributes** — emit a conditional template-literal segment so the attribute is *absent* when the binding is falsy, matching `hydrate.js`'s remove-when-falsy semantics. Previously batch always emitted `attr="${expr}"`, leaving `attr="false"` in the DOM when clone would have removed it.
55
+ - **DOM properties** (`value` / `checked` / `selected`) — collected at compile time into a sidecar list with a transient `data-vibe-batch="…"` marker on the element, then applied in a small post-stamp loop that mirrors `hydrate.js`'s DOM-property branch (sets the property AND the attribute, removes attribute when value is `undefined`/`null`). The attribute alone isn't enough — `<option selected="false">` is still selected by HTML semantics, only the property write makes it correct.
56
+ - **Name bindings** (`<el @[expr]>`) — emit a conditional template-literal segment that produces ` resolvedName=""` when truthy / nothing when falsy, with HTML-lowercase fallback so `<icon @[attrName]>` still resolves the camelCase state key after the browser parses it as `@[attrname]`.
57
+ - The compiled batch function now also receives `$` as its last parameter so component-scoped expressions like `$['_c0'].chosen` (produced by the `this.X` rewrite) resolve against live state.
58
+ - New equivalence harness: `tests/e2e/batch-vs-clone-equivalence.spec.js` flips a `__vibeForceClonePath` debug flag and compares region-by-region across both paths. 13 regions cover all four gaps plus regression cases for the binding forms that already worked.
59
+
60
+ - **`this.X` inside iteration rows didn't resolve in the clone path either** (`runtime/utils.js`, `runtime/iterate.js`) — `findComponentIdForElement` walked from the row's cloned element up to the detached `parseContainer` and stopped, returning `null`. Hydrate then evaluated `this.X` against `globalThis`, producing `undefined`. Added a fallback: when `closest('[data-vibe-component-id]')` finds nothing, walk to the root and check a `_vibeComponentId` expando. `initializeBlock` now stashes the iteration's owning component id on the parseContainer (set from `findComponentIdForElement(startComment.parentElement)` in `renderIteration` and `buildInstance`), so detached hydrate sees the right component context. Latent bug that didn't surface until the equivalence harness above exercised `@[item === this.chosen]` and `@[item.toUpperCase() === this.chosen.toUpperCase()]` inside iteration rows.
61
+
62
+ - **Combined default + named imports in component scripts** (`runtime/component.js`) — `<script type="module">` blocks that mix default and named imports (e.g. `import component, { foo } from '...'`) are now rewritten to two separate `await import()` calls (one for the default binding, one for the named bindings). Previously only one form was supported per import statement.
63
+
64
+ ---
65
+
3
66
  ## [1.9.1] - 2026-04-29
4
67
 
5
68
  ### Added
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Vibe
2
2
 
3
- **Version 1.9.1 (Alpha)** — A runtime-first reactive framework with optional compilation.
3
+ **Version 1.9.2 (Beta)** — A runtime-first reactive framework with optional compilation.
4
4
 
5
5
  No virtual DOM. No build step required. Just modern JavaScript. When you need production optimizations, add the optional Rust-based compiler.
6
6
 
@@ -35,14 +35,31 @@ The core reactive runtime. Works directly in the browser without any build tools
35
35
  </html>
36
36
  ```
37
37
 
38
+ ### The `vibe()` Signature
39
+
40
+ ```js
41
+ window.$ = vibe(state, config?, targetSelector?);
42
+ ```
43
+
44
+ - **`state`** *(object, required)* — initial reactive state. Becomes `window.$`. Mutate freely (`$.count++`, `$.user.name = 'Alice'`); deep mutations trigger updates automatically. Methods on the object are preserved.
45
+
46
+ - **`config`** *(object, optional)* — runtime configuration. Currently supported keys:
47
+ - `debug` *(boolean, default `false`)* — colored console logs for every lifecycle phase (parse, hydrate, iterate, mutate, …). Useful for debugging reactivity issues.
48
+
49
+ - **`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
+ ```js
52
+ window.$ = vibe({ count: 0 }, { debug: true }, '#app');
53
+ ```
54
+
38
55
  ### Prevent FOUC
39
56
 
40
57
  To prevent a flash of unstyled content while Vibe hydrates:
41
58
 
42
59
  1. Include `vibe.css` in your HTML
43
- 2. Add the `vibe` attribute to an element (typically `<body>`) to mark the hydration boundary
60
+ 2. Add the `vibe-fouc` attribute (or `class="vibe-fouc"`) to an element typically `<body>`
44
61
 
45
- The CSS targets `[vibe]` and hides it until hydration completes. Once Vibe finishes processing, it removes the attribute, revealing your content.
62
+ `vibe.css` hides `[vibe-fouc]` and `.vibe-fouc` until hydration completes; Vibe removes the attribute/class once it's done.
46
63
 
47
64
  ### Reactive Bindings
48
65
 
@@ -110,14 +127,23 @@ Nested iteration with dot paths:
110
127
 
111
128
  ### Components
112
129
 
113
- Runtime component loading with props and slots:
130
+ Runtime component loading with props and slots. Slot content (between the tags) replaces `<slot></slot>` inside the component template:
114
131
 
115
132
  ```html
133
+ <!-- /components/card.html -->
134
+ <div class="card">
135
+ <h3>@[title]</h3>
136
+ <slot></slot>
137
+ </div>
138
+
139
+ <!-- usage -->
116
140
  <component src="/components/card.html" title="@[pageTitle]" theme="dark">
117
141
  <p>Content passed as slot</p>
118
142
  </component>
119
143
  ```
120
144
 
145
+ `<div class="component" src="...">` is an equivalent alternative to `<component src="...">` for cases where standard HTML elements are required (validation, accessibility tooling).
146
+
121
147
  Props can be reactive bindings (`title="@[pageTitle]"`), static literals (`theme="dark"`), or live objects/arrays passed through iteration scope (`<component src="/card.html" card="@[card]">` inside `<!-- each cards as card -->`). Non-primitive props are stashed in an internal registry so the child template can dot/iterate into them (`@[card.name]`, `<!-- each card.abilities as a -->`).
122
148
 
123
149
  ### Component-Local State
@@ -146,6 +172,30 @@ How it works:
146
172
 
147
173
  Multi-segment paths (`@[this.user.profile.name]`), conditionals (`<!-- if this.editing -->`), and iterations (`<!-- each this.items as item -->`) all resolve against the component's bucket. Global `$` and component `this.X` coexist freely.
148
174
 
175
+ ### Drop-In Components (no `vibe()` needed)
176
+
177
+ `component()` auto-boots the runtime. You can drop a self-contained reactive block into any HTML page — no top-level `vibe(...)` call, no global state setup, no build step. Import directly from a CDN and the runtime wires itself up:
178
+
179
+ ```html
180
+ <component>
181
+ <script type="module">
182
+ import component from 'https://esm.sh/@ape-egg/vibe/component.js';
183
+ component({ count: 0 });
184
+ </script>
185
+ <button onclick="this.count--">-</button>
186
+ <span>Count: <strong>@[this.count]</strong></span>
187
+ <button onclick="this.count++">+</button>
188
+ </component>
189
+ ```
190
+
191
+ How it works:
192
+
193
+ 1. `component({...})` claims the nearest unprocessed `<component>` (or `<div class="component">`) wrapper, registers state at `$[id]`, and tags the wrapper with `data-vibe-component-id`
194
+ 2. Internally it calls `ensureBoot()` (from `@ape-egg/vibe/boot`), which initializes `window.$`, parses the DOM, hydrates bindings, and starts the MutationObserver — exactly once, even if multiple `<component>` blocks call `component()`
195
+ 3. From there, `@[this.X]`, `onclick="this.fn()"`, and `<!-- if this.X -->` work as documented
196
+
197
+ 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.
198
+
149
199
  ### Lifecycle Hooks
150
200
 
151
201
  ```javascript
@@ -168,112 +218,12 @@ Skip reactive processing for an element:
168
218
  <code vibe-dehydrate>@[this] displays literally</code>
169
219
  ```
170
220
 
171
- ### Reserved Words & Gotchas
172
-
173
- Vibe uses specific patterns and keywords that have special meaning. Avoid using these for other purposes to prevent unexpected behavior:
174
-
175
- #### Classes & Attributes
176
-
177
- - **`vibe-fouc`** — Class or attribute for FOUC (Flash of Unstyled Content) prevention. Automatically removed after hydration completes.
178
-
179
- ```html
180
- <body vibe-fouc>
181
- <!-- or class="vibe-fouc" -->
182
- </body>
183
- ```
184
-
185
- - **`vibe-dehydrate`** — Class or attribute to skip reactive processing. Useful for displaying literal `@[...]` syntax in documentation.
186
- ```html
187
- <code vibe-dehydrate>@[variable]</code>
188
- <!-- or class="vibe-dehydrate" -->
189
- ```
190
-
191
- #### Element Names & Classes
192
-
193
- - **`<component>`** — Element name for component system. Used with `src` attribute for runtime component loading, or as a wrapper for inlined components.
194
-
195
- ```html
196
- <component src="/path/to/component.html"></component>
197
- ```
198
-
199
- - **`class="component"`** — Alternative syntax for components using standard HTML elements. Useful for HTML validation or accessibility.
200
-
201
- ```html
202
- <div class="component" src="/path/to/component.html"></div>
203
- ```
204
-
205
- - **`<slot>`** — Element name for component content injection. Gets replaced with content passed between component tags.
206
-
207
- ```html
208
- <!-- In component file -->
209
- <slot></slot>
210
-
211
- <!-- Usage -->
212
- <component src="...">
213
- <p>This replaces the slot</p>
214
- </component>
215
- ```
216
-
217
- #### Comment Syntax
218
-
219
- - **`<!-- each -->`** / **`<!-- /each -->`** — Iteration block markers.
220
-
221
- ```html
222
- <!-- each items as item -->
223
- <!-- /each -->
224
- ```
225
-
226
- - **`<!-- if -->`** / **`<!-- else -->`** / **`<!-- /if -->`** — Conditional block markers.
227
- ```html
228
- <!-- if condition -->
229
- <!-- else -->
230
- <!-- /if -->
231
- ```
232
-
233
- #### Binding Syntax
234
-
235
- - **`@[...]`** — Reactive binding syntax. Reserved for state references.
236
- ```html
237
- <div>@[variable]</div>
238
- ```
239
-
240
- #### Global Properties
241
-
242
- - **`window.$`** — Global reactive state object. All reactive data should be accessed through this.
243
-
244
- ```javascript
245
- window.$ = state({ count: 0 });
246
- $.count++; // Triggers reactive updates
247
- ```
248
-
249
- - **`window.__vibeManifest`** — Internal manifest data. Used by the compiler for optimization. Don't modify.
250
-
251
- - **`window.__vibeCompiling`** — Internal flag. Set to `true` when running in compiler context.
252
-
253
- #### Data Attributes
254
-
255
- - **`data-vibe-component-id`** — Internal attribute for component scoping. Automatically added to component elements. Don't use manually.
256
-
257
- #### Event Names
258
-
259
- - **`vibe:ready`** — Custom event fired when Vibe completes initial hydration.
260
- ```javascript
261
- document.addEventListener('vibe:ready', () => {
262
- console.info('Vibe is ready');
263
- });
264
- ```
265
-
266
- #### Special Attribute Meanings
267
-
268
- - **`src`** on **`<component>`** or **`<div class="component">`** — Triggers runtime component fetching. Components without `src` are treated as inline wrappers.
221
+ ### Internal Names (don't collide)
269
222
 
270
- ```html
271
- <!-- Both work the same way -->
272
- <component src="/components/card.html"></component>
273
- <div class="component" src="/components/card.html"></div>
274
- ```
223
+ These are used by the runtime — don't repurpose them in your code:
275
224
 
276
- - **`dehydrate`** Attribute or class name to skip reactive processing (alias for `vibe-dehydrate`).
225
+ - `window.__vibeManifest`, `window.__vibeCompiling`, `window.__vibeComponents`, `window.__vibeIterProps` internal registries
226
+ - `data-vibe-component-id`, `data-vibe-iter-prop` — element-level bookkeeping attributes (set automatically)
277
227
 
278
228
  ### Deep Reactivity
279
229
 
@@ -586,11 +536,12 @@ Every time `matchesKey` can't prove an expression is unrelated to a state change
586
536
 
587
537
  The browser lowercases all attribute names during HTML parsing. `<page @[pageName]>` becomes `@[pagename]` in the DOM. Every value hydrated into an attribute NAME position loses its case. `@[myCoolPage]` → DOM stores `@[mycoolpage]`, causing comparison mismatches against camelCase state keys.
588
538
 
589
- Handled today by case-insensitive fallbacks in two places:
590
- - `runtime/hydrate.js:~32` — case-insensitive state key lookup when evaluating name bindings
539
+ Handled today by case-insensitive fallbacks in three places:
540
+ - `runtime/hydrate.js:~32` — case-insensitive state key lookup when evaluating name bindings (clone path)
591
541
  - `runtime/affected.js:~235` — case-insensitive `matchesKey` wrapper for name bindings
542
+ - `runtime/iterate.js` — case-insensitive lookup against `stateKeys` inside `compileBatchFn`'s name-binding emission (batch path)
592
543
 
593
- Both are workarounds for HTML's behavior. A cleaner architecture would centralize the case-insensitive state key resolution into one helper, or normalize the name binding expression to canonical state-key case at first evaluation and cache it on the tree node.
544
+ All three are workarounds for HTML's behavior. A cleaner architecture would centralize the case-insensitive state key resolution into one helper, or normalize the name binding expression to canonical state-key case at first evaluation and cache it on the tree node.
594
545
 
595
546
  ### Related: value hydration into attribute values is case-preserving
596
547
 
package/ROADMAP.md CHANGED
@@ -6,7 +6,8 @@ Feature proposals and improvements for Vibe's runtime-first reactive framework.
6
6
 
7
7
  ## Proposed: Manual Hydration API
8
8
 
9
- **Status**: Proposal
9
+ **Status**: Superseded by `$.reconcile()` (shipped in 1.9.0). See `runtime/reconcile.js` and the changelog entry. The reconciliation API solves the wholesale-replacement use cases listed below by walking the new source HTML against the live tree with a two-pointer aligner, preserving DOM identity, focus, and selection. Vibe-managed regions (iterations, conditionals, components, `<slot>` pairs) stay opaque. The shapes proposed below (`vibe.hydrate(el)` / `vibe.evaluate(html, state)` / `vibe.setHTML(el, html)`) were not implemented; `$.reconcile(target, source)` covers the same scenarios with a different ergonomic.
10
+ **Original status**: Proposal
10
11
  **Priority**: Medium
11
12
  **Category**: Core Runtime
12
13
 
@@ -265,22 +266,15 @@ Likely related to:
265
266
 
266
267
  #### Resolution Path
267
268
 
268
- **Phase 1 (Current)**: Document and work around
269
- - Remove `<tutorial>` wrapper from demos
270
- - Add note in CLAUDE.md about limitation
271
- - Tests validate core reactivity works correctly
269
+ **Recommended workaround today**: use `$.reconcile(target, source)` (shipped 1.9.0) instead of multiple raw `innerHTML` replacements. Reconcile is the supported path for wholesale HTML swaps and preserves vibe-managed regions cleanly. The original `innerHTML`-based reproduction is an unsupported pattern.
272
270
 
273
- **Phase 2+**: Consider fixing if real-world need emerges
274
- - Deep investigation into branch reference lifecycle
275
- - Possibly: deep clone branches instead of sharing reference
276
- - Possibly: rebuild conditional metadata on each innerHTML replacement
277
- - Possibly: manual hydration API (see "Manual Hydration API" proposal above)
271
+ **If the bug needs a proper fix** (not yet scheduled): deep investigation into branch reference lifecycle, deep-clone the branches metadata instead of sharing references, or rebuild conditional metadata on each replacement.
278
272
 
279
273
  This is acceptable technical debt since:
280
274
  1. Edge case not representative of normal usage
281
275
  2. Core reactivity (the 99% case) works correctly
282
- 3. Can be addressed when/if users report needing this pattern
283
- 4. Phase 1 goals (iteration + conditionals) are met
276
+ 3. `$.reconcile` covers the wholesale-replacement scenarios that motivated this report
277
+ 4. Can be addressed if/when a real-world need to use raw `innerHTML` replacement surfaces
284
278
 
285
279
  ---
286
280
 
@@ -173,7 +173,7 @@ while (walker.nextNode()) {
173
173
  - `depth = 1` when we exit nested `section.items` (line 98)
174
174
  - `depth = 0` when we exit `menuSections` (line 483) - **this is our match**
175
175
 
176
- **Files Modified**: `pre-compiled-manifest.rs`
176
+ **Files Modified**: `pre-compiled-manifest.js`
177
177
 
178
178
  ---
179
179
 
@@ -86,6 +86,22 @@ pub fn should_skip_path(path: &Path, name: &str) -> bool {
86
86
  false
87
87
  }
88
88
 
89
+ /// Rename `src="{component_src}"` to `data-vibe-recursive-src="{component_src}"`
90
+ /// on `<component>` / `<div class="component">` tags. Used during cache build
91
+ /// to neutralize cyclic refs before they reach `inline_component_elements` —
92
+ /// the inliner's `src=` regex won't match the renamed attribute, so it stops
93
+ /// re-expanding. `process_html_with_cache` restores the attribute to plain
94
+ /// `src=` at the end of compilation so the runtime fetches it normally.
95
+ fn escape_recursive_src(content: &str, component_src: &str) -> String {
96
+ let pattern = format!(
97
+ r#"(<(?:component|div)\s+[^>]*\b)src="{}""#,
98
+ regex::escape(component_src),
99
+ );
100
+ let re = Regex::new(&pattern).unwrap();
101
+ re.replace_all(content, format!(r#"$1data-vibe-recursive-src="{}""#, component_src))
102
+ .into_owned()
103
+ }
104
+
89
105
  /// Remove FOUC prevention class and/or attribute from compiled HTML
90
106
  /// Since HTML is pre-rendered, there's no need for FOUC prevention
91
107
  fn remove_fouc_prevention(html: String) -> String {
@@ -1251,7 +1267,8 @@ impl Compiler {
1251
1267
  // Extract and fetch components recursively
1252
1268
  let components = self.extract_component_srcs(&transformed);
1253
1269
  for component_src in components {
1254
- self.fetch_component_recursive(&component_src, parser);
1270
+ let mut visiting = std::collections::HashSet::new();
1271
+ self.fetch_component_recursive(&component_src, parser, &mut visiting);
1255
1272
  }
1256
1273
  }
1257
1274
 
@@ -1288,16 +1305,31 @@ impl Compiler {
1288
1305
  // Extract and fetch components recursively
1289
1306
  let components = self.extract_component_srcs(&transformed);
1290
1307
  for component_src in components {
1291
- self.fetch_component_recursive(&component_src, parser);
1308
+ let mut visiting = std::collections::HashSet::new();
1309
+ self.fetch_component_recursive(&component_src, parser, &mut visiting);
1292
1310
  }
1293
1311
  }
1294
1312
 
1295
1313
  Ok(())
1296
1314
  }
1297
1315
 
1298
- /// Recursively fetch a component and its nested components (for inlining)
1299
- /// Returns the fully inlined content (all nested components resolved)
1300
- fn fetch_component_recursive(&mut self, component_src: &str, parser: &HtmlParser) -> Option<String> {
1316
+ /// Recursively fetch a component and its nested components (for inlining).
1317
+ /// Returns the fully inlined content (all nested components resolved).
1318
+ ///
1319
+ /// `visiting` tracks the chain of components currently being fetched. A
1320
+ /// `<component src>` that points back into the chain is a cycle (direct
1321
+ /// self-reference, or A→B→A) — those are valid runtime patterns (recursive
1322
+ /// tree components bounded by data depth) but would expand infinitely at
1323
+ /// compile time. Cycle references are escaped to `data-vibe-recursive-src`
1324
+ /// in the cached content so the inliner's `src=` regex won't keep matching
1325
+ /// them; `process_html_with_cache` restores the attribute name at the end
1326
+ /// of compilation so the runtime sees a normal `<component src>` tag.
1327
+ fn fetch_component_recursive(
1328
+ &mut self,
1329
+ component_src: &str,
1330
+ parser: &HtmlParser,
1331
+ visiting: &mut std::collections::HashSet<String>,
1332
+ ) -> Option<String> {
1301
1333
  // Normalize path: ensure it starts with / (unless it's a URL)
1302
1334
  let normalized_src = if component_src.starts_with("http://") || component_src.starts_with("https://") {
1303
1335
  component_src.to_string()
@@ -1315,6 +1347,13 @@ impl Compiler {
1315
1347
  return Some(cached.clone());
1316
1348
  }
1317
1349
 
1350
+ // Cycle: this component is already higher in the fetch chain. Don't
1351
+ // recurse — the caller will leave the `<component src>` tag as-is and
1352
+ // the runtime handles the recursion with real data bounds.
1353
+ if visiting.contains(&normalized_src) {
1354
+ return None;
1355
+ }
1356
+
1318
1357
  // Fetch external or read internal component (raw content)
1319
1358
  let content = if normalized_src.starts_with("http://") || normalized_src.starts_with("https://") {
1320
1359
  match self.fetch_external_component_raw(&normalized_src) {
@@ -1355,15 +1394,36 @@ impl Compiler {
1355
1394
  &self.config.components,
1356
1395
  );
1357
1396
 
1397
+ // Mark this component as in-flight before recursing into its nested
1398
+ // refs. Cycles (direct self-reference or A→B→A chains) are detected
1399
+ // by the visiting check at the top of this function.
1400
+ visiting.insert(normalized_src.clone());
1401
+
1358
1402
  // Recursively fetch and inline all nested components
1359
1403
  let nested = self.extract_component_srcs(&transformed);
1360
1404
  for nested_src in nested {
1361
- if let Some(nested_content) = self.fetch_component_recursive(&nested_src, parser) {
1405
+ if let Some(nested_content) = self.fetch_component_recursive(&nested_src, parser, visiting) {
1362
1406
  // Inline this nested component into the current component
1363
1407
  transformed = parser.inline_single_component(&transformed, &nested_src, &nested_content);
1364
1408
  }
1365
1409
  }
1366
1410
 
1411
+ visiting.remove(&normalized_src);
1412
+
1413
+ // Any `<component src>` still in `transformed` whose target is in the
1414
+ // visiting chain (a cycle) didn't get inlined. Escape its `src=` so
1415
+ // `inline_component_elements` won't keep re-expanding it forever when
1416
+ // a page inlines this cached content. `process_html_with_cache`
1417
+ // restores the attribute to plain `src=` at the end of compilation so
1418
+ // the runtime fetches it normally.
1419
+ for ancestor in visiting.iter() {
1420
+ transformed = escape_recursive_src(&transformed, ancestor);
1421
+ }
1422
+ // Also escape direct self-references — a component pointing at itself
1423
+ // never made it into `visiting` (the cycle check short-circuited
1424
+ // before insertion), so handle it explicitly.
1425
+ transformed = escape_recursive_src(&transformed, &normalized_src);
1426
+
1367
1427
  // Cache the fully resolved content
1368
1428
  self.component_cache.insert(normalized_src.clone(), transformed.clone());
1369
1429
 
@@ -154,7 +154,13 @@ impl HtmlParser {
154
154
  result = transform_custom_tags_to_divs(&result, reserved_elements);
155
155
  }
156
156
 
157
- // Step 4: Restore DOCTYPE if it was present
157
+ // Step 4: Restore cyclic `<component src>` references that were
158
+ // escaped during cache build (see compile.rs::escape_recursive_src).
159
+ // These are runtime-handled component references that the inliner
160
+ // had to skip; the runtime needs them as real `src=` attributes.
161
+ result = result.replace("data-vibe-recursive-src=", "src=");
162
+
163
+ // Step 5: Restore DOCTYPE if it was present
158
164
  if let Some(dt) = doctype {
159
165
  // Remove any existing DOCTYPE that might have been left behind
160
166
  result = doctype_re.replace(&result, "").to_string();
package/llms.txt CHANGED
@@ -183,6 +183,31 @@ How it works:
183
183
 
184
184
  Multi-segment paths, conditionals (`<!-- if this.editing -->`), and iterations (`<!-- each this.items as item -->`) all work. Global `$` and component-local `this.X` coexist within the same template.
185
185
 
186
+ ## Drop-In Components (Standalone Usage)
187
+
188
+ `component()` auto-boots the runtime, so a `<component>` block is a fully self-contained reactive unit. Drop it into any HTML page — no top-level `vibe(...)` call, no build step, no global state setup. Import directly from a CDN:
189
+
190
+ ```html
191
+ <component>
192
+ <script type="module">
193
+ import component from 'https://esm.sh/@ape-egg/vibe/component.js';
194
+ component({ count: 0 });
195
+ </script>
196
+ <button onclick="this.count--">-</button>
197
+ <span>Count: @[this.count]</span>
198
+ <button onclick="this.count++">+</button>
199
+ </component>
200
+ ```
201
+
202
+ Mechanism:
203
+ 1. `component({...})` claims the nearest unprocessed `<component>` (or `<div class="component">`) wrapper
204
+ 2. It calls `ensureBoot()` from `@ape-egg/vibe/boot`, which initializes `window.$`, parses the DOM, hydrates bindings, and starts the MutationObserver — exactly once per page, even if multiple drop-in blocks call `component()`
205
+ 3. `@[this.X]`, `onclick="this.fn()"`, `<!-- if this.X -->`, etc. resolve against that block's bucket
206
+
207
+ Multiple drop-in blocks on the same page each get their own state and run independently. They can still read each other's state via global `$['<id>']` if coordination is needed.
208
+
209
+ This makes vibe usable as a "sprinkle of reactivity" library: paste a snippet into a static site, blog post, CMS-rendered page, or third-party HTML, and it just works.
210
+
186
211
  ## Lifecycle Hooks
187
212
 
188
213
  ```javascript
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ape-egg/vibe",
3
- "version": "1.9.1",
3
+ "version": "1.9.6",
4
4
  "type": "module",
5
5
  "description": "Runtime-first reactivity with optional compiler",
6
6
  "main": "index.js",