@ape-egg/vibe 1.9.0 → 1.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- package/README.md +44 -2
- package/llms.txt +151 -84
- package/package.json +2 -1
- package/runtime/component.js +186 -144
- package/runtime/constants.js +12 -0
- package/runtime/hydrate.js +27 -10
- package/runtime/index.js +15 -2
- package/runtime/iterate.js +61 -8
- package/runtime/parse.js +2 -1
- package/runtime/utils.js +3 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.9.1] - 2026-04-29
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **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`.
|
|
8
|
+
- Test: `tests/e2e/component-in-iteration-rich-props.spec.js`
|
|
9
|
+
- **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).
|
|
10
|
+
- Test: `tests/e2e/idempotent-hydrate.spec.js`
|
|
11
|
+
- **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.
|
|
12
|
+
- **`$.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.
|
|
13
|
+
- **`./boot` package export** (`package.json`) — `import { boot } from '@ape-egg/vibe/boot'` is now usable from consumers.
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- **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`.
|
|
18
|
+
- **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]`.
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
3
22
|
## [1.9.0] - 2026-04-18
|
|
4
23
|
|
|
5
24
|
### Added
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Vibe
|
|
2
2
|
|
|
3
|
-
**Version 1.
|
|
3
|
+
**Version 1.9.1 (Alpha)** — 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>
|
|
@@ -118,6 +118,48 @@ Runtime component loading with props and slots:
|
|
|
118
118
|
</component>
|
|
119
119
|
```
|
|
120
120
|
|
|
121
|
+
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
|
+
|
|
123
|
+
### Component-Local State
|
|
124
|
+
|
|
125
|
+
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:
|
|
126
|
+
|
|
127
|
+
```html
|
|
128
|
+
<!-- /components/Counter.html -->
|
|
129
|
+
<script type="module">
|
|
130
|
+
import component from '@ape-egg/vibe/component';
|
|
131
|
+
component({
|
|
132
|
+
count: 0,
|
|
133
|
+
increment() { this.count++; }
|
|
134
|
+
});
|
|
135
|
+
</script>
|
|
136
|
+
|
|
137
|
+
<button onclick="this.increment()">Clicked @[this.count] times</button>
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
How it works:
|
|
141
|
+
|
|
142
|
+
1. `component({...})` generates a unique id (e.g. `_c0`, `_c1`) and registers the state at `$[id]`
|
|
143
|
+
2. The `<script>` and every following sibling is tagged with `data-vibe-component-id="<id>"`
|
|
144
|
+
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]`
|
|
145
|
+
4. When the component leaves the DOM, its state entry is freed automatically
|
|
146
|
+
|
|
147
|
+
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
|
+
|
|
149
|
+
### Lifecycle Hooks
|
|
150
|
+
|
|
151
|
+
```javascript
|
|
152
|
+
$.on('ready', () => {}); // once, after initial parse + first hydrate + all components mounted
|
|
153
|
+
$.on('afterUpdate', (cur, prev) => {}); // every state change (batched per microtask)
|
|
154
|
+
$.on('afterDomMutation', () => {}); // after every MutationObserver batch
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
`$.ready` is also exposed as a Promise (`await $.ready`), useful for code that captured `window.$` before boot.
|
|
158
|
+
|
|
159
|
+
### Subtree Reconciliation (advanced)
|
|
160
|
+
|
|
161
|
+
`$.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.
|
|
162
|
+
|
|
121
163
|
### Dehydrate
|
|
122
164
|
|
|
123
165
|
Skip reactive processing for an element:
|
package/llms.txt
CHANGED
|
@@ -9,9 +9,12 @@ Vibe is a lightweight reactive library that uses Proxy-based state and MutationO
|
|
|
9
9
|
**Key characteristics:**
|
|
10
10
|
- Proxy-based reactive state (`window.$`)
|
|
11
11
|
- Deep reactivity (nested mutations automatically trigger updates)
|
|
12
|
-
- Surgical DOM updates (only affected elements re-render)
|
|
12
|
+
- Surgical DOM updates (only affected elements re-render; idempotent writes skip no-op DOM mutations)
|
|
13
|
+
- State batching (multiple `$.x = y` writes in the same microtask flush as one update)
|
|
13
14
|
- MutationObserver for dynamic element tracking
|
|
14
|
-
- Works with vanilla HTML
|
|
15
|
+
- Works with vanilla HTML — no special file format
|
|
16
|
+
- Component-local state via `component({...})` + `this.X`
|
|
17
|
+
- Component composition via `<component src>` with reactive props (objects/arrays/primitives) and slots
|
|
15
18
|
|
|
16
19
|
## Installation
|
|
17
20
|
|
|
@@ -23,19 +26,21 @@ npm install @ape-egg/vibe
|
|
|
23
26
|
|
|
24
27
|
```html
|
|
25
28
|
<script type="module">
|
|
26
|
-
import
|
|
27
|
-
window.$ =
|
|
29
|
+
import vibe from "@ape-egg/vibe";
|
|
30
|
+
window.$ = vibe({ name: "World", count: 0 });
|
|
28
31
|
</script>
|
|
29
32
|
|
|
30
|
-
<
|
|
31
|
-
<
|
|
33
|
+
<body vibe-fouc>
|
|
34
|
+
<h1>Hello, @[name]!</h1>
|
|
35
|
+
<button onclick="$.count++">Clicked @[count] times</button>
|
|
36
|
+
</body>
|
|
32
37
|
```
|
|
33
38
|
|
|
34
39
|
## Core Syntax
|
|
35
40
|
|
|
36
41
|
### Reactive Bindings
|
|
37
42
|
|
|
38
|
-
Use `@[
|
|
43
|
+
Use `@[expression]` syntax anywhere in HTML or CSS:
|
|
39
44
|
|
|
40
45
|
```html
|
|
41
46
|
<!-- Text content -->
|
|
@@ -44,11 +49,15 @@ Use `@[property]` syntax anywhere in HTML or CSS:
|
|
|
44
49
|
<!-- Expressions -->
|
|
45
50
|
<div>@[firstName + ' ' + lastName]</div>
|
|
46
51
|
<div>@[count * 2]</div>
|
|
52
|
+
<div>@[items.filter(x => x.active).length]</div>
|
|
47
53
|
|
|
48
54
|
<!-- Attributes -->
|
|
49
55
|
<input value="@[inputValue]">
|
|
50
56
|
<button disabled="@[isLoading]">Submit</button>
|
|
51
57
|
|
|
58
|
+
<!-- Attribute names (name bindings) -->
|
|
59
|
+
<icon @[iconName]></icon>
|
|
60
|
+
|
|
52
61
|
<!-- CSS -->
|
|
53
62
|
<style>
|
|
54
63
|
.box { background: @[themeColor]; }
|
|
@@ -65,7 +74,7 @@ State is accessed globally via `window.$`:
|
|
|
65
74
|
// Read
|
|
66
75
|
console.log($.firstName);
|
|
67
76
|
|
|
68
|
-
// Write (triggers re-render)
|
|
77
|
+
// Write (triggers re-render — batched per microtask)
|
|
69
78
|
$.firstName = "John";
|
|
70
79
|
|
|
71
80
|
// Increment
|
|
@@ -97,9 +106,17 @@ With index:
|
|
|
97
106
|
<!-- /each -->
|
|
98
107
|
```
|
|
99
108
|
|
|
109
|
+
The expression after `each` is any JavaScript that returns an array or array-like:
|
|
110
|
+
|
|
111
|
+
```html
|
|
112
|
+
<!-- each items.filter(x => x.active) as item -->
|
|
113
|
+
<!-- each Array.from({length: 10}, (_, i) => i) as n -->
|
|
114
|
+
<!-- each Object.values(users) as user, i -->
|
|
115
|
+
```
|
|
116
|
+
|
|
100
117
|
### Nested Iteration
|
|
101
118
|
|
|
102
|
-
Use dot paths
|
|
119
|
+
Use dot paths or any expression:
|
|
103
120
|
|
|
104
121
|
```html
|
|
105
122
|
<!-- each categories as category -->
|
|
@@ -122,71 +139,103 @@ Use dot paths for nested arrays:
|
|
|
122
139
|
|
|
123
140
|
Conditionals can be nested inside iterations and vice versa.
|
|
124
141
|
|
|
125
|
-
##
|
|
142
|
+
## Components
|
|
126
143
|
|
|
127
|
-
|
|
144
|
+
```html
|
|
145
|
+
<component src="/components/card.html" title="@[pageTitle]" theme="dark">
|
|
146
|
+
<p>This content replaces <slot></slot></p>
|
|
147
|
+
</component>
|
|
148
|
+
```
|
|
128
149
|
|
|
129
|
-
|
|
150
|
+
**Behavior:**
|
|
151
|
+
1. Vibe finds `<component src="...">` elements (also `<div class="component" src="...">`)
|
|
152
|
+
2. Fetches the source HTML
|
|
153
|
+
3. Substitutes props (`@[title]` in the template becomes `@[pageTitle]`)
|
|
154
|
+
4. Inserts slot content (between the tags) into `<slot></slot>`
|
|
155
|
+
5. Removes `src`; the `<component>` wrapper remains as a boundary marker
|
|
156
|
+
|
|
157
|
+
**Reactive props** (`title="@[pageTitle]"`) keep tracking the parent's state. **Static props** (`theme="dark"`) inline as literals. **Object/array props** through iteration scope (`<component card="@[card]">` inside `<!-- each cards as card -->`) are stashed in an internal registry so child templates can `@[card.name]` or `<!-- each card.abilities as a -->`.
|
|
158
|
+
|
|
159
|
+
**Two-way binding** falls out for free: a child component that writes `$.value = x` in an event handler has that `$.value` rewritten to the parent's bound path, so the parent's state actually updates.
|
|
160
|
+
|
|
161
|
+
**Slot content** that itself contains `<component src>` is also processed recursively.
|
|
162
|
+
|
|
163
|
+
## Component-Local State
|
|
164
|
+
|
|
165
|
+
For state scoped to a single component (form drafts, toggles, derived view-state) instead of global `$`:
|
|
130
166
|
|
|
131
167
|
```html
|
|
132
|
-
|
|
168
|
+
<!-- /components/Counter.html -->
|
|
169
|
+
<script type="module">
|
|
170
|
+
import component from '@ape-egg/vibe/component';
|
|
171
|
+
component({ count: 0, increment() { this.count++; } });
|
|
172
|
+
</script>
|
|
173
|
+
|
|
174
|
+
<button onclick="this.increment()">Clicked @[this.count] times</button>
|
|
133
175
|
```
|
|
134
176
|
|
|
135
|
-
|
|
136
|
-
- Displaying `@[...]` syntax in documentation
|
|
137
|
-
- Static content that shouldn't be reactive
|
|
138
|
-
- Performance optimization for large static sections
|
|
177
|
+
How it works:
|
|
139
178
|
|
|
140
|
-
|
|
179
|
+
1. `component({...})` generates a unique id (e.g. `_c0`) and stores state at `$[id]`
|
|
180
|
+
2. The `<script>` and every following sibling get `data-vibe-component-id="<id>"`
|
|
181
|
+
3. Inside that subtree, `@[this.X.Y]` is rewritten to `@[_c0.X.Y]`; `onclick="this.fn()"` becomes `onclick="$['_c0'].fn()"`; `$.this.x = v` (in event handlers) becomes `$['_c0'].x = v`
|
|
182
|
+
4. When the component leaves the DOM, its bucket is freed automatically
|
|
141
183
|
|
|
142
|
-
|
|
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.
|
|
143
185
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
186
|
+
## Lifecycle Hooks
|
|
187
|
+
|
|
188
|
+
```javascript
|
|
189
|
+
$.on('ready', () => {}); // once, after initial parse + hydrate + components mounted
|
|
190
|
+
$.on('afterUpdate', (cur, prev) => {}); // every state change (batched per microtask)
|
|
191
|
+
$.on('afterDomMutation', () => {}); // after every MutationObserver batch
|
|
147
192
|
```
|
|
148
193
|
|
|
149
|
-
|
|
194
|
+
Also: `await $.ready` resolves after boot, useful when calling code captured `window.$` before vibe finished initializing.
|
|
195
|
+
|
|
196
|
+
## Special Attributes
|
|
197
|
+
|
|
198
|
+
### vibe-fouc
|
|
150
199
|
|
|
151
|
-
|
|
200
|
+
Add to an element (typically `<body>`) to hide it until Vibe's first hydration completes. Vibe removes the attribute when ready.
|
|
152
201
|
|
|
153
202
|
```html
|
|
154
|
-
<
|
|
155
|
-
<input oninput="$.text = this.value">
|
|
156
|
-
<form onsubmit="event.preventDefault(); handleSubmit()">
|
|
203
|
+
<body vibe-fouc>
|
|
157
204
|
```
|
|
158
205
|
|
|
159
|
-
|
|
206
|
+
Pair with `vibe.css` (`<link rel="stylesheet" href="@ape-egg/vibe/vibe.css">`) which targets `[vibe-fouc]` with `visibility: hidden`.
|
|
160
207
|
|
|
161
|
-
###
|
|
208
|
+
### vibe-dehydrate
|
|
162
209
|
|
|
163
|
-
|
|
210
|
+
Skip reactive processing for an element and its children:
|
|
164
211
|
|
|
165
212
|
```html
|
|
166
|
-
<
|
|
167
|
-
.box {
|
|
168
|
-
background: @[backgroundColor];
|
|
169
|
-
color: @[textColor];
|
|
170
|
-
width: @[width]px;
|
|
171
|
-
}
|
|
172
|
-
</style>
|
|
213
|
+
<code vibe-dehydrate>@[this] displays literally, not parsed</code>
|
|
173
214
|
```
|
|
174
215
|
|
|
175
|
-
|
|
216
|
+
Use cases: documenting `@[...]` syntax, large static sections, content that must not be evaluated.
|
|
217
|
+
|
|
218
|
+
### Boolean Attributes
|
|
176
219
|
|
|
177
|
-
|
|
220
|
+
Pure-binding attributes that aren't in the value whitelist (e.g. `disabled`, `checked`, `active`) are added when truthy and removed when falsy:
|
|
178
221
|
|
|
179
222
|
```html
|
|
180
|
-
<
|
|
223
|
+
<button disabled="@[isLoading]">Submit</button>
|
|
224
|
+
<!-- isLoading=false → attribute removed entirely -->
|
|
181
225
|
```
|
|
182
226
|
|
|
183
|
-
|
|
227
|
+
## Events
|
|
228
|
+
|
|
229
|
+
Use standard inline event handlers — they execute against `$` directly:
|
|
184
230
|
|
|
185
231
|
```html
|
|
186
|
-
<
|
|
187
|
-
<
|
|
232
|
+
<button onclick="$.count++">Increment</button>
|
|
233
|
+
<input oninput="$.text = this.value">
|
|
234
|
+
<form onsubmit="event.preventDefault(); handleSubmit()">
|
|
188
235
|
```
|
|
189
236
|
|
|
237
|
+
Inside a component, `this.X` references the component's bucket (rewritten at parse time); `this.value`, `this.checked`, `this.dataset` etc. still reach the DOM element since DOM properties are preserved.
|
|
238
|
+
|
|
190
239
|
## Dynamic Elements
|
|
191
240
|
|
|
192
241
|
Elements added via JavaScript are automatically hydrated through MutationObserver:
|
|
@@ -200,33 +249,47 @@ document.body.appendChild(div);
|
|
|
200
249
|
|
|
201
250
|
## API Reference
|
|
202
251
|
|
|
203
|
-
### `
|
|
252
|
+
### `vibe(initialState, config?)`
|
|
204
253
|
|
|
205
|
-
|
|
254
|
+
Initializes the framework and returns the reactive state proxy.
|
|
206
255
|
|
|
207
256
|
```javascript
|
|
208
|
-
import
|
|
257
|
+
import vibe from "@ape-egg/vibe";
|
|
209
258
|
|
|
210
|
-
window.$ =
|
|
259
|
+
window.$ = vibe(
|
|
211
260
|
{ count: 0, user: { name: "Alice" } },
|
|
212
|
-
|
|
213
|
-
console.log("State updated:", newState);
|
|
214
|
-
}
|
|
261
|
+
{ debug: false }
|
|
215
262
|
);
|
|
216
263
|
```
|
|
217
264
|
|
|
218
265
|
**Parameters:**
|
|
219
|
-
- `initialState`
|
|
220
|
-
- `
|
|
266
|
+
- `initialState` — object containing initial state values
|
|
267
|
+
- `config` — optional object. Currently supports `{ debug: boolean }`. A third positional argument can pass a target selector (defaults to `body`).
|
|
268
|
+
|
|
269
|
+
**Returns:** Reactive proxy. Assign it to `window.$` so inline event handlers and bindings can find it.
|
|
270
|
+
|
|
271
|
+
### `component(initialState)`
|
|
272
|
+
|
|
273
|
+
Imported from `@ape-egg/vibe/component`. Registers a component-local state bucket; called from inside a `<script type="module">` in a component file. See [Component-Local State](#component-local-state).
|
|
221
274
|
|
|
222
|
-
|
|
275
|
+
### `$.on(event, callback)`
|
|
223
276
|
|
|
224
|
-
|
|
277
|
+
Register a lifecycle listener. Events: `'ready'`, `'afterUpdate'`, `'afterDomMutation'`. Listeners registered before boot are queued and replayed once vibe is ready.
|
|
278
|
+
|
|
279
|
+
### `$.ready`
|
|
280
|
+
|
|
281
|
+
A Promise that resolves once vibe has finished initial parse + hydrate + component loading.
|
|
282
|
+
|
|
283
|
+
### `$.reconcile(el, html)` and `$.renderComponent(rawHtml, props, slot, opts)` (advanced)
|
|
284
|
+
|
|
285
|
+
Subtree reconciliation primitives used by `@ape-egg/vite-plugin-vibe` for surgical HMR. `$.reconcile` diffs `el`'s children against fresh `html` while preserving DOM identity, focus, and selection; vibe-managed regions (iterations, conditionals, components, slot pairs) are treated as opaque. Treat these as plumbing — shape may evolve.
|
|
286
|
+
|
|
287
|
+
## Scoped Variables in Iterations
|
|
225
288
|
|
|
226
289
|
Inside `<!-- each -->` blocks, these variables are available:
|
|
227
|
-
- `item` (or custom name)
|
|
228
|
-
- `index` (or custom name)
|
|
229
|
-
- Parent state remains accessible via `$`
|
|
290
|
+
- `item` (or custom name) — current array element
|
|
291
|
+
- `index` (or custom name) — current index
|
|
292
|
+
- Parent state remains accessible via `$` and via free identifiers (e.g. `tutorialProgress`)
|
|
230
293
|
|
|
231
294
|
```html
|
|
232
295
|
<!-- each users as user, i -->
|
|
@@ -236,7 +299,7 @@ Inside `<!-- each -->` blocks, these variables are available:
|
|
|
236
299
|
|
|
237
300
|
### Scoped State (Internal)
|
|
238
301
|
|
|
239
|
-
Each iteration instance stores a `scopedState`
|
|
302
|
+
Each iteration instance stores a `scopedState` Proxy that combines local variables (`item`, `index`) with global state. This lets bindings inside iterations react to global state changes:
|
|
240
303
|
|
|
241
304
|
```html
|
|
242
305
|
<!-- each menuItems as item -->
|
|
@@ -246,50 +309,50 @@ Each iteration instance stores a `scopedState` - a Proxy wrapper that provides a
|
|
|
246
309
|
<!-- /each -->
|
|
247
310
|
```
|
|
248
311
|
|
|
249
|
-
When `tutorialProgress` changes,
|
|
250
|
-
1. Finds affected elements inside iteration instances
|
|
251
|
-
2. Uses that instance's `scopedState` for evaluation
|
|
252
|
-
3. Can access both `item` (local) and `tutorialProgress` (global)
|
|
253
|
-
4. Updates only the affected elements without re-rendering the entire iteration
|
|
312
|
+
When `tutorialProgress` changes, vibe finds affected elements inside iteration instances, evaluates against that instance's `scopedState`, and updates only the changed elements without re-rendering the whole list.
|
|
254
313
|
|
|
255
314
|
## Architecture
|
|
256
315
|
|
|
257
|
-
Vibe consists of these core modules:
|
|
316
|
+
Vibe consists of these core runtime modules (in `runtime/`):
|
|
258
317
|
|
|
259
|
-
- **state.js**
|
|
260
|
-
- **parse.js**
|
|
261
|
-
- **
|
|
262
|
-
- **hydrate.js**
|
|
263
|
-
- **affected.js**
|
|
264
|
-
- **iterate.js**
|
|
265
|
-
- **conditionals.js**
|
|
318
|
+
- **state.js** — Proxy-based reactive state, recursive proxies for deep reactivity, batched flush via `queueMicrotask`
|
|
319
|
+
- **parse.js** — DOM parser that finds `@[...]` bindings, iteration/conditional comments, and `<component src>` elements
|
|
320
|
+
- **manifest.js** — Maps DOM elements to parsed-tree nodes (was `link.js` in older versions)
|
|
321
|
+
- **hydrate.js** — Updates DOM with current state values; idempotent (skips no-op writes)
|
|
322
|
+
- **affected.js** — Determines which bindings need re-evaluation when state changes
|
|
323
|
+
- **iterate.js** — Array rendering with diffing; bulk-replacement fast path; iteration-prop registry for non-primitive component props
|
|
324
|
+
- **conditionals.js** — Conditional block rendering; preserves both branches in the manifest for reactive switching
|
|
325
|
+
- **component.js** — `<component src>` fetching, prop substitution, slot inlining, component-local state setup, `this.X` rewriting
|
|
326
|
+
- **reconcile.js** — Subtree reconciliation primitive (`$.reconcile`)
|
|
327
|
+
- **cleanup.js** — `vibe-fouc` release once a subtree is fully processed
|
|
266
328
|
|
|
267
329
|
## How It Works
|
|
268
330
|
|
|
269
331
|
```
|
|
270
|
-
1.
|
|
271
|
-
2. parse.js scans DOM for @[...], <!-- each -->, <!-- if
|
|
272
|
-
3.
|
|
332
|
+
1. vibe(initialState) creates the Proxy and starts the runtime
|
|
333
|
+
2. parse.js scans DOM for @[...], <!-- each -->, <!-- if -->, <component src>
|
|
334
|
+
3. manifest.js maps elements → parsed tree
|
|
273
335
|
4. hydrate.js replaces bindings with values
|
|
274
336
|
5. iterate.js renders <!-- each --> loops
|
|
275
337
|
6. conditionals.js renders <!-- if --> blocks
|
|
276
|
-
7.
|
|
277
|
-
8.
|
|
338
|
+
7. component.js fetches <component src> templates, substitutes props, inlines slots
|
|
339
|
+
8. MutationObserver watches for new/removed elements; affected.js + hydrate.js handle state changes
|
|
278
340
|
```
|
|
279
341
|
|
|
280
342
|
## Current Limitations
|
|
281
343
|
|
|
282
|
-
- **No computed values**:
|
|
283
|
-
- **
|
|
284
|
-
- **
|
|
344
|
+
- **No computed values primitive**: derived state can be done with `Object.defineProperty($, 'x', { get })` or with `afterUpdate` listeners; a first-class `computed` API isn't shipped
|
|
345
|
+
- **Expression security**: `new Function()` evaluation — don't bind untrusted input
|
|
346
|
+
- **HTML binding**: `@[expr]` always sets `textContent` (escape-safe). A first-class opt-in for trusted HTML is planned but not finalized
|
|
285
347
|
|
|
286
348
|
## Best Practices
|
|
287
349
|
|
|
288
|
-
1. **Initialize state
|
|
289
|
-
2. **
|
|
290
|
-
3. **
|
|
291
|
-
4. **
|
|
292
|
-
5. **
|
|
350
|
+
1. **Initialize state in `<head>`** before reactive elements; pair with `vibe-fouc` on `<body>`
|
|
351
|
+
2. **Mutate freely** — deep reactivity means `$.user.name = "New"` just works; no spread operators needed
|
|
352
|
+
3. **Use lifecycle hooks instead of `setTimeout` / `requestAnimationFrame`** when you need to wait for vibe to finish work
|
|
353
|
+
4. **Use `component({...})` for state that's local to one piece of UI** (form draft, toggle); keep cross-cutting state on global `$`
|
|
354
|
+
5. **Use `vibe-dehydrate` when you need to display `@[...]` literally** (docs, code examples)
|
|
355
|
+
6. **Keep expressions simple** — complex logic belongs in JavaScript; templates should read like HTML
|
|
293
356
|
|
|
294
357
|
## Browser Support
|
|
295
358
|
|
|
@@ -298,6 +361,10 @@ Modern browsers with:
|
|
|
298
361
|
- MutationObserver
|
|
299
362
|
- ES Modules
|
|
300
363
|
|
|
364
|
+
## Related Packages
|
|
365
|
+
|
|
366
|
+
- **`@ape-egg/vite-plugin-vibe`** — Vite plugin with surgical component HMR (template-only edits reconcile in place; script edits re-mount)
|
|
367
|
+
|
|
301
368
|
## Resources
|
|
302
369
|
|
|
303
370
|
- **Homepage**: https://vibe.korte.kim
|
package/package.json
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ape-egg/vibe",
|
|
3
|
-
"version": "1.9.
|
|
3
|
+
"version": "1.9.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Runtime-first reactivity with optional compiler",
|
|
6
6
|
"main": "index.js",
|
|
7
7
|
"homepage": "https://vibe.korte.kim",
|
|
8
8
|
"exports": {
|
|
9
9
|
".": "./index.js",
|
|
10
|
+
"./boot": "./boot.js",
|
|
10
11
|
"./component": "./component.js",
|
|
11
12
|
"./runtime": "./runtime/index.js",
|
|
12
13
|
"./compiler": "./compiler/bin/vibe-compile.js"
|
package/runtime/component.js
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { debugLog } from './debug.js';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
PHASE_FETCH,
|
|
4
|
+
DEHYDRATE_CLASS_OR_ATTR,
|
|
5
|
+
BINDING_REGEX,
|
|
6
|
+
THIS_PROP_REGEX,
|
|
7
|
+
STATE_THIS_PROP_REGEX,
|
|
8
|
+
} from './constants.js';
|
|
3
9
|
import { evalInScope } from './utils.js';
|
|
4
10
|
|
|
5
11
|
// Deterministic component counter
|
|
@@ -79,6 +85,162 @@ const isNestedInUnprocessedComponent = (el, rootElement) => {
|
|
|
79
85
|
return false;
|
|
80
86
|
};
|
|
81
87
|
|
|
88
|
+
// Rewrite `@[this.x...]` and `$.this.x...` inside element's text/attrs to use
|
|
89
|
+
// componentId. Shared between script-execution path and pure-render path.
|
|
90
|
+
//
|
|
91
|
+
// Within `@[...]` bindings we rewrite every `this.X` reference (preserving
|
|
92
|
+
// any trailing `.Y.Z` chain), so `@[this.user.name]` and `@[this.x + 1]` both
|
|
93
|
+
// resolve correctly. Outside bindings — i.e. event handler attribute bodies
|
|
94
|
+
// like `onclick="$.this.mode = 'edit'"` — only `$.this.X` writes are
|
|
95
|
+
// rewritten; bare `this.X` reads in event handlers are handled later by
|
|
96
|
+
// parse.js (which preserves DOM properties like `this.value`).
|
|
97
|
+
const rewriteBindingsInString = (str, componentId) =>
|
|
98
|
+
str.replace(BINDING_REGEX, (match, expr) => {
|
|
99
|
+
const rewritten = expr.replace(THIS_PROP_REGEX, `${componentId}.$1`);
|
|
100
|
+
return rewritten === expr ? match : `@[${rewritten}]`;
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const rewriteThisBindings = (element, componentId) => {
|
|
104
|
+
Array.from(element.childNodes).forEach((node) => {
|
|
105
|
+
if (node.nodeType === Node.TEXT_NODE && node.textContent.includes('@[this.')) {
|
|
106
|
+
node.textContent = rewriteBindingsInString(node.textContent, componentId);
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
Array.from(element.attributes || []).forEach((attr) => {
|
|
111
|
+
if (attr.value.includes('@[this.')) {
|
|
112
|
+
attr.value = rewriteBindingsInString(attr.value, componentId);
|
|
113
|
+
}
|
|
114
|
+
if (attr.value.includes('$.this.')) {
|
|
115
|
+
attr.value = attr.value.replace(STATE_THIS_PROP_REGEX, `$.${componentId}.$1`);
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
Array.from(element.children).forEach((child) => {
|
|
120
|
+
rewriteThisBindings(child, componentId);
|
|
121
|
+
});
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
// Tag siblings of a <script> with componentId and rewrite `this.` bindings.
|
|
125
|
+
// Runs BEFORE script.remove() so nextElementSibling is valid. Shared by
|
|
126
|
+
// processSingle (executing path) and renderComponentTemplate (pure path).
|
|
127
|
+
const tagScriptSiblings = (script, componentId) => {
|
|
128
|
+
let sibling = script.nextElementSibling;
|
|
129
|
+
while (sibling) {
|
|
130
|
+
if (sibling.tagName === 'SCRIPT' && sibling.getAttribute('type') === 'module') {
|
|
131
|
+
break;
|
|
132
|
+
}
|
|
133
|
+
sibling.setAttribute('data-vibe-component-id', componentId);
|
|
134
|
+
rewriteThisBindings(sibling, componentId);
|
|
135
|
+
sibling = sibling.nextElementSibling;
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
// Substitute props + inline slot content into a pre-processed temp container.
|
|
140
|
+
// Returns the final processed HTML string. Shared by processSingle.finalize()
|
|
141
|
+
// (runtime mount) and renderComponentTemplate (surgical HMR).
|
|
142
|
+
const renderPropsAndSlot = (temp, props, slotHtml) => {
|
|
143
|
+
let transformedHtml = temp.innerHTML;
|
|
144
|
+
|
|
145
|
+
const DIRECTIVE_COMMENT_REGEX = /<!--\s*(if|else if|each)\s+([^]*?)\s*-->/g;
|
|
146
|
+
const EVENT_ATTR_REGEX = /\bon(\w+)="([^"]*)"/g;
|
|
147
|
+
|
|
148
|
+
Object.entries(props).forEach(([propName, propValue]) => {
|
|
149
|
+
const bindingMatch = propValue.match(/^@\[(.+)\]$/);
|
|
150
|
+
const exactPattern = new RegExp(`@\\[${escapeRegex(propName)}\\]`, 'g');
|
|
151
|
+
const idRegex = new RegExp(
|
|
152
|
+
`(?<![a-zA-Z0-9_$\\.])${escapeRegex(propName)}(?![a-zA-Z0-9_$])`,
|
|
153
|
+
'g'
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
const substituteInExpr = (expr, replacement) => expr.replace(idRegex, replacement);
|
|
157
|
+
|
|
158
|
+
if (bindingMatch) {
|
|
159
|
+
const path = bindingMatch[1];
|
|
160
|
+
transformedHtml = transformedHtml.replace(exactPattern, `@[${path}]`);
|
|
161
|
+
transformedHtml = transformedHtml.replace(BINDING_REGEX, (match, expr) => {
|
|
162
|
+
const rewritten = substituteInExpr(expr, `(${path})`);
|
|
163
|
+
return rewritten === expr ? match : `@[${rewritten}]`;
|
|
164
|
+
});
|
|
165
|
+
transformedHtml = transformedHtml.replace(
|
|
166
|
+
DIRECTIVE_COMMENT_REGEX,
|
|
167
|
+
(match, kw, expr) => {
|
|
168
|
+
const replacement = kw === 'each' ? path : `(${path})`;
|
|
169
|
+
const rewritten = substituteInExpr(expr, replacement);
|
|
170
|
+
return rewritten === expr ? match : `<!-- ${kw} ${rewritten} -->`;
|
|
171
|
+
}
|
|
172
|
+
);
|
|
173
|
+
const stateRegex = new RegExp(`\\$\\.${escapeRegex(propName)}(?![a-zA-Z0-9_$])`, 'g');
|
|
174
|
+
transformedHtml = transformedHtml.replace(EVENT_ATTR_REGEX, (match, evName, body) => {
|
|
175
|
+
const rewritten = body.replace(stateRegex, `$.${path}`);
|
|
176
|
+
return rewritten === body ? match : `on${evName}="${rewritten}"`;
|
|
177
|
+
});
|
|
178
|
+
} else {
|
|
179
|
+
transformedHtml = transformedHtml.replace(exactPattern, propValue);
|
|
180
|
+
const isNumeric =
|
|
181
|
+
typeof propValue === 'string' && /^-?\d+(\.\d+)?(e[+-]?\d+)?$/i.test(propValue);
|
|
182
|
+
const isBooleanAttr = propValue === '';
|
|
183
|
+
const literal = isBooleanAttr
|
|
184
|
+
? 'true'
|
|
185
|
+
: typeof propValue === 'string' && !isNumeric
|
|
186
|
+
? JSON.stringify(propValue)
|
|
187
|
+
: String(propValue);
|
|
188
|
+
transformedHtml = transformedHtml.replace(BINDING_REGEX, (match, expr) => {
|
|
189
|
+
const rewritten = substituteInExpr(expr, literal);
|
|
190
|
+
return rewritten === expr ? match : `@[${rewritten}]`;
|
|
191
|
+
});
|
|
192
|
+
transformedHtml = transformedHtml.replace(
|
|
193
|
+
DIRECTIVE_COMMENT_REGEX,
|
|
194
|
+
(match, kw, expr) => {
|
|
195
|
+
const rewritten = substituteInExpr(expr, literal);
|
|
196
|
+
return rewritten === expr ? match : `<!-- ${kw} ${rewritten} -->`;
|
|
197
|
+
}
|
|
198
|
+
);
|
|
199
|
+
const stateRegex = new RegExp(`\\$\\.${escapeRegex(propName)}(?![a-zA-Z0-9_$])`, 'g');
|
|
200
|
+
transformedHtml = transformedHtml.replace(EVENT_ATTR_REGEX, (match, evName, body) => {
|
|
201
|
+
const rewritten = body.replace(stateRegex, `$.${literal}`);
|
|
202
|
+
return rewritten === body ? match : `on${evName}="${rewritten}"`;
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
const children = (slotHtml || '').trim();
|
|
208
|
+
if (children) {
|
|
209
|
+
transformedHtml = transformedHtml.replace(
|
|
210
|
+
/<slot(\s[^>]*)?>\s*<\/slot>/g,
|
|
211
|
+
(_, attrs) => `<slot${attrs || ''}>${children}</slot>`
|
|
212
|
+
);
|
|
213
|
+
transformedHtml = transformedHtml.replace(
|
|
214
|
+
/<slot(\s[^>]*)?\/>/g,
|
|
215
|
+
(_, attrs) => `<slot${attrs || ''}>${children}</slot>`
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return transformedHtml;
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
// Pure-render path for surgical HMR. Takes a component's raw template HTML
|
|
223
|
+
// plus the live instance's props, slot, and existing componentIds. Returns
|
|
224
|
+
// the processed HTML string that $.reconcile can diff against the live
|
|
225
|
+
// wrapper's children. Scripts are NOT executed — callers are responsible
|
|
226
|
+
// for deciding whether to preserve the existing state (reuse componentIds)
|
|
227
|
+
// or trigger a full re-mount (new componentIds).
|
|
228
|
+
export const renderComponentTemplate = (rawHtml, props = {}, slotHtml = '', options = {}) => {
|
|
229
|
+
const { componentIds = [] } = options;
|
|
230
|
+
const temp = document.createElement('div');
|
|
231
|
+
temp.innerHTML = rawHtml;
|
|
232
|
+
|
|
233
|
+
const idsToReuse = [...componentIds];
|
|
234
|
+
const moduleScripts = temp.querySelectorAll('script[type="module"]');
|
|
235
|
+
for (const script of moduleScripts) {
|
|
236
|
+
const componentId = idsToReuse.shift() || generateComponentId();
|
|
237
|
+
tagScriptSiblings(script, componentId);
|
|
238
|
+
script.remove();
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return renderPropsAndSlot(temp, props, slotHtml);
|
|
242
|
+
};
|
|
243
|
+
|
|
82
244
|
// Process a single component element: fetch HTML, execute scripts, replace DOM
|
|
83
245
|
const processSingle = (el, debug) => {
|
|
84
246
|
// Skip dehydrated components
|
|
@@ -182,49 +344,9 @@ const processSingle = (el, debug) => {
|
|
|
182
344
|
console.warn('[vibe] Failed to execute component script:', e);
|
|
183
345
|
}
|
|
184
346
|
|
|
185
|
-
//
|
|
186
|
-
//
|
|
187
|
-
|
|
188
|
-
const rewriteThisBindings = (element) => {
|
|
189
|
-
const bindingRegex = /@\[this\.(\w+)\]/g;
|
|
190
|
-
const writeRegex = /\$\.this\.(\w+)/g;
|
|
191
|
-
|
|
192
|
-
// Rewrite in text nodes
|
|
193
|
-
Array.from(element.childNodes).forEach((node) => {
|
|
194
|
-
if (node.nodeType === Node.TEXT_NODE && node.textContent.includes('@[this.')) {
|
|
195
|
-
node.textContent = node.textContent.replace(bindingRegex, `@[${componentId}.$1]`);
|
|
196
|
-
}
|
|
197
|
-
});
|
|
198
|
-
|
|
199
|
-
// Rewrite in attributes — both @[this.x] bindings and $.this.x in event handlers
|
|
200
|
-
Array.from(element.attributes || []).forEach((attr) => {
|
|
201
|
-
if (attr.value.includes('@[this.')) {
|
|
202
|
-
attr.value = attr.value.replace(bindingRegex, `@[${componentId}.$1]`);
|
|
203
|
-
}
|
|
204
|
-
if (attr.value.includes('$.this.')) {
|
|
205
|
-
attr.value = attr.value.replace(writeRegex, `$.${componentId}.$1`);
|
|
206
|
-
}
|
|
207
|
-
});
|
|
208
|
-
|
|
209
|
-
// Recurse into children
|
|
210
|
-
Array.from(element.children).forEach((child) => {
|
|
211
|
-
rewriteThisBindings(child);
|
|
212
|
-
});
|
|
213
|
-
};
|
|
214
|
-
|
|
215
|
-
// Tag siblings with componentId and rewrite this. bindings.
|
|
216
|
-
// This runs BEFORE script.remove() so nextElementSibling is valid.
|
|
217
|
-
// For async scripts, componentFn runs later (after removal) and can't
|
|
218
|
-
// find siblings — so we tag here instead.
|
|
219
|
-
let sibling = script.nextElementSibling;
|
|
220
|
-
while (sibling) {
|
|
221
|
-
if (sibling.tagName === 'SCRIPT' && sibling.getAttribute('type') === 'module') {
|
|
222
|
-
break;
|
|
223
|
-
}
|
|
224
|
-
sibling.setAttribute('data-vibe-component-id', componentId);
|
|
225
|
-
rewriteThisBindings(sibling);
|
|
226
|
-
sibling = sibling.nextElementSibling;
|
|
227
|
-
}
|
|
347
|
+
// Tag siblings + rewrite this. bindings using shared helper. Runs
|
|
348
|
+
// BEFORE script.remove() so nextElementSibling is valid.
|
|
349
|
+
tagScriptSiblings(script, componentId);
|
|
228
350
|
|
|
229
351
|
// Remove script from temp (we executed it manually)
|
|
230
352
|
script.remove();
|
|
@@ -232,106 +354,8 @@ const processSingle = (el, debug) => {
|
|
|
232
354
|
|
|
233
355
|
// Finalize: props, slots, DOM replacement
|
|
234
356
|
const finalize = () => {
|
|
235
|
-
//
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
// Replace props — both exact @[propName] bindings and identifiers
|
|
239
|
-
// appearing inside larger expressions like @[Math.floor(propName / 100)]
|
|
240
|
-
// or conditional/iteration comment expressions like <!-- if propName -->.
|
|
241
|
-
// Matches any <!-- if ... -->, <!-- else if ... -->, or <!-- each ... -->
|
|
242
|
-
// so prop identifiers resolve there the same way they do inside @[...].
|
|
243
|
-
const DIRECTIVE_COMMENT_REGEX = /<!--\s*(if|else if|each)\s+([^]*?)\s*-->/g;
|
|
244
|
-
// Event handler attributes (onclick, oninput, onchange, …). Prop
|
|
245
|
-
// identifiers inside these are JS expressions that read/write state —
|
|
246
|
-
// substituting them gives components natural two-way binding:
|
|
247
|
-
// child writes `$.value = x`, parent passed `value="@[email]"`,
|
|
248
|
-
// substitution turns it into `$.email = x`.
|
|
249
|
-
const EVENT_ATTR_REGEX = /\bon(\w+)="([^"]*)"/g;
|
|
250
|
-
|
|
251
|
-
Object.entries(props).forEach(([propName, propValue]) => {
|
|
252
|
-
const bindingMatch = propValue.match(/^@\[(.+)\]$/);
|
|
253
|
-
const exactPattern = new RegExp(`@\\[${escapeRegex(propName)}\\]`, 'g');
|
|
254
|
-
// Word-boundary identifier match for substitution inside expressions.
|
|
255
|
-
// The lookbehind also excludes `.` so property accesses like `_c0.email`
|
|
256
|
-
// aren't double-substituted when the prop name is `email` — only
|
|
257
|
-
// standalone identifiers match, not property tails.
|
|
258
|
-
const idRegex = new RegExp(
|
|
259
|
-
`(?<![a-zA-Z0-9_$\\.])${escapeRegex(propName)}(?![a-zA-Z0-9_$])`,
|
|
260
|
-
'g'
|
|
261
|
-
);
|
|
262
|
-
|
|
263
|
-
const substituteInExpr = (expr, replacement) => expr.replace(idRegex, replacement);
|
|
264
|
-
|
|
265
|
-
if (bindingMatch) {
|
|
266
|
-
// Reactive prop: @[propName] → @[path], identifiers inside expressions → (path)
|
|
267
|
-
const path = bindingMatch[1];
|
|
268
|
-
transformedHtml = transformedHtml.replace(exactPattern, `@[${path}]`);
|
|
269
|
-
transformedHtml = transformedHtml.replace(BINDING_REGEX, (match, expr) => {
|
|
270
|
-
const rewritten = substituteInExpr(expr, `(${path})`);
|
|
271
|
-
return rewritten === expr ? match : `@[${rewritten}]`;
|
|
272
|
-
});
|
|
273
|
-
transformedHtml = transformedHtml.replace(
|
|
274
|
-
DIRECTIVE_COMMENT_REGEX,
|
|
275
|
-
(match, kw, expr) => {
|
|
276
|
-
const replacement = kw === 'each' ? path : `(${path})`;
|
|
277
|
-
const rewritten = substituteInExpr(expr, replacement);
|
|
278
|
-
return rewritten === expr ? match : `<!-- ${kw} ${rewritten} -->`;
|
|
279
|
-
}
|
|
280
|
-
);
|
|
281
|
-
// Event handlers: rewrite `$.propName` → `$.path` for two-way
|
|
282
|
-
// binding. Only targets state access (`$.xxx`) so DOM properties
|
|
283
|
-
// like `this.value` stay untouched.
|
|
284
|
-
const stateRegex = new RegExp(`\\$\\.${escapeRegex(propName)}(?![a-zA-Z0-9_$])`, 'g');
|
|
285
|
-
transformedHtml = transformedHtml.replace(EVENT_ATTR_REGEX, (match, evName, body) => {
|
|
286
|
-
const rewritten = body.replace(stateRegex, `$.${path}`);
|
|
287
|
-
return rewritten === body ? match : `on${evName}="${rewritten}"`;
|
|
288
|
-
});
|
|
289
|
-
} else {
|
|
290
|
-
// Static prop: @[propName] → literal value (raw),
|
|
291
|
-
// identifiers inside expressions → JS literal.
|
|
292
|
-
transformedHtml = transformedHtml.replace(exactPattern, propValue);
|
|
293
|
-
const isNumeric =
|
|
294
|
-
typeof propValue === 'string' && /^-?\d+(\.\d+)?(e[+-]?\d+)?$/i.test(propValue);
|
|
295
|
-
const isBooleanAttr = propValue === '';
|
|
296
|
-
const literal = isBooleanAttr
|
|
297
|
-
? 'true'
|
|
298
|
-
: typeof propValue === 'string' && !isNumeric
|
|
299
|
-
? JSON.stringify(propValue)
|
|
300
|
-
: String(propValue);
|
|
301
|
-
transformedHtml = transformedHtml.replace(BINDING_REGEX, (match, expr) => {
|
|
302
|
-
const rewritten = substituteInExpr(expr, literal);
|
|
303
|
-
return rewritten === expr ? match : `@[${rewritten}]`;
|
|
304
|
-
});
|
|
305
|
-
transformedHtml = transformedHtml.replace(
|
|
306
|
-
DIRECTIVE_COMMENT_REGEX,
|
|
307
|
-
(match, kw, expr) => {
|
|
308
|
-
const rewritten = substituteInExpr(expr, literal);
|
|
309
|
-
return rewritten === expr ? match : `<!-- ${kw} ${rewritten} -->`;
|
|
310
|
-
}
|
|
311
|
-
);
|
|
312
|
-
// Event handlers: rewrite `$.propName` → `$.literal` for static props
|
|
313
|
-
const stateRegex = new RegExp(`\\$\\.${escapeRegex(propName)}(?![a-zA-Z0-9_$])`, 'g');
|
|
314
|
-
transformedHtml = transformedHtml.replace(EVENT_ATTR_REGEX, (match, evName, body) => {
|
|
315
|
-
const rewritten = body.replace(stateRegex, `$.${literal}`);
|
|
316
|
-
return rewritten === body ? match : `on${evName}="${rewritten}"`;
|
|
317
|
-
});
|
|
318
|
-
}
|
|
319
|
-
});
|
|
320
|
-
|
|
321
|
-
// Replace <slot> with children wrapped in <slot> boundary.
|
|
322
|
-
// Allow attributes on the slot (e.g. data-vibe-component-id added by
|
|
323
|
-
// sibling tagging when a <slot> happens to be a direct sibling of the
|
|
324
|
-
// component's <script>).
|
|
325
|
-
if (children) {
|
|
326
|
-
transformedHtml = transformedHtml.replace(
|
|
327
|
-
/<slot(\s[^>]*)?>\s*<\/slot>/g,
|
|
328
|
-
(_, attrs) => `<slot${attrs || ''}>${children}</slot>`
|
|
329
|
-
);
|
|
330
|
-
transformedHtml = transformedHtml.replace(
|
|
331
|
-
/<slot(\s[^>]*)?\/>/g,
|
|
332
|
-
(_, attrs) => `<slot${attrs || ''}>${children}</slot>`
|
|
333
|
-
);
|
|
334
|
-
}
|
|
357
|
+
// Delegate prop substitution + slot inlining to shared helper.
|
|
358
|
+
const transformedHtml = renderPropsAndSlot(temp, props, children);
|
|
335
359
|
|
|
336
360
|
// Clean up pending fetch tracker
|
|
337
361
|
pendingFetches.delete(el);
|
|
@@ -350,6 +374,24 @@ const processSingle = (el, debug) => {
|
|
|
350
374
|
}
|
|
351
375
|
|
|
352
376
|
newWrapper.innerHTML = transformedHtml;
|
|
377
|
+
// Stash the raw source so the HMR plugin can establish a baseline
|
|
378
|
+
// script hash on the very first update — without this, the first
|
|
379
|
+
// save after page load would always fall back to re-mount (since
|
|
380
|
+
// the plugin would have nothing to compare against). Vibe itself
|
|
381
|
+
// never reads this; it's purely for the plugin spy.
|
|
382
|
+
newWrapper._vibeRawSource = html;
|
|
383
|
+
// Transfer iteration-prop registry ownership from the soon-to-be-
|
|
384
|
+
// detached `<component src>` to the new wrapper. The detached element
|
|
385
|
+
// would otherwise trigger releaseOrphanedIterationProps and free the
|
|
386
|
+
// registry slots that the inlined template's bindings still reference,
|
|
387
|
+
// causing every `@[window.__vibeIterProps._pN]` to resolve to undefined
|
|
388
|
+
// on the next hydrate.
|
|
389
|
+
if (el._vibeIterPropIds) {
|
|
390
|
+
newWrapper._vibeIterPropIds = el._vibeIterPropIds;
|
|
391
|
+
newWrapper.setAttribute('data-vibe-iter-prop', '');
|
|
392
|
+
el._vibeIterPropIds = null;
|
|
393
|
+
el.removeAttribute('data-vibe-iter-prop');
|
|
394
|
+
}
|
|
353
395
|
el.replaceWith(newWrapper);
|
|
354
396
|
debugLog(PHASE_FETCH, src, debug);
|
|
355
397
|
|
package/runtime/constants.js
CHANGED
|
@@ -224,3 +224,15 @@ export const CONDITIONAL_REGEX = /^if\s+(.+)$/;
|
|
|
224
224
|
|
|
225
225
|
// Regex for detecting start of conditional comment
|
|
226
226
|
export const CONDITIONAL_START_REGEX = /^if\s+/;
|
|
227
|
+
|
|
228
|
+
// Regex for rewriting component-local `this.X` references to the component's
|
|
229
|
+
// state path. Captures the leading identifier only — any trailing `.Y.Z`
|
|
230
|
+
// chain stays attached after replacement, so `this.user.name` becomes
|
|
231
|
+
// `<componentId>.user.name`. Used in expression bodies (bindings, event
|
|
232
|
+
// handlers, conditional/iteration directives).
|
|
233
|
+
export const THIS_PROP_REGEX = /\bthis\.(\w+)/g;
|
|
234
|
+
|
|
235
|
+
// Regex for rewriting `$.this.X` writes (proxy assignment from event handlers)
|
|
236
|
+
// to the component's write path. Same prefix-only semantics as THIS_PROP_REGEX
|
|
237
|
+
// — `$.this.user.name = x` becomes `$.<componentId>.user.name = x`.
|
|
238
|
+
export const STATE_THIS_PROP_REGEX = /\$\.this\.(\w+)/g;
|
package/runtime/hydrate.js
CHANGED
|
@@ -87,17 +87,24 @@ export default (affected, state, manifest = {}, oldState = {}) => {
|
|
|
87
87
|
// Attribute: Truthful DOM representation, enables compiler snapshots via outerHTML
|
|
88
88
|
const expr = isPureBinding[1];
|
|
89
89
|
const value = evalInScope(expr, effectiveState, element);
|
|
90
|
-
element[attrName] = value;
|
|
90
|
+
if (element[attrName] !== value) element[attrName] = value;
|
|
91
91
|
if (value !== undefined && value !== null) {
|
|
92
|
-
|
|
92
|
+
const str = String(value);
|
|
93
|
+
if (element.getAttribute(attrName) !== str) element.setAttribute(attrName, str);
|
|
93
94
|
}
|
|
94
95
|
} else if (!isValueAttr && isPureBinding) {
|
|
95
|
-
// Boolean-like attributes: add or remove based on truthiness
|
|
96
|
+
// Boolean-like attributes: add or remove based on truthiness.
|
|
97
|
+
// Compare both presence AND value — initial hydration starts with
|
|
98
|
+
// the raw `@[...]` binding text as the attribute value, so
|
|
99
|
+
// `hasAttribute` alone isn't enough to know the canonical state is
|
|
100
|
+
// already set.
|
|
96
101
|
const expr = isPureBinding[1];
|
|
97
102
|
const value = evalInScope(expr, effectiveState, element);
|
|
98
103
|
if (value) {
|
|
99
|
-
element.
|
|
100
|
-
|
|
104
|
+
if (element.getAttribute(attrName) !== '') {
|
|
105
|
+
element.setAttribute(attrName, '');
|
|
106
|
+
}
|
|
107
|
+
} else if (element.hasAttribute(attrName)) {
|
|
101
108
|
element.removeAttribute(attrName);
|
|
102
109
|
}
|
|
103
110
|
} else {
|
|
@@ -105,7 +112,9 @@ export default (affected, state, manifest = {}, oldState = {}) => {
|
|
|
105
112
|
const newValue = attrValue.replace(BINDING_REGEX, (_, expr) => {
|
|
106
113
|
return evalInScope(expr, effectiveState, element);
|
|
107
114
|
});
|
|
108
|
-
element.
|
|
115
|
+
if (element.getAttribute(attrName) !== newValue) {
|
|
116
|
+
element.setAttribute(attrName, newValue);
|
|
117
|
+
}
|
|
109
118
|
}
|
|
110
119
|
} catch (e) {}
|
|
111
120
|
return;
|
|
@@ -128,11 +137,19 @@ export default (affected, state, manifest = {}, oldState = {}) => {
|
|
|
128
137
|
});
|
|
129
138
|
|
|
130
139
|
// If we have a direct reference to the text node, update it specifically
|
|
131
|
-
// This prevents wiping child elements when parent has both text and element children
|
|
140
|
+
// This prevents wiping child elements when parent has both text and element children.
|
|
141
|
+
// Skip the write when the value is already correct — the browser would repaint
|
|
142
|
+
// (and any in-flight CSS transition on the row would jitter) even when no value
|
|
143
|
+
// actually changed. Reactivity coverage is unchanged: the only state changes that
|
|
144
|
+
// hit this path either produce a new value (still applied) or don't (now no-op).
|
|
145
|
+
// Skip the write when the value is already correct — the browser would
|
|
146
|
+
// repaint (and any in-flight CSS transition would jitter) even when no
|
|
147
|
+
// value actually changed. Reactivity coverage is unchanged: state changes
|
|
148
|
+
// that produce a new value still apply; state changes that don't are now
|
|
149
|
+
// proper no-ops at the DOM layer.
|
|
132
150
|
if (textNode && textNode.nodeType === 3) {
|
|
133
|
-
textNode.textContent = toReplace;
|
|
134
|
-
} else {
|
|
135
|
-
// Fallback: element has no children or is just a text container
|
|
151
|
+
if (textNode.textContent !== toReplace) textNode.textContent = toReplace;
|
|
152
|
+
} else if (element.textContent !== toReplace) {
|
|
136
153
|
element.textContent = toReplace;
|
|
137
154
|
}
|
|
138
155
|
} catch (e) {}
|
package/runtime/index.js
CHANGED
|
@@ -4,7 +4,7 @@ import createManifest from './manifest.js';
|
|
|
4
4
|
import hydrate from './hydrate.js';
|
|
5
5
|
import affected from './affected.js';
|
|
6
6
|
import { deepMerge, hash } from './utils.js';
|
|
7
|
-
import { renderAllIterations, setRenderAllConditionals } from './iterate.js';
|
|
7
|
+
import { renderAllIterations, setRenderAllConditionals, releaseOrphanedIterationProps } from './iterate.js';
|
|
8
8
|
import { renderAllConditionals, branchNodeRegistry, managedNodes } from './conditionals.js';
|
|
9
9
|
import {
|
|
10
10
|
NON_REACTIVE_ELEMENTS,
|
|
@@ -21,7 +21,7 @@ import {
|
|
|
21
21
|
PHASE_READY,
|
|
22
22
|
DEHYDRATE_CLASS_OR_ATTR,
|
|
23
23
|
} from './constants.js';
|
|
24
|
-
import { processComponent, abortComponentFetch, collectComponentIds, releaseOrphanedComponentState } from './component.js';
|
|
24
|
+
import { processComponent, abortComponentFetch, collectComponentIds, releaseOrphanedComponentState, renderComponentTemplate } from './component.js';
|
|
25
25
|
import { debugLog } from './debug.js';
|
|
26
26
|
import { shouldCleanup, cleanup } from './cleanup.js';
|
|
27
27
|
import { reconcile } from './reconcile.js';
|
|
@@ -634,6 +634,16 @@ const main = (s, config = {}, stringSelector = '') => {
|
|
|
634
634
|
enumerable: false,
|
|
635
635
|
});
|
|
636
636
|
|
|
637
|
+
// Pure-render path for surgical component HMR. Given raw component template
|
|
638
|
+
// HTML, callsite props, slot HTML, and existing componentIds, returns the
|
|
639
|
+
// processed HTML string the plugin's HMR handler can hand to $.reconcile.
|
|
640
|
+
// Scripts are NOT executed — callers use this only when they've verified
|
|
641
|
+
// script contents haven't changed (so registered state is still valid).
|
|
642
|
+
Object.defineProperty($, 'renderComponent', {
|
|
643
|
+
value: renderComponentTemplate,
|
|
644
|
+
enumerable: false,
|
|
645
|
+
});
|
|
646
|
+
|
|
637
647
|
// Initial hydration - pass plain values so iteration can do reference comparison
|
|
638
648
|
const initialState = extractPlainValue($);
|
|
639
649
|
const affectedElements = affected(parsedTree, initialState, initialState);
|
|
@@ -864,6 +874,9 @@ const main = (s, config = {}, stringSelector = '') => {
|
|
|
864
874
|
|
|
865
875
|
// CLEANUP OF CURRENT STATE
|
|
866
876
|
releaseOrphanedComponentState(removedComponentIds);
|
|
877
|
+
mutations.forEach(({ removedNodes: removedNodesList }) => {
|
|
878
|
+
releaseOrphanedIterationProps(removedNodesList);
|
|
879
|
+
});
|
|
867
880
|
|
|
868
881
|
// Fire hooks once after all mutations are processed (not per-node)
|
|
869
882
|
if (hadChanges) {
|
package/runtime/iterate.js
CHANGED
|
@@ -35,8 +35,18 @@ const hasNestedStructures = (tree) => {
|
|
|
35
35
|
return false;
|
|
36
36
|
};
|
|
37
37
|
|
|
38
|
+
// Batch render template-literal-interpolates `@[expr]` as `${expr}` — that
|
|
39
|
+
// stringifies object/array values, which breaks `<component src>` props that
|
|
40
|
+
// rely on resolveIterationComponentProps to stash non-primitives in the
|
|
41
|
+
// registry. Templates carrying any `<component src>` go through the
|
|
42
|
+
// clone+hydrate path instead.
|
|
43
|
+
const hasComponentSrc = (templateEl) =>
|
|
44
|
+
!!templateEl.querySelector?.('component[src], div.component[src]');
|
|
45
|
+
|
|
38
46
|
const canUseBatchRender = (template) =>
|
|
39
|
-
!hasNestedStructures(template) &&
|
|
47
|
+
!hasNestedStructures(template) &&
|
|
48
|
+
template.element.children.length <= 1 &&
|
|
49
|
+
!hasComponentSrc(template.element);
|
|
40
50
|
|
|
41
51
|
const compileBatchFn = (template, itemAlias, indexAlias, stateKeys) => {
|
|
42
52
|
const templateHtml = template.element.innerHTML.trim();
|
|
@@ -58,14 +68,49 @@ const compileBatchFn = (template, itemAlias, indexAlias, stateKeys) => {
|
|
|
58
68
|
);
|
|
59
69
|
};
|
|
60
70
|
|
|
71
|
+
// Registry for non-primitive iteration prop snapshots. Lives on `window` (not
|
|
72
|
+
// on `$`) so it doesn't pollute user-visible state enumeration, but is still
|
|
73
|
+
// reachable from binding expressions because `window` is in evalInScope's
|
|
74
|
+
// known-globals list. Each entry is freed when the owning component element
|
|
75
|
+
// is detached (see releaseOrphanedIterationProps).
|
|
76
|
+
let __vibeIterPropCounter = 0;
|
|
77
|
+
const ensureIterPropsRegistry = () => {
|
|
78
|
+
if (!window.__vibeIterProps) window.__vibeIterProps = {};
|
|
79
|
+
return window.__vibeIterProps;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
// Walk a removed subtree and free any iteration-prop registry slots stashed
|
|
83
|
+
// on `<component>` elements inside it. Called from the mutation-observer
|
|
84
|
+
// cleanup path after DOM detachment.
|
|
85
|
+
export const releaseOrphanedIterationProps = (nodes) => {
|
|
86
|
+
if (!window.__vibeIterProps) return;
|
|
87
|
+
for (const node of nodes) {
|
|
88
|
+
if (node.nodeType !== 1) continue;
|
|
89
|
+
const free = (el) => {
|
|
90
|
+
const ids = el._vibeIterPropIds;
|
|
91
|
+
if (!ids) return;
|
|
92
|
+
for (const id of ids) delete window.__vibeIterProps[id];
|
|
93
|
+
el._vibeIterPropIds = null;
|
|
94
|
+
};
|
|
95
|
+
free(node);
|
|
96
|
+
node.querySelectorAll?.('[data-vibe-iter-prop]').forEach(free);
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
|
|
61
100
|
// For <component src> elements inside an iteration instance, evaluate any
|
|
62
101
|
// `@[expr]` attribute bindings against the iteration's scoped state and replace
|
|
63
|
-
// them with the resolved
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
//
|
|
102
|
+
// them with the resolved value. Primitives stringify into the attribute as
|
|
103
|
+
// before. Non-primitives (objects, arrays) snapshot into the registry and the
|
|
104
|
+
// attribute becomes a binding into that slot — preserving live object/array
|
|
105
|
+
// access for the child component's template (`@[prop.x]`,
|
|
106
|
+
// `<!-- each prop as item -->`, etc.).
|
|
107
|
+
//
|
|
108
|
+
// Component[src] attributes intentionally bypass hydrate (parse.js) so they
|
|
109
|
+
// reach processComponent as bindings — but bindings that depend on
|
|
110
|
+
// iteration-local vars (item, index) can't resolve later when processComponent
|
|
111
|
+
// inlines the component, since by then iteration scope is gone. Only called
|
|
112
|
+
// from iteration code paths; conditionals don't need this because their branch
|
|
113
|
+
// content is registered in the global manifest and reacts to state updates.
|
|
69
114
|
const resolveIterationComponentProps = (nodes, scopedState) => {
|
|
70
115
|
for (let n = 0; n < nodes.length; n++) {
|
|
71
116
|
const node = nodes[n];
|
|
@@ -83,8 +128,16 @@ const resolveIterationComponentProps = (nodes, scopedState) => {
|
|
|
83
128
|
if (!match) continue;
|
|
84
129
|
try {
|
|
85
130
|
const value = evalInScope(match[1], scopedState, el);
|
|
86
|
-
if (value
|
|
131
|
+
if (value === undefined) continue;
|
|
132
|
+
if (value === null || (typeof value !== 'object' && typeof value !== 'function')) {
|
|
87
133
|
el.setAttribute(attr.name, String(value));
|
|
134
|
+
} else {
|
|
135
|
+
const registry = ensureIterPropsRegistry();
|
|
136
|
+
const id = `_p${__vibeIterPropCounter++}`;
|
|
137
|
+
registry[id] = value;
|
|
138
|
+
el.setAttribute(attr.name, `@[window.__vibeIterProps.${id}]`);
|
|
139
|
+
el.setAttribute('data-vibe-iter-prop', '');
|
|
140
|
+
(el._vibeIterPropIds = el._vibeIterPropIds || []).push(id);
|
|
88
141
|
}
|
|
89
142
|
} catch {
|
|
90
143
|
// Leave binding raw — processComponent will handle it as a binding
|
package/runtime/parse.js
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
CONDITIONAL_REGEX,
|
|
7
7
|
DOM_ELEMENT_PROPERTIES,
|
|
8
8
|
DEHYDRATE_CLASS_OR_ATTR,
|
|
9
|
+
THIS_PROP_REGEX,
|
|
9
10
|
} from './constants.js';
|
|
10
11
|
|
|
11
12
|
// Walks up the DOM for the nearest component wrapper tagged by component.js.
|
|
@@ -49,7 +50,7 @@ const captureAttributeBindings = (element) => {
|
|
|
49
50
|
if (attr.name.startsWith('on') && attr.value.includes('this.')) {
|
|
50
51
|
const componentId = findComponentIdForElement(element);
|
|
51
52
|
if (componentId) {
|
|
52
|
-
const rewritten = attr.value.replace(
|
|
53
|
+
const rewritten = attr.value.replace(THIS_PROP_REGEX, (match, prop) => {
|
|
53
54
|
return DOM_ELEMENT_PROPERTIES.has(prop) ? match : `$['${componentId}'].${prop}`;
|
|
54
55
|
});
|
|
55
56
|
element.setAttribute(attr.name, rewritten);
|
package/runtime/utils.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { THIS_PROP_REGEX } from './constants.js';
|
|
2
|
+
|
|
1
3
|
// Fast incrementing counter instead of expensive random hash
|
|
2
4
|
let hashCounter = 0;
|
|
3
5
|
export const hash = () => `_${hashCounter++}`;
|
|
@@ -43,7 +45,7 @@ export const evalInScope = (expr, state, element = null) => {
|
|
|
43
45
|
const componentId = findComponentIdForElement(element);
|
|
44
46
|
if (componentId) {
|
|
45
47
|
// Replace this.property with $['componentId'].property
|
|
46
|
-
normalized = normalized.replace(
|
|
48
|
+
normalized = normalized.replace(THIS_PROP_REGEX, `$['${componentId}'].$1`);
|
|
47
49
|
}
|
|
48
50
|
}
|
|
49
51
|
|