@ape-egg/vibe 1.9.0 → 1.9.5

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,76 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.9.5] - 2026-05-23
4
+
5
+ ### Added
6
+
7
+ - **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.
8
+ - 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).
9
+ - 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.
10
+ - 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.
11
+ - 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.
12
+ - 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)
13
+
14
+ ---
15
+
16
+ ## [1.9.4] - 2026-05-22
17
+
18
+ ### Fixed
19
+
20
+ - **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.
21
+ - 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.
22
+ - 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.
23
+ - 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)
24
+
25
+ ---
26
+
27
+ ## [1.9.3] - 2026-05-18
28
+
29
+ ### Fixed
30
+
31
+ - **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.
32
+ - `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.
33
+ - 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.
34
+
35
+ ---
36
+
37
+ ## [1.9.2] - 2026-05-08
38
+
39
+ ### Fixed
40
+
41
+ - **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:
42
+ - **`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`.
43
+ - **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.
44
+ - **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.
45
+ - **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]`.
46
+ - 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.
47
+ - 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.
48
+
49
+ - **`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.
50
+
51
+ - **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.
52
+
53
+ ---
54
+
55
+ ## [1.9.1] - 2026-04-29
56
+
57
+ ### Added
58
+
59
+ - **Non-primitive iteration props** (`runtime/iterate.js`) — `<component src>` props inside `<!-- each -->` can now receive objects and arrays. Primitives stringify as before; non-primitives snapshot into `window.__vibeIterProps` and the attribute becomes `@[window.__vibeIterProps._pN]`, so child templates can dot/iterate into the value (`@[card.name]`, `<!-- each card.abilities as a -->`). Slots are released on wrapper detach via `releaseOrphanedIterationProps`.
60
+ - Test: `tests/e2e/component-in-iteration-rich-props.spec.js`
61
+ - **Idempotent hydrate writes** (`runtime/hydrate.js`) — attribute, boolean-attribute, and text writes now compare current vs. new value and skip the write when unchanged. Eliminates needless paints and CSS-transition jitter on every re-hydrate (e.g. 4 Hz client-clock tick re-running unrelated bindings).
62
+ - Test: `tests/e2e/idempotent-hydrate.spec.js`
63
+ - **Multi-segment `this.X.Y` rewriting** (`runtime/component.js`, `runtime/constants.js`) — `@[this.user.name]`, `@[this.x + 1]`, etc. inside a component now rewrite the whole `this.X` head consistently. Centralized via `THIS_PROP_REGEX` / `STATE_THIS_PROP_REGEX` so `parse.js`, `utils.js`, and `component.js` share one definition.
64
+ - **`$.renderComponent` — pure-render path for surgical HMR** (`runtime/component.js`, `runtime/index.js`) — extracted prop substitution + slot inlining into a non-executing helper. The vite plugin uses this with `$.reconcile` to update components in place when only the template changes; scripts are not re-run, so registered component state survives.
65
+ - **`./boot` package export** (`package.json`) — `import { boot } from '@ape-egg/vibe/boot'` is now usable from consumers.
66
+
67
+ ### Fixed
68
+
69
+ - **Iteration-prop registry slots freed prematurely on wrapper swap** (`runtime/component.js`, `runtime/iterate.js`) — `processSingle.finalize` and the plugin's `remount` now transfer `_vibeIterPropIds` from the soon-to-be-detached element to its replacement. Without the transfer, `releaseOrphanedIterationProps` would clear registry entries that the new wrapper's bindings still reference, rendering them as `undefined`.
70
+ - **Batch-render path stringified `<component src>` props** (`runtime/iterate.js`) — `canUseBatchRender` now skips templates containing `<component src>` so iteration items with rich props go through the clone+hydrate path that resolves them via the registry instead of through template-literal interpolation that coerces objects to `[object Object]`.
71
+
72
+ ---
73
+
3
74
  ## [1.9.0] - 2026-04-18
4
75
 
5
76
  ### Added
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Vibe
2
2
 
3
- **Version 1.6.0 (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
 
@@ -25,7 +25,7 @@ The core reactive runtime. Works directly in the browser without any build tools
25
25
  <script type="module">
26
26
  import vibe from './node_modules/@ape-egg/vibe/index.js';
27
27
 
28
- vibe({ name: 'World', count: 0 });
28
+ window.$ = vibe({ name: 'World', count: 0 });
29
29
  </script>
30
30
  </head>
31
31
  <body vibe-fouc>
@@ -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,128 +127,103 @@ 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
 
121
- ### Dehydrate
122
-
123
- Skip reactive processing for an element:
145
+ `<div class="component" src="...">` is an equivalent alternative to `<component src="...">` for cases where standard HTML elements are required (validation, accessibility tooling).
124
146
 
125
- ```html
126
- <code vibe-dehydrate>@[this] displays literally</code>
127
- ```
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 -->`).
128
148
 
129
- ### Reserved Words & Gotchas
149
+ ### Component-Local State
130
150
 
131
- Vibe uses specific patterns and keywords that have special meaning. Avoid using these for other purposes to prevent unexpected behavior:
132
-
133
- #### Classes & Attributes
134
-
135
- - **`vibe-fouc`** — Class or attribute for FOUC (Flash of Unstyled Content) prevention. Automatically removed after hydration completes.
136
-
137
- ```html
138
- <body vibe-fouc>
139
- <!-- or class="vibe-fouc" -->
140
- </body>
141
- ```
151
+ For state scoped to a single component, import `component` from `@ape-egg/vibe/component` and call it from a `<script type="module">` inside the component file:
142
152
 
