@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 +71 -0
- package/README.md +90 -97
- package/ROADMAP.md +6 -12
- package/compiler/native/vibe-compiler-darwin-arm64 +0 -0
- package/compiler/native/vibe-compiler-linux-x64 +0 -0
- package/compiler/src/compiler/PRE-RENDERING-IMPLEMENTATION.md +1 -1
- package/compiler/src/compiler/compile.rs +66 -6
- package/compiler/src/parser/html.rs +7 -1
- package/llms.txt +175 -83
- package/package.json +2 -1
- package/runtime/affected.js +141 -52
- package/runtime/component.js +302 -148
- package/runtime/conditionals.js +45 -3
- package/runtime/constants.js +24 -5
- package/runtime/hydrate.js +35 -21
- package/runtime/index.js +50 -3
- package/runtime/iterate.js +598 -56
- package/runtime/iteration-utils.js +9 -2
- package/runtime/loop-scope.js +157 -0
- package/runtime/parse.js +95 -20
- package/runtime/pre-compiled-iterations.js +12 -0
- package/runtime/state.js +18 -1
- package/runtime/utils.js +39 -2
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.
|
|
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
|
|
60
|
+
2. Add the `vibe-fouc` attribute (or `class="vibe-fouc"`) to an element — typically `<body>`
|
|
44
61
|
|
|
45
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
###
|
|
149
|
+
### Component-Local State
|
|
130
150
|
|
|
131
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
<slot></slot>
|
|
163
|
+
<button onclick="this.increment()">Clicked @[this.count] times</button>
|
|
164
|
+
```
|
|
168
165
|
|
|
169
|
-
|
|
170
|
-
<component src="...">
|
|
171
|
-
<p>This replaces the slot</p>
|
|
172
|
-
</component>
|
|
173
|
-
```
|
|
166
|
+
How it works:
|
|
174
167
|
|
|
175
|
-
|
|
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
|
-
-
|
|
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
|
-
|
|
180
|
-
<!-- each items as item -->
|
|
181
|
-
<!-- /each -->
|
|
182
|
-
```
|
|
175
|
+
### Drop-In Components (no `vibe()` needed)
|
|
183
176
|
|
|
184
|
-
-
|
|
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
|
-
|
|
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
|
-
|
|
194
|
-
```html
|
|
195
|
-
<div>@[variable]</div>
|
|
196
|
-
```
|
|
191
|
+
How it works:
|
|
197
192
|
|
|
198
|
-
|
|
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
|
-
-
|
|
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
|
-
|
|
203
|
-
window.$ = state({ count: 0 });
|
|
204
|
-
$.count++; // Triggers reactive updates
|
|
205
|
-
```
|
|
199
|
+
### Lifecycle Hooks
|
|
206
200
|
|
|
207
|
-
|
|
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
|
-
|
|
207
|
+
`$.ready` is also exposed as a Promise (`await $.ready`), useful for code that captured `window.$` before boot.
|
|
210
208
|
|
|
211
|
-
|
|
209
|
+
### Subtree Reconciliation (advanced)
|
|
212
210
|
|
|
213
|
-
|
|
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
|
-
|
|
213
|
+
### Dehydrate
|
|
216
214
|
|
|
217
|
-
|
|
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
|
-
|
|
217
|
+
```html
|
|
218
|
+
<code vibe-dehydrate>@[this] displays literally</code>
|
|
219
|
+
```
|
|
225
220
|
|
|
226
|
-
|
|
221
|
+
### Internal Names (don't collide)
|
|
227
222
|
|
|
228
|
-
|
|
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
|
-
-
|
|
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
|
|
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
|
-
|
|
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**:
|
|
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
|
-
**
|
|
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
|
-
**
|
|
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.
|
|
283
|
-
4.
|
|
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
|
|
|
Binary file
|
|
Binary file
|
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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();
|