@ape-egg/vibe 1.9.6 → 1.9.8
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 +37 -0
- package/README.md +34 -1
- package/compiler/native/vibe-compiler-darwin-arm64 +0 -0
- package/compiler/native/vibe-compiler-linux-x64 +0 -0
- package/compiler/src/compiler/value_stamper.rs +27 -1
- package/package.json +1 -1
- package/runtime/affected.js +109 -0
- package/runtime/hydrate.js +31 -3
- package/runtime/index.js +27 -1
- package/runtime/iterate.js +25 -12
- package/runtime/loop-scope.js +33 -0
- package/runtime/raw-html.js +15 -0
- package/runtime/utils.js +39 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,42 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.9.8] - 2026-06-01
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **Raw-HTML rendering via `$.unsafe(expr)`** (`runtime/raw-html.js` (new), `runtime/hydrate.js`, `runtime/index.js`, `runtime/utils.js`, `compiler/src/compiler/value_stamper.rs`) — a binding that renders a trusted string as real markup instead of escaping it; Vibe's equivalent of Svelte `{@html}` / Vue `v-html` / React `dangerouslySetInnerHTML`. `$.unsafe(str)` wraps a string in a `RawHtml` marker. When the binding is the **sole content of its element** (`<p>@[$.unsafe(desc)]</p>`), the runtime sets `innerHTML` from the string and marks the injected subtree opaque (`managedNodes`) so the MutationObserver never re-walks it — injected markup is **inert**, matching Svelte. Mixed into surrounding text it falls back to escaped literal text (via the marker's `toString()`). Reactive in every scope (top-level, iterations, conditionals, components). Compiled mode paints the markup raw at stamp time (`value_stamper.rs` defines `$.unsafe` in the QuickJS context) while the manifest preserves the `@[$.unsafe(...)]` marker for runtime re-hydration. **Trusted input only — no sanitizing.**
|
|
8
|
+
- Test: `tests/unit/raw-html.test.js`, `tests/e2e/unsafe-html.spec.js` (pure render, escaped-text fallback, reactivity, in-iteration, in-conditional, inert injected markup), and `$.unsafe` stamping in `tests/compiler/runtime`.
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- **`$` inside expressions must read from the per-cycle state, not the live root** (`runtime/utils.js`) — surfaced while wiring `$.unsafe`. The reactivity engine detects change by evaluating each expression against an old snapshot and a new snapshot and comparing; binding `$` to the live root proxy made both reads identical, silently defeating change detection for any expression reading through `$` or `this.` (which compiles to `$['id']…`) — e.g. a computed iteration array or conditional gated on component state would stop re-rendering. `evalInScope` now resolves `$` to the same state object the diff cycle is evaluating, and reaches the root's non-enumerable helper methods (`unsafe`, `on`, `reconcile`) via a thin per-state fallback proxy so `$.unsafe` stays callable from plain snapshots on the update path. State reads stay on the snapshot (diffable); only missing method names fall through to root. Scoped states already delegate to root, so the hot path is unaffected.
|
|
13
|
+
- Test: `tests/unit/dollar-binding-scope.test.js` (a `$`-expression yields different values for two snapshots → diffable; `$.unsafe` resolves from a plain snapshot lacking it)
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## [1.9.7] - 2026-06-01
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
|
|
21
|
+
- **Conditional inside an iteration row didn't react to global-state changes** (`runtime/affected.js`, `runtime/hydrate.js`) — a `<!-- if … -->` (or `<!-- if … --><!-- else --><!-- /if -->`) living inside an `<!-- each -->` row, whose expression depended on a global rather than the loop item, stayed frozen on the branch active at mount time when the global later changed and the row's `item` was unchanged. Two compounding gaps, fixed at the right layer in two steps:
|
|
22
|
+
- **Branch swap re-evaluated against the wrong scope** (`affected.js`, `hydrate.js`) — `affected` correctly detected the change against the row's merged state, but the affected entry it pushed carried only the node. `hydrate` then called `updateConditional` with the cycle's top-level state, which had no `item`, so the branch eval evaluated `selected.includes(item.id)` etc. against `item === undefined`. Rows mounted on the *if* branch happened to flip to *else* (often correct by coincidence); rows mounted on the *else* branch stayed stuck. `affected.js` now attaches the same `state`/`newState` pair the change was detected against as `scopedState`/`oldScopedState` on the conditional entry — merged row state inside an iteration, the cycle's top-level state outside — and `hydrate.js`'s conditional handler uses them when present.
|
|
23
|
+
- **Inlined per-row components were invisible to the iteration recursion** (`affected.js`) — a `<component src>` mounted per iteration row has its parsed tree stashed on the post-process wrapper as `_vibeIterTree`, not as a child of `instance.tree`. `hydrateInlinedIterationComponents` already re-hydrates those trees, but it only runs from `updateInstance`, which only fires when the iteration's array changes. On a pure global-state change the array is unchanged, so the inlined component trees were never visited — a row-internal `<!-- if -->` *without* an else, gated on a global and living inside a per-row component (the Battle Brawlers `<brawler-menu>`/`<brawler-activity>` shape), wouldn't tear down. `affected.js`'s iteration recursion now also descends into every `_vibeIterTree` reachable from each instance's cloned nodes with the same merged scoped state, so the conditional flows through the normal affected → hydrate → `updateConditional` path and the existing `branches.else === null` handling triggers `unmountBranch`. Same path covers any other binding/conditional living inside a per-row component, so they all now react to global state changes too.
|
|
24
|
+
- Test: `tests/e2e/conditional-in-iteration-global.spec.js` (7 tests: swap of `selected.includes(item.id)` in both directions; dynamic property lookup; compound gate with both halves; `if`-without-else direct shape; compound `if`-without-else; nested-property gate `combat.duration !== 0 && busyMap[item.id]`; and the per-row-component repro toggling `$.showMenu` through a mount→teardown→re-mount cycle)
|
|
25
|
+
|
|
26
|
+
- **Loop-scoped `on*` handler inside a row-internal `<!-- if -->` resolved the previous item after the row updated** (`runtime/loop-scope.js`) — a `<!-- if -->` inside an iteration mounts its branch content separately from the row's `clonedNodes` and stamps `__vibeScope` once at mount time. When the row was later updated in place (its DOM reused for a new item), the iteration refreshed its own root stamp but the branch root kept the stale mount-time stamp, and `resolveScope` hit that first on the walk up — so a handler inside the conditional resolved the previous item. `stampInstanceScopes` now walks the instance's parsed tree and re-stamps every active conditional-branch root with the same fresh scope. Nested loops are skipped — each iteration node already manages its own instances' scopes.
|
|
27
|
+
- Test: `tests/e2e/loop-scoped-handlers.spec.js` (+ unit coverage in `tests/unit/loop-scope.test.js`)
|
|
28
|
+
|
|
29
|
+
- **Treeless (batch-rendered) iteration was rebuilt on every unrelated state change** (`runtime/iterate.js`) — `affected.js` conservatively flags a treeless iteration on any state change (it can't walk per-row trees to know what they depend on), and `updateIteration` responded with an unconditional `bulkReplace`. A background tick (e.g. a 250 ms client clock) therefore recreated every row on every flush, dropping imperatively-attached listeners (tooltip mouseleave) and any in-progress drag. `renderBatch` now remembers the rendered HTML on the iteration's runtime; before tearing down, `updateIteration` re-runs the batch — if the output is byte-identical to the previous one, the rows don't depend on what changed, and the existing DOM nodes are kept. Skipped when the template has DOM-property writes (`value`/`checked`/`selected`), which aren't reflected in the HTML string.
|
|
30
|
+
- Test: `tests/e2e/iteration-treeless-unrelated-update.spec.js`
|
|
31
|
+
|
|
32
|
+
- **Inlined component's literal-wrap `<!-- each [prop] as alias -->` froze on the original snapshot** (`runtime/iterate.js`) — when an inlined component received an object prop and wrapped it in a literal-array iteration to render its fields, the inner iteration's `forceRegistryBackedIterationUpdates` calls `updateIteration` with `oldState === newState`, but the old/new array eval produces fresh arrays containing the just-rewritten registry value, so the diff saw identical contents and emitted no UPDATE. `updateIteration` now treats `oldState === newState` as a "trust the rendered snapshot" signal — the previously rendered items on iteration instances are the only honest "old" when the registry side-effect is the only mutation — and correctly emits per-row updates.
|
|
33
|
+
|
|
34
|
+
### Added
|
|
35
|
+
|
|
36
|
+
- **`<component src>` callsite receives its componentId** (`runtime/component.js`) — `processSingle` now returns the resolved `componentId` so `const id = component(state)` inside a `<script type="module">` block of a src-fetched component resolves to a non-undefined id. Matches the public component.js contract; without it, downstream `$[id]` was silently undefined for src-fetched components.
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
3
40
|
## [1.9.6] - 2026-05-25
|
|
4
41
|
|
|
5
42
|
### Fixed
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Vibe
|
|
2
2
|
|
|
3
|
-
**Version 1.9.
|
|
3
|
+
**Version 1.9.8 (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
|
|
|
@@ -105,6 +105,26 @@ Vibe supports bindings in three positions:
|
|
|
105
105
|
<!-- /each -->
|
|
106
106
|
```
|
|
107
107
|
|
|
108
|
+
The "array" position accepts any JS expression evaluated in scope, not just a state path:
|
|
109
|
+
|
|
110
|
+
```html
|
|
111
|
+
<!-- each items.filter(i => i.active) as item -->...<!-- /each -->
|
|
112
|
+
<!-- each Array.from({ length: count }) as n, index -->...<!-- /each -->
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
**Keyed iteration** — give each row a stable identity with `(keyExpr)` so survivors keep their DOM (and listeners / animation state) when the list reorders or items are removed. The key comes before the optional index. Without a key, Vibe falls back to an index-coupled hash and bulk-re-renders the tail on reorder.
|
|
116
|
+
|
|
117
|
+
```html
|
|
118
|
+
<!-- each rows as row (row.id) -->
|
|
119
|
+
<li>@[row.label]</li>
|
|
120
|
+
<!-- /each -->
|
|
121
|
+
|
|
122
|
+
<!-- key + index -->
|
|
123
|
+
<!-- each rows as row (row.id), index -->
|
|
124
|
+
<li>@[index]: @[row.label]</li>
|
|
125
|
+
<!-- /each -->
|
|
126
|
+
```
|
|
127
|
+
|
|
108
128
|
Nested iteration with dot paths:
|
|
109
129
|
|
|
110
130
|
```html
|
|
@@ -218,6 +238,19 @@ Skip reactive processing for an element:
|
|
|
218
238
|
<code vibe-dehydrate>@[this] displays literally</code>
|
|
219
239
|
```
|
|
220
240
|
|
|
241
|
+
### Raw HTML (`$.unsafe`)
|
|
242
|
+
|
|
243
|
+
`@[expr]` escapes its output (`textContent`) — safe by default. To render **trusted** markup instead, wrap the value in `$.unsafe(...)`; the binding sets `innerHTML`. This is Vibe's equivalent of Svelte `{@html}` / Vue `v-html` / React `dangerouslySetInnerHTML`.
|
|
244
|
+
|
|
245
|
+
```html
|
|
246
|
+
<p>@[$.unsafe(description)]</p>
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
- The binding **must be the sole content of its element** (innerHTML semantics). Mixed into surrounding text, it falls back to escaped literal text.
|
|
250
|
+
- Injected markup is **inert** — `@[...]` / `<!-- if -->` / `<!-- each -->` inside it are not processed (matches Svelte `{@html}`); the subtree is opaque to the MutationObserver.
|
|
251
|
+
- Fully **reactive** — re-renders on value change; works in iterations, conditionals, and components. Compiled mode paints the markup at stamp time and re-hydrates at runtime.
|
|
252
|
+
- **Trusted input only** — no sanitizing. Don't pass user-supplied strings.
|
|
253
|
+
|
|
221
254
|
### Internal Names (don't collide)
|
|
222
255
|
|
|
223
256
|
These are used by the runtime — don't repurpose them in your code:
|
|
Binary file
|
|
Binary file
|
|
@@ -64,7 +64,14 @@ impl<'a> ValueStamper<'a> {
|
|
|
64
64
|
context.with(|ctx| -> Result<(), String> {
|
|
65
65
|
let state_str = serde_json::to_string(state)
|
|
66
66
|
.map_err(|e| format!("Failed to serialize state: {}", e))?;
|
|
67
|
-
|
|
67
|
+
// Expose state both as bare globals (via Object.assign) and as a
|
|
68
|
+
// persistent `$` global carrying the runtime `unsafe` helper, so a
|
|
69
|
+
// `@[$.unsafe(expr)]` binding stamps its trusted string raw at build
|
|
70
|
+
// time — mirroring the runtime, where `$.unsafe` marks raw HTML.
|
|
71
|
+
ctx.eval::<(), _>(format!(
|
|
72
|
+
"const $ = {}; Object.assign(globalThis, $); $.unsafe = (s) => s; globalThis.$ = $",
|
|
73
|
+
state_str
|
|
74
|
+
))
|
|
68
75
|
.map_err(|e| format!("Failed to set up state in QuickJS: {:?}", e))?;
|
|
69
76
|
Ok(())
|
|
70
77
|
})?;
|
|
@@ -714,6 +721,25 @@ mod tests {
|
|
|
714
721
|
assert_eq!(result, "<div>Hello @[missing]</div>");
|
|
715
722
|
}
|
|
716
723
|
|
|
724
|
+
#[test]
|
|
725
|
+
fn stamp_unsafe_emits_raw_html() {
|
|
726
|
+
let state = json!({ "desc": "Gain <span data-green>+3%</span>." });
|
|
727
|
+
let stamper = ValueStamper::new(&state, false).unwrap();
|
|
728
|
+
let html = String::from("<p>@[$.unsafe(desc)]</p>");
|
|
729
|
+
let result = stamper.stamp_html(html).unwrap();
|
|
730
|
+
// Raw markup is painted directly — not escaped, not left as a marker.
|
|
731
|
+
assert_eq!(result, "<p>Gain <span data-green>+3%</span>.</p>");
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
#[test]
|
|
735
|
+
fn stamp_unsafe_nested_path() {
|
|
736
|
+
let state = json!({ "tooltip": { "props": { "description": "<em>x</em>" } } });
|
|
737
|
+
let stamper = ValueStamper::new(&state, false).unwrap();
|
|
738
|
+
let html = String::from("<p>@[$.unsafe(tooltip.props.description)]</p>");
|
|
739
|
+
let result = stamper.stamp_html(html).unwrap();
|
|
740
|
+
assert_eq!(result, "<p><em>x</em></p>");
|
|
741
|
+
}
|
|
742
|
+
|
|
717
743
|
#[test]
|
|
718
744
|
fn stamp_attribute_binding() {
|
|
719
745
|
let state = json!({ "firstName": "John" });
|
package/package.json
CHANGED
package/runtime/affected.js
CHANGED
|
@@ -38,6 +38,63 @@ const matchesKey = (matchStr, key) => {
|
|
|
38
38
|
return false;
|
|
39
39
|
};
|
|
40
40
|
|
|
41
|
+
// Identifier-followed-by-`(` — i.e. an attempted function or method call.
|
|
42
|
+
// Used to flag conditional expressions whose result can't be trusted to the
|
|
43
|
+
// snapshot-equality short-circuit because the called function may read live
|
|
44
|
+
// `$` (or other state-holding globals) via closure rather than the state
|
|
45
|
+
// snapshot affected.js passes in. False positives are acceptable: when the
|
|
46
|
+
// expression contains a call, we fall back to "if any state key changed,
|
|
47
|
+
// re-evaluate"; mountBranch's branchChanged guard suppresses the no-op DOM
|
|
48
|
+
// churn when the value didn't actually flip.
|
|
49
|
+
const CALL_EXPR_REGEX = /\b[A-Za-z_$][\w$]*\s*\(/;
|
|
50
|
+
|
|
51
|
+
// Resolve a clone-list entry to its live counterpart. processComponent swaps
|
|
52
|
+
// the original `<component src>` for a post-process `<component>` wrapper and
|
|
53
|
+
// records the new node on the original via `_vibeReplacedBy`. Mirrors
|
|
54
|
+
// `liveCloneNode` in iterate.js — kept inline to avoid a circular import.
|
|
55
|
+
const liveCloneNode = (node) => {
|
|
56
|
+
let cur = node;
|
|
57
|
+
while (cur && cur._vibeReplacedBy) cur = cur._vibeReplacedBy;
|
|
58
|
+
return cur;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
// Descend into any inlined `<component>` parsed trees attached to an
|
|
62
|
+
// iteration row. `_vibeIterTree` is stamped on the post-process wrapper by
|
|
63
|
+
// index.js (mutation-observer path) and on nested inlined components reached
|
|
64
|
+
// via `[data-vibe-iter-prop]`. The trees retain the original `@[…]` binding
|
|
65
|
+
// text so this affected walk can flag changes the same way it would on the
|
|
66
|
+
// row's own tree.
|
|
67
|
+
const walkInlinedComponentTrees = (
|
|
68
|
+
clonedNodes,
|
|
69
|
+
state,
|
|
70
|
+
newState,
|
|
71
|
+
affected,
|
|
72
|
+
depth,
|
|
73
|
+
) => {
|
|
74
|
+
for (let n = 0; n < clonedNodes.length; n++) {
|
|
75
|
+
const node = liveCloneNode(clonedNodes[n]);
|
|
76
|
+
if (!node || node.nodeType !== 1) continue;
|
|
77
|
+
const wrappers = [];
|
|
78
|
+
if (node._vibeIterTree) wrappers.push(node);
|
|
79
|
+
const found = node.querySelectorAll?.('[data-vibe-iter-prop]');
|
|
80
|
+
if (found) {
|
|
81
|
+
for (let i = 0; i < found.length; i++) {
|
|
82
|
+
if (found[i]._vibeIterTree) wrappers.push(found[i]);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
for (let w = 0; w < wrappers.length; w++) {
|
|
86
|
+
recursive(
|
|
87
|
+
wrappers[w]._vibeIterTree,
|
|
88
|
+
state,
|
|
89
|
+
newState,
|
|
90
|
+
affected,
|
|
91
|
+
newState,
|
|
92
|
+
depth,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
|
|
41
98
|
const recursive = (
|
|
42
99
|
tree,
|
|
43
100
|
state,
|
|
@@ -129,6 +186,26 @@ const recursive = (
|
|
|
129
186
|
mergedNewState,
|
|
130
187
|
depth + 1,
|
|
131
188
|
);
|
|
189
|
+
|
|
190
|
+
// Components mounted as `<component src>` *inside* an iteration row
|
|
191
|
+
// are not children of `instance.tree` — their parsed tree lives on
|
|
192
|
+
// the post-process wrapper as `_vibeIterTree`. Without descending
|
|
193
|
+
// into it, conditionals / bindings inside the inlined component are
|
|
194
|
+
// invisible to this walk, so a global state change that should tear
|
|
195
|
+
// down a row-internal `<!-- if -->` (or flip a binding) inside the
|
|
196
|
+
// component is missed. `updateInstance` re-hydrates these trees via
|
|
197
|
+
// `hydrateInlinedIterationComponents`, but only when the iteration
|
|
198
|
+
// itself is flagged affected (array change). For pure global-state
|
|
199
|
+
// updates the array is unchanged, so we descend here instead.
|
|
200
|
+
if (instance.clonedNodes) {
|
|
201
|
+
walkInlinedComponentTrees(
|
|
202
|
+
instance.clonedNodes,
|
|
203
|
+
mergedOldState,
|
|
204
|
+
mergedNewState,
|
|
205
|
+
affected,
|
|
206
|
+
depth + 1,
|
|
207
|
+
);
|
|
208
|
+
}
|
|
132
209
|
}
|
|
133
210
|
}
|
|
134
211
|
}
|
|
@@ -151,14 +228,46 @@ const recursive = (
|
|
|
151
228
|
|
|
152
229
|
// Check if condition result changed
|
|
153
230
|
if (oldValue !== newValue) {
|
|
231
|
+
// Carry the state pair the condition was evaluated against. Inside an
|
|
232
|
+
// iteration row this is the merged state (loop alias + current globals),
|
|
233
|
+
// outside it is the cycle's top-level state. hydrate hands these to
|
|
234
|
+
// updateConditional so the branch swap re-evaluates the expression in
|
|
235
|
+
// the same scope — without them, a row-internal `if` is re-evaluated
|
|
236
|
+
// against a state with no `item`, which always reads falsy and leaves
|
|
237
|
+
// rows that were initially on the else branch stuck there.
|
|
154
238
|
affected.push({
|
|
155
239
|
type: "conditional",
|
|
156
240
|
node: tree,
|
|
157
241
|
changeType: "expression",
|
|
242
|
+
scopedState: newState,
|
|
243
|
+
oldScopedState: state,
|
|
158
244
|
});
|
|
159
245
|
return affected;
|
|
160
246
|
}
|
|
161
247
|
|
|
248
|
+
// Function-call short-circuit: when the expression contains a `(`-style
|
|
249
|
+
// call, the snapshot eval can be blind — the called helper may read live
|
|
250
|
+
// `$` via closure rather than the passed state, so `oldValue` and
|
|
251
|
+
// `newValue` both run against the *current* values and the equality check
|
|
252
|
+
// never fires. Flag as affected whenever any state key actually changed
|
|
253
|
+
// this cycle so updateConditional re-runs against live state; if the
|
|
254
|
+
// result hasn't really flipped, mountBranch's branchChanged guard absorbs
|
|
255
|
+
// the call cheaply. Skip during initial hydration (state === newState).
|
|
256
|
+
if (state !== newState && CALL_EXPR_REGEX.test(tree.meta.expression)) {
|
|
257
|
+
const stateKeys = Object.keys(state);
|
|
258
|
+
const anyChanged = stateKeys.some((k) => state[k] !== newState[k]);
|
|
259
|
+
if (anyChanged) {
|
|
260
|
+
affected.push({
|
|
261
|
+
type: "conditional",
|
|
262
|
+
node: tree,
|
|
263
|
+
changeType: "expression-fn-call",
|
|
264
|
+
scopedState: newState,
|
|
265
|
+
oldScopedState: state,
|
|
266
|
+
});
|
|
267
|
+
return affected;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
162
271
|
// Condition didn't change, check for affected elements inside active branch
|
|
163
272
|
if (tree.runtime.activeInstance && tree.runtime.activeInstance.parsedTree) {
|
|
164
273
|
return recursive(
|
package/runtime/hydrate.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { updateIteration } from './iterate.js';
|
|
2
|
-
import { updateConditional } from './conditionals.js';
|
|
2
|
+
import { updateConditional, managedNodes } from './conditionals.js';
|
|
3
3
|
import { VALUE_ATTRS, DOM_PROPERTIES, BINDING_REGEX, PURE_BINDING_REGEX } from './constants.js';
|
|
4
4
|
import { evalInScope, resolveCaseInsensitivePath } from './utils.js';
|
|
5
|
+
import { RawHtml } from './raw-html.js';
|
|
5
6
|
|
|
6
7
|
export default (affected, state, manifest = {}, oldState = {}) => {
|
|
7
8
|
affected.forEach((aff) => {
|
|
@@ -14,9 +15,15 @@ export default (affected, state, manifest = {}, oldState = {}) => {
|
|
|
14
15
|
return;
|
|
15
16
|
}
|
|
16
17
|
|
|
17
|
-
// Handle conditional updates
|
|
18
|
+
// Handle conditional updates. Use the scoped pair affected attached when
|
|
19
|
+
// the conditional sits inside an iteration row — that pair has the row's
|
|
20
|
+
// loop alias overlaid on current globals, so the branch eval re-runs in
|
|
21
|
+
// the same scope the change was detected against. Top-level conditionals
|
|
22
|
+
// pass through with the cycle's state (affected attaches it verbatim there).
|
|
18
23
|
if (aff.type === 'conditional') {
|
|
19
|
-
|
|
24
|
+
const condNewState = aff.scopedState || state;
|
|
25
|
+
const condOldState = aff.oldScopedState || oldState;
|
|
26
|
+
updateConditional(aff.node, condNewState, condOldState, manifest);
|
|
20
27
|
return;
|
|
21
28
|
}
|
|
22
29
|
|
|
@@ -125,6 +132,27 @@ export default (affected, state, manifest = {}, oldState = {}) => {
|
|
|
125
132
|
// Evaluate the expression with state as context
|
|
126
133
|
const evaluated = evalInScope(matchInner, effectiveState, element);
|
|
127
134
|
|
|
135
|
+
// Raw-HTML render: `$.unsafe(str)` returns a RawHtml marker. When the
|
|
136
|
+
// binding is the sole content of its element (`<p>@[$.unsafe(x)]</p>`),
|
|
137
|
+
// set innerHTML from the trusted string instead of escaping via
|
|
138
|
+
// textContent. The injected subtree is marked opaque (managedNodes) so
|
|
139
|
+
// the MutationObserver never re-walks or hydrates it — raw HTML is inert,
|
|
140
|
+
// matching Svelte {@html}. Any non-pure use (marker spliced into
|
|
141
|
+
// surrounding text, or an element with other children) falls through to
|
|
142
|
+
// the normal path, where RawHtml.toString() degrades to escaped text.
|
|
143
|
+
const isPureBinding = input.trim() === matchOuter;
|
|
144
|
+
if (evaluated instanceof RawHtml && isPureBinding &&
|
|
145
|
+
(element._vibeRawHtml || element.childNodes.length === 1)) {
|
|
146
|
+
const html = evaluated.html;
|
|
147
|
+
if (element._vibeRawHtmlValue !== html) {
|
|
148
|
+
element.innerHTML = html;
|
|
149
|
+
for (const child of element.children) managedNodes.add(child);
|
|
150
|
+
element._vibeRawHtmlValue = html;
|
|
151
|
+
}
|
|
152
|
+
element._vibeRawHtml = true;
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
128
156
|
const toReplace = input.replaceAll(matchOuter, evaluated).trim();
|
|
129
157
|
|
|
130
158
|
affected.forEach((innerAff) => {
|
package/runtime/index.js
CHANGED
|
@@ -3,7 +3,8 @@ import parse from './parse.js';
|
|
|
3
3
|
import createManifest from './manifest.js';
|
|
4
4
|
import hydrate from './hydrate.js';
|
|
5
5
|
import affected from './affected.js';
|
|
6
|
-
import { deepMerge, hash } from './utils.js';
|
|
6
|
+
import { deepMerge, hash, setRootState } from './utils.js';
|
|
7
|
+
import { unsafe } from './raw-html.js';
|
|
7
8
|
import { renderAllIterations, setRenderAllConditionals, releaseOrphanedIterationProps } from './iterate.js';
|
|
8
9
|
import { renderAllConditionals, branchNodeRegistry, managedNodes } from './conditionals.js';
|
|
9
10
|
import { installScopeResolver } from './loop-scope.js';
|
|
@@ -655,6 +656,16 @@ const main = (s, config = {}, stringSelector = '') => {
|
|
|
655
656
|
enumerable: false,
|
|
656
657
|
});
|
|
657
658
|
|
|
659
|
+
// Mark a trusted string as raw HTML. A binding that is the sole content of
|
|
660
|
+
// its element — `<p>@[$.unsafe(desc)]</p>` — sets innerHTML from the string
|
|
661
|
+
// instead of escaping it via textContent. Trusted input only (no sanitizing,
|
|
662
|
+
// like Svelte {@html}). Non-enumerable so it never leaks into state snapshots.
|
|
663
|
+
Object.defineProperty($, 'unsafe', {
|
|
664
|
+
value: unsafe,
|
|
665
|
+
enumerable: false,
|
|
666
|
+
configurable: true,
|
|
667
|
+
});
|
|
668
|
+
|
|
658
669
|
// Pure-render path for surgical component HMR. Given raw component template
|
|
659
670
|
// HTML, callsite props, slot HTML, and existing componentIds, returns the
|
|
660
671
|
// processed HTML string the plugin's HMR handler can hand to $.reconcile.
|
|
@@ -672,6 +683,21 @@ const main = (s, config = {}, stringSelector = '') => {
|
|
|
672
683
|
// manifest's node-path entry iteration.
|
|
673
684
|
Object.defineProperty(manifest, '__live', { value: $, enumerable: false, configurable: true });
|
|
674
685
|
|
|
686
|
+
// Bind `$` inside every expression to this live root proxy (see setRootState
|
|
687
|
+
// in utils.js). Done before the first hydration pass so `$.unsafe` and the
|
|
688
|
+
// other reserved methods are reachable from the initial render onward.
|
|
689
|
+
setRootState($);
|
|
690
|
+
|
|
691
|
+
// Publish the real proxy on `window.$` BEFORE the first hydration pass.
|
|
692
|
+
// boot.js sets `window.$ = main(...)`, but until main returns it's the
|
|
693
|
+
// pre-boot placeholder (vibeInstance with no state keys). User helpers
|
|
694
|
+
// defined on `window` that close over `$` — e.g. a global `brawlerActivity`
|
|
695
|
+
// function reading `$.combat?.duration` — would then read the placeholder
|
|
696
|
+
// during initial render and treat the whole world as empty. Assigning the
|
|
697
|
+
// live proxy here lets those closures see the right `$` from the very
|
|
698
|
+
// first conditional/binding eval.
|
|
699
|
+
if (typeof window !== 'undefined') window.$ = $;
|
|
700
|
+
|
|
675
701
|
// Initial hydration - pass plain values so iteration can do reference comparison
|
|
676
702
|
const initialState = extractPlainValue($);
|
|
677
703
|
const affectedElements = affected(parsedTree, initialState, initialState);
|
package/runtime/iterate.js
CHANGED
|
@@ -996,19 +996,32 @@ export const updateIteration = (iterationNode, newState, oldState, manifest, par
|
|
|
996
996
|
const newArray = evalInScope(resolvedExpr, newState, startComment.parentElement) ?? resolvePath(newState, resolvedExpr) ?? [];
|
|
997
997
|
|
|
998
998
|
// Use instances (what's actually rendered) as ground truth for old array
|
|
999
|
-
//
|
|
1000
|
-
//
|
|
1001
|
-
//
|
|
1002
|
-
//
|
|
1003
|
-
//
|
|
1004
|
-
//
|
|
1005
|
-
//
|
|
1006
|
-
//
|
|
999
|
+
// whenever the only signal of change is a registry side-effect, or when
|
|
1000
|
+
// the rendered count doesn't match the freshly evaluated state. Three
|
|
1001
|
+
// cases collapse to "trust the rendered snapshot":
|
|
1002
|
+
// 1. oldState === newState — forceRegistryBackedIterationUpdates calls
|
|
1003
|
+
// updateIteration with the same state on both sides because the only
|
|
1004
|
+
// mutation was a registry slot rewrite. The previously rendered
|
|
1005
|
+
// items are the only honest record of what was there before.
|
|
1006
|
+
// 2. stateOldArray === newArray — the iteration's arrayPath resolves
|
|
1007
|
+
// directly to a registry slot (`window.__vibeIterProps._pN`); the
|
|
1008
|
+
// slot was swapped in place, so both reads return the same NEW
|
|
1009
|
+
// array.
|
|
1010
|
+
// 3. length mismatch — oldState predates the current render.
|
|
1011
|
+
// Otherwise the freshly evaluated state is a trustworthy "old".
|
|
1012
|
+
//
|
|
1013
|
+
// Literal-wrap expressions like `[s]` would otherwise slip through this
|
|
1014
|
+
// net: they build different array refs each eval but both contain the
|
|
1015
|
+
// just-rewritten registry value, so a naive diff sees no change. Case 1
|
|
1016
|
+
// catches them.
|
|
1007
1017
|
const instances = iterationNode.runtime.instances;
|
|
1008
|
-
const
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1018
|
+
const useInstancesAsOld =
|
|
1019
|
+
oldState === newState ||
|
|
1020
|
+
stateOldArray === newArray ||
|
|
1021
|
+
instances.length !== stateOldArray.length;
|
|
1022
|
+
const oldArray = useInstancesAsOld
|
|
1023
|
+
? instances.map((inst) => inst.item)
|
|
1024
|
+
: stateOldArray;
|
|
1012
1025
|
|
|
1013
1026
|
// Compiled path: Use pre-compiled batch function when available
|
|
1014
1027
|
if (compiled.canUseCompiled(iterationNode)) {
|
package/runtime/loop-scope.js
CHANGED
|
@@ -128,6 +128,35 @@ export const resolveScope = (el, name) => {
|
|
|
128
128
|
return undefined;
|
|
129
129
|
};
|
|
130
130
|
|
|
131
|
+
// Re-stamp the instance scope onto conditional-branch roots mounted inside a
|
|
132
|
+
// row. A `<!-- if -->` within a loop mounts its branch content separately from
|
|
133
|
+
// the iteration's own clonedNodes and stamps it once, at mount time
|
|
134
|
+
// (conditionals.js). When the row later updates in place — its DOM node reused
|
|
135
|
+
// for a new item — the iteration refreshes its own root stamp, but the branch
|
|
136
|
+
// root keeps the stale mount-time stamp, and `resolveScope` hits that first on
|
|
137
|
+
// the walk up (so a handler inside the conditional resolves the previous item).
|
|
138
|
+
// Walking the instance's parsed tree and re-stamping every active conditional
|
|
139
|
+
// branch root with the same fresh `scope` object closes that gap. Nested loops
|
|
140
|
+
// are skipped: each iteration node manages its own instances' scopes.
|
|
141
|
+
const restampConditionalBranches = (tree, scope) => {
|
|
142
|
+
if (!tree || typeof tree !== 'object') return;
|
|
143
|
+
if (tree.type === 'iteration') return;
|
|
144
|
+
if (tree.type === 'conditional') {
|
|
145
|
+
const active = tree.runtime?.activeInstance;
|
|
146
|
+
if (active?.nodes) {
|
|
147
|
+
for (let i = 0; i < active.nodes.length; i++) {
|
|
148
|
+
const node = active.nodes[i];
|
|
149
|
+
if (node && node.nodeType === 1) node.__vibeScope = scope;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (active?.parsedTree) restampConditionalBranches(active.parsedTree, scope);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
if (tree.children) {
|
|
156
|
+
for (const key in tree.children) restampConditionalBranches(tree.children[key], scope);
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
|
|
131
160
|
// Stamp the in-scope loop vars onto every iteration instance's root element
|
|
132
161
|
// node(s). The stamp accumulates the enclosing loop vars (`parentScope`) plus
|
|
133
162
|
// this loop's item/index, so a single innermost stamp resolves every alias in
|
|
@@ -151,6 +180,10 @@ export const stampInstanceScopes = (iterationNode, parentScope = {}) => {
|
|
|
151
180
|
const node = roots[r];
|
|
152
181
|
if (node && node.nodeType === 1) node.__vibeScope = scope;
|
|
153
182
|
}
|
|
183
|
+
// Conditional branches inside the row carry their own stamp from mount time;
|
|
184
|
+
// refresh them with the same fresh scope so in-place row updates don't leave
|
|
185
|
+
// a handler inside an `<!-- if -->` resolving the previous item.
|
|
186
|
+
if (inst.tree) restampConditionalBranches(inst.tree, scope);
|
|
154
187
|
}
|
|
155
188
|
};
|
|
156
189
|
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Marker for trusted raw-HTML output. `$.unsafe(str)` wraps a string so the
|
|
2
|
+
// hydrate path knows to set innerHTML instead of escaping via textContent.
|
|
3
|
+
// toString() returns the raw string so any non-pure use (the marker spliced
|
|
4
|
+
// into surrounding text) degrades to escaped literal text automatically — the
|
|
5
|
+
// browser escapes it when it lands in a text node.
|
|
6
|
+
export class RawHtml {
|
|
7
|
+
constructor(html) {
|
|
8
|
+
this.html = html == null ? '' : String(html);
|
|
9
|
+
}
|
|
10
|
+
toString() {
|
|
11
|
+
return this.html;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const unsafe = (html) => new RawHtml(html);
|
package/runtime/utils.js
CHANGED
|
@@ -4,6 +4,40 @@ import { THIS_PROP_REGEX } from './constants.js';
|
|
|
4
4
|
let hashCounter = 0;
|
|
5
5
|
export const hash = () => `_${hashCounter++}`;
|
|
6
6
|
|
|
7
|
+
// The live root reactive proxy, wired once at boot by index.js. Used ONLY to
|
|
8
|
+
// reach the root's non-enumerable helper methods (`$.unsafe`, `$.on`, …) — NOT
|
|
9
|
+
// for state reads.
|
|
10
|
+
let rootProxy = null;
|
|
11
|
+
export const setRootState = (proxy) => { rootProxy = proxy; };
|
|
12
|
+
|
|
13
|
+
// Resolve the `$` identifier for an expression. `$` must read from the SAME
|
|
14
|
+
// state object the diff cycle is evaluating — affected.js / iterate.js compare
|
|
15
|
+
// an expression's value against an old snapshot and a new snapshot, so binding
|
|
16
|
+
// `$` to the live root instead would make both reads identical and defeat
|
|
17
|
+
// change detection (the whole reactivity engine). So state reads stay on the
|
|
18
|
+
// passed `state`.
|
|
19
|
+
//
|
|
20
|
+
// The catch: the root's reserved methods (`unsafe`, `on`, `reconcile`, …) are
|
|
21
|
+
// non-enumerable and don't survive the plain `{...state}` snapshots, so a
|
|
22
|
+
// plain snapshot can't reach `$.unsafe`. Wrap such a state in a thin proxy that
|
|
23
|
+
// serves its own keys (snapshot reads, fully diffable) and falls back to the
|
|
24
|
+
// live root only for keys it lacks (the helper methods). Scoped states already
|
|
25
|
+
// delegate to the live root (their target is `$`), so they need no wrapper.
|
|
26
|
+
const dollarCache = new WeakMap();
|
|
27
|
+
const RESERVED_PROBE = 'unsafe'; // reserved method present on root-backed states, absent on plain snapshots
|
|
28
|
+
const dollarFor = (state) => {
|
|
29
|
+
if (!rootProxy || state === rootProxy || RESERVED_PROBE in state) return state;
|
|
30
|
+
let wrapped = dollarCache.get(state);
|
|
31
|
+
if (!wrapped) {
|
|
32
|
+
wrapped = new Proxy(state, {
|
|
33
|
+
get: (t, k) => (k in t ? t[k] : rootProxy[k]),
|
|
34
|
+
has: (t, k) => k in t || k in rootProxy,
|
|
35
|
+
});
|
|
36
|
+
dollarCache.set(state, wrapped);
|
|
37
|
+
}
|
|
38
|
+
return wrapped;
|
|
39
|
+
};
|
|
40
|
+
|
|
7
41
|
// Function compilation cache: avoids creating new Function() for repeated expressions
|
|
8
42
|
// Key: normalized expression + '\0' + state keys joined by '\0'
|
|
9
43
|
const fnCache = new Map();
|
|
@@ -113,10 +147,13 @@ export const evalInScope = (expr, state, element = null) => {
|
|
|
113
147
|
fnCache.set(cacheKey, fn);
|
|
114
148
|
}
|
|
115
149
|
|
|
116
|
-
// Build values array matching the cached function's parameter order
|
|
150
|
+
// Build values array matching the cached function's parameter order. Bare
|
|
151
|
+
// identifiers resolve from the passed (possibly scoped) state; the `$`
|
|
152
|
+
// identifier resolves via dollarFor — the same state for reads (so old/new
|
|
153
|
+
// diffing works), with helper-method fallback to the live root.
|
|
117
154
|
const values = new Array(keyCount + 1);
|
|
118
155
|
for (let i = 0; i < keyCount; i++) values[i] = state[stateKeys[i]];
|
|
119
|
-
values[keyCount] = state;
|
|
156
|
+
values[keyCount] = dollarFor(state);
|
|
120
157
|
|
|
121
158
|
const result = fn(...values);
|
|
122
159
|
|