143
- - **`vibe-dehydrate`** — Class or attribute to skip reactive processing. Useful for displaying literal `@[...]` syntax in documentation.
144
- ```html
145
- <code vibe-dehydrate>@[variable]</code>
146
- <!-- or class="vibe-dehydrate" -->
147
- ```
148
-
149
- #### Element Names & Classes
150
-
151
- - **`<component>`** — Element name for component system. Used with `src` attribute for runtime component loading, or as a wrapper for inlined components.
152
-
153
- ```html
154
- <component src="/path/to/component.html"></component>
155
- ```
156
-
157
- - **`class="component"`** — Alternative syntax for components using standard HTML elements. Useful for HTML validation or accessibility.
158
-
159
- ```html
160
- <div class="component" src="/path/to/component.html"></div>
161
- ```
162
-
163
- - **`<slot>`** — Element name for component content injection. Gets replaced with content passed between component tags.
153
+ ```html
154
+ <!-- /components/Counter.html -->
155
+ <script type="module">
156
+ import component from '@ape-egg/vibe/component';
157
+ component({
158
+ count: 0,
159
+ increment() { this.count++; }
160
+ });
161
+ </script>
164
162
 
165
- ```html
166
- <!-- In component file -->
167
- <slot></slot>
163
+ <button onclick="this.increment()">Clicked @[this.count] times</button>
164
+ ```
168
165
 
169
- <!-- Usage -->
170
- <component src="...">
171
- <p>This replaces the slot</p>
172
- </component>
173
- ```
166
+ How it works:
174
167
 
175
- #### Comment Syntax
168
+ 1. `component({...})` generates a unique id (e.g. `_c0`, `_c1`) and registers the state at `$[id]`
169
+ 2. The `<script>` and every following sibling is tagged with `data-vibe-component-id="<id>"`
170
+ 3. Inside that subtree, `@[this.X.Y]` is rewritten to `@[_c0.X.Y]` and event handlers like `onclick="this.method()"` or `oninput="$.this.value = ..."` are rewritten to address `$[id]`
171
+ 4. When the component leaves the DOM, its state entry is freed automatically
176
172
 
177
- - **`<!-- each -->`** / **`<!-- /each -->`** Iteration block markers.
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.
178
174
 
179
- ```html
180
- <!-- each items as item -->
181
- <!-- /each -->
182
- ```
175
+ ### Drop-In Components (no `vibe()` needed)
183
176
 
184
- - **`<!-- if -->`** / **`<!-- else -->`** / **`<!-- /if -->`**Conditional block markers.
185
- ```html
186
- <!-- if condition -->
187
- <!-- else -->
188
- <!-- /if -->
189
- ```
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:
190
178
 
191
- #### Binding Syntax
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
+ ```
192
190
 
193
- - **`@[...]`** — Reactive binding syntax. Reserved for state references.
194
- ```html
195
- <div>@[variable]</div>
196
- ```
191
+ How it works:
197
192
 
198
- #### Global Properties
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
199
196
 
200
- - **`window.$`** Global reactive state object. All reactive data should be accessed through this.
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.
201
198
 
202
- ```javascript
203
- window.$ = state({ count: 0 });
204
- $.count++; // Triggers reactive updates
205
- ```
199
+ ### Lifecycle Hooks
206
200
 
207
- - **`window.__vibeManifest`** — Internal manifest data. Used by the compiler for optimization. Don't modify.
201
+ ```javascript
202
+ $.on('ready', () => {}); // once, after initial parse + first hydrate + all components mounted
203
+ $.on('afterUpdate', (cur, prev) => {}); // every state change (batched per microtask)
204
+ $.on('afterDomMutation', () => {}); // after every MutationObserver batch
205
+ ```
208
206
 
209
- - **`window.__vibeCompiling`** Internal flag. Set to `true` when running in compiler context.
207
+ `$.ready` is also exposed as a Promise (`await $.ready`), useful for code that captured `window.$` before boot.
210
208
 
211
- #### Data Attributes
209
+ ### Subtree Reconciliation (advanced)
212
210
 
213
- - **`data-vibe-component-id`** Internal attribute for component scoping. Automatically added to component elements. Don't use manually.
211
+ `$.reconcile(el, html)` and `$.renderComponent(rawHtml, props, slot, opts)` are public-but-advanced APIs used by the vite plugin's HMR path. Their shape may evolve; treat them as plumbing rather than application code for now.
214
212
 
215
- #### Event Names
213
+ ### Dehydrate
216
214
 
217
- - **`vibe:ready`** Custom event fired when Vibe completes initial hydration.
218
- ```javascript
219
- document.addEventListener('vibe:ready', () => {
220
- console.info('Vibe is ready');
221
- });
222
- ```
215
+ Skip reactive processing for an element:
223
216
 
224
- #### Special Attribute Meanings
217
+ ```html
218
+ <code vibe-dehydrate>@[this] displays literally</code>
219
+ ```
225
220
 
226
- - **`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)
227
222
 
228
- ```html
229
- <!-- Both work the same way -->
230
- <component src="/components/card.html"></component>
231
- <div class="component" src="/components/card.html"></div>
232
- ```
223
+ These are used by the runtime — don't repurpose them in your code:
233
224
 
234
- - **`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)
235
227
 
236
228
  ### Deep Reactivity
237
229
 
@@ -544,11 +536,12 @@ Every time `matchesKey` can't prove an expression is unrelated to a state change
544
536
 
545
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.
546
538
 
547
- Handled today by case-insensitive fallbacks in two places:
548
- - `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)
549
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)
550
543
 
551
- 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.
552
545
 
553
546
  ### Related: value hydration into attribute values is case-preserving
554
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();