@ape-egg/vibe 1.8.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 CHANGED
@@ -1,5 +1,112 @@
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
+
22
+ ## [1.9.0] - 2026-04-18
23
+
24
+ ### Added
25
+
26
+ - **`$.reconcile()` — subtree reconciliation API** (`runtime/reconcile.js`, new 621-line module) — reconcile a Vibe-managed subtree against new source HTML with a tag-aligned, two-pointer walker. Vibe-owned regions (iterations, conditionals, components, `<slot>` pairs) are treated as opaque; their internals stay state-driven. DOM identity, focus, and selection survive wherever a match is found.
27
+ - Exposed as `$.reconcile` (non-enumerable so it stays out of state snapshots)
28
+ - Test: `tests/e2e/reconcile.spec.js`
29
+
30
+ - **Component state cleanup on unmount** (`runtime/component.js`, `runtime/conditionals.js`, `runtime/index.js`, `runtime/manifest.js`) — when a `<component>` leaves the DOM (conditional branch swap, iteration key removal, etc.), its entry in `window.__vibeComponents[id]` and `window.$[id]` is evicted. `collectComponentIds` walks removed subtrees; `releaseOrphanedComponentState` verifies the DOM is fully gone before deleting state. Prevents state leaks from components that mount and unmount repeatedly.
31
+ - Test: `tests/e2e/component-state-cleanup.spec.js`
32
+
33
+ - **Arbitrary JS expressions in `<!-- each -->`** (`runtime/constants.js`) — `ITERATION_REGEX` relaxed from `[\w.\[\]]+` to `.+`. The array expression is now evaluated via `evalInScope`, so you can iterate over method calls, window globals, inline array literals, filters, and `Array.from(...)`:
34
+ ```html
35
+ <!-- each items.filter(x => x.active) as item -->
36
+ <!-- each Array.from({length: 10}, (_, i) => i) as n -->
37
+ <!-- each window.fights as fight, i -->
38
+ ```
39
+ - Test: `tests/e2e/each-expression.spec.js`
40
+
41
+ - **Missing-identifier safety in expressions** (`runtime/utils.js`) — `evalInScope` now wraps free identifiers that aren't state keys, reserved words, or known globals in `typeof` guards so they resolve to `undefined` instead of throwing `ReferenceError`. `@[!missingProp]` → `true`, `@[optional?.x]` → `undefined`. Uses `typeof` rather than a `var` hoist so real globals (app functions on `window` like `getLevelByExperience`) stay reachable.
42
+
43
+ - **Arrow function parameters and object-literal keys recognized** (`runtime/utils.js`) — identifier rewriting now skips `(a, b) =>` / `x =>` parameter lists and `{ key: value }` property names. Expressions like `items.map(x => x.name)` and `@[{ a: 1 }[k]]` no longer get turned into invalid JS by the missing-identifier pass.
44
+
45
+ - **Prop substitution covers directive comments and event handlers** (`runtime/component.js`) — prop identifiers now resolve inside `<!-- if propName -->`, `<!-- else if propName -->`, `<!-- each propName as item -->` and `onclick="$.propName = x"` in addition to `@[...]`. Writing `$.value = x` inside an event handler on a child component whose parent passed `value="@[email]"` updates `$.email` on the parent — natural two-way binding with no extra API.
46
+ - Tests: `tests/e2e/prop-in-directive.spec.js`, `tests/e2e/prop-in-expression.spec.js`, `tests/e2e/two-way-binding.spec.js`
47
+
48
+ - **Pre-boot `$.ready` promise** (`index.js`, `boot.js`) — the placeholder returned by `vibe()` before boot now exposes a `.ready` promise that chains to the real post-boot `$.ready`. Consumers that captured `window.$` early (tests, auto-initializers) can `await $.ready` without polling. New `chainInstanceReady` export links the two.
49
+
50
+ - **Async component scripts** (`runtime/component.js`) — `<script type="module">` blocks in components with `import` statements are rewritten to `await import()` and executed via `AsyncFunction`. Sync execution preserved when there are no imports (keeps boot timing unchanged). Component finalization waits for all async scripts before inlining.
51
+ - Tests: `tests/e2e/async-component-state.spec.js`, `tests/e2e/parallel-components.spec.js`
52
+
53
+ - **Promises never proxied as reactive state** (`runtime/state.js`) — proxy `get` trap now bails on `value instanceof Promise`. Wrapping a Promise violates the Proxy invariant when exposed as a non-writable property (e.g. `$.ready`) and makes no sense as reactive state.
54
+
55
+ ### Fixed
56
+
57
+ - **HTML-entity decoding in compiled iteration templates** (`runtime/_vibe-compiled-iteration-batch.js`) — when a template's attributes get serialized via `innerHTML`, the browser encodes `<`, `>`, `"`, `&`, `'`. The batch-function compiler was wrapping those encoded expressions directly in a template literal, producing invalid JS for bindings like `@[x > 0 ? 'a' : 'b']` when used inside an attribute. Entities are now decoded before interpolation.
58
+
59
+ - **`<!-- each -->` over `Array.from(...)` and other inline expressions** — previously failed silently because `ITERATION_REGEX` only accepted simple paths. See `ITERATION_REGEX` change above.
60
+
61
+ - **Attribute bindings on fetched `<component src>` wrappers were coerced to strings** (`runtime/parse.js`) — `captureAttributeBindings` now returns nulls for elements that are still `<component src>` / `<div class="component" src>` (pre-fetch), so raw prop values survive untouched for `processComponent` to substitute. Post-fetch wrappers (no `src`) capture attributes normally.
62
+
63
+ ### Changed
64
+
65
+ - **Iteration engine rework** (`runtime/iterate.js`, +378 lines over 1.8.1) — unified update path around tagged instance keys, bulk-replacement fast path when old/new arrays share no common keys (uses `Range.deleteContents` + single `innerHTML`), and DocumentFragment batching inside `renderIteration` so N inserts become one.
66
+
67
+ - **Debug scaffolding removed** (`runtime/index.js`) — an ad-hoc `[vibe-debug] rerender` `console.info` and the associated `window.__lastHits` / `window.__parsedTree` probes (leftover from an investigation) have been deleted.
68
+
69
+ ### Documentation
70
+
71
+ - **README — "Future improvements to refactor"** — documents the string-based dependency-tracking approach used by `affected()` and its always-affected fallback semantics, and the HTML-lowercased attribute-name handling.
72
+
73
+ ### Packaging
74
+
75
+ - **`@ape-egg/vite-plugin-vibe` published** — new sibling package providing a Vite plugin with HMR for Vibe pages and components. See its own `README.md` and `CHANGELOG.md`.
76
+
77
+ ---
78
+
79
+ ## [1.8.1] - 2026-04-12
80
+
81
+ ### Added
82
+
83
+ - **`<slot>` boundary preserved in processed DOM** — component slot content is now wrapped in a `<slot>` element instead of being spliced in directly. Dev tools, HMR, and anything that needs to locate slot boundaries can query for `<slot>` instead of tracking comment pairs.
84
+ - `runtime/component.js` — `<slot></slot>` replaced with `<slot>${children}</slot>`
85
+ - `compiler/src/parser/html.rs` — compiler emits the same wrapping
86
+ - `vibe.css` — `slot, div.slot { display: contents }` so the wrapper is layout-transparent
87
+
88
+ ### Fixed
89
+
90
+ - **Conditionals inside doubly-nested component slots lost their templates** — when a `<component src>` appeared inside another component's slot content, Vibe's parser ran `renderConditional` (which strips template nodes) before `processComponent` captured the slot content, so branch templates were gone by the time the nested component tried to use them.
91
+ - `runtime/index.js` — `processMutations` now captures `_vibeSlotContent` for all nested `<component src>` elements before parse/hydrate runs
92
+ - Test: `tests/e2e/conditional-in-nested-slot.spec.js`
93
+
94
+ - **Bindings with complex expressions re-hydrated on every state change** — `matchesKey` did prefix-only matching, so expressions like `@[Math.floor(coins / 100)]` couldn't be matched to any state key and hit the "always affected" fallback. A 250ms client clock would re-hydrate every such binding 4× per second.
95
+ - `runtime/affected.js` — `matchesKey` now also does word-boundary search so it finds `coins` as an identifier inside the expression
96
+ - Test: `tests/e2e/expression-dependency.spec.js`
97
+
98
+ - **Iteration instance comparisons used live Proxy as "old state"** — `scopedState` is a Proxy that reflects current global state, so comparing `state[k]` vs `newState[k]` showed both as the new value. Global state changes inside iterations weren't detected as changes.
99
+ - `runtime/affected.js` — iteration recursion now builds plain-object snapshots from previousState/currentState merged with scopedState's local vars
100
+
101
+ - **Name bindings re-hydrated on every state change** — HTML lowercases attribute names, so `<page @[pageName]>` becomes `@[pagename]` in the DOM. `matchesKey('pagename', 'pageName')` failed (case-sensitive), triggering the always-affected fallback and causing constant attribute flashing.
102
+ - `runtime/affected.js` — name binding dependency check now matches state keys case-insensitively, mirroring `hydrate.js`'s existing case-insensitive evaluation fallback
103
+
104
+ ### Documentation
105
+
106
+ - `README.md` — "Future improvements to refactor" section documenting string-based dependency tracking limitations and HTML lowercase attribute handling
107
+
108
+ ---
109
+
3
110
  ## [1.8.0] - 2026-04-10
4
111
 
5
112
  ### Added
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Vibe
2
2
 
3
- **Version 1.6.0 (Alpha)** — A runtime-first reactive framework with optional compilation.
3
+ **Version 1.9.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:
@@ -525,3 +567,31 @@ Modern browsers with Proxy and MutationObserver support.
525
567
  ## License
526
568
 
527
569
  ISC
570
+
571
+ ---
572
+
573
+ ## Future improvements to refactor
574
+
575
+ ### Dependency tracking uses string matching, not AST
576
+
577
+ `affected()` determines which bindings to re-hydrate by string-matching expression text against state key names. When matching fails, a safety fallback marks the binding as affected on every state change.
578
+
579
+ - `runtime/affected.js:15` — `matchesKey` does exact/prefix match, then word-boundary search
580
+ - `runtime/affected.js:~122` — `shouldAffect = noMatch || ...` fallback for unmatched expressions
581
+ - `runtime/affected.js:~248` — same fallback for name bindings
582
+
583
+ Every time `matchesKey` can't prove an expression is unrelated to a state change, that binding re-hydrates. A proper AST-based dependency extraction at parse time would eliminate the fallback entirely: each binding stores its exact dependency set, and runtime just checks if any dep key changed.
584
+
585
+ ### HTML lowercases attribute names — breaks name binding matching
586
+
587
+ The browser lowercases all attribute names during HTML parsing. `<page @[pageName]>` becomes `@[pagename]` in the DOM. Every value hydrated into an attribute NAME position loses its case. `@[myCoolPage]` → DOM stores `@[mycoolpage]`, causing comparison mismatches against camelCase state keys.
588
+
589
+ Handled today by case-insensitive fallbacks in two places:
590
+ - `runtime/hydrate.js:~32` — case-insensitive state key lookup when evaluating name bindings
591
+ - `runtime/affected.js:~235` — case-insensitive `matchesKey` wrapper for name bindings
592
+
593
+ Both are workarounds for HTML's behavior. A cleaner architecture would centralize the case-insensitive state key resolution into one helper, or normalize the name binding expression to canonical state-key case at first evaluation and cache it on the tree node.
594
+
595
+ ### Related: value hydration into attribute values is case-preserving
596
+
597
+ Only attribute NAMES are lowercased by HTML, not values. So `title="@[pageName]"` and `@[pageName]` in text content preserve case and work without fallbacks — the issue is specific to name bindings.
package/boot.js CHANGED
@@ -2,7 +2,7 @@
2
2
  // Used by both index.js (global state) and component.js (component state)
3
3
 
4
4
  import main from './runtime/index.js';
5
- import { getPendingListeners } from './index.js';
5
+ import { getPendingListeners, chainInstanceReady } from './index.js';
6
6
 
7
7
  let bootQueued = false;
8
8
  let booted = false;
@@ -40,6 +40,10 @@ export const boot = () => {
40
40
  // Boot with merged state
41
41
  window.$ = main(mergedState, config, targetSelector);
42
42
 
43
+ // Forward real $.ready to the pre-boot placeholder's ready promise so
44
+ // any consumer that captured the placeholder can still await readiness.
45
+ chainInstanceReady(window.$.ready);
46
+
43
47
  // Apply pending listeners from vibe instance
44
48
  const pendingListeners = getPendingListeners();
45
49
  if (pendingListeners) {
@@ -231,15 +231,15 @@ impl HtmlParser {
231
231
  replacement = replacement.replace(&prop_binding, &prop_value);
232
232
  }
233
233
 
234
- // Replace <slot> tags with content, or remove if empty/missing
235
- let slot_replacement = if slot_content.trim().is_empty() {
236
- ""
234
+ // Replace <slot> tags wrap children in <slot> boundary, or remove if empty
235
+ let slot_wrapped = if slot_content.trim().is_empty() {
236
+ String::new()
237
237
  } else {
238
- &slot_content
238
+ format!("<slot>{}</slot>", slot_content)
239
239
  };
240
- replacement = replacement.replace("<slot></slot>", slot_replacement);
241
- replacement = replacement.replace("<slot/>", slot_replacement);
242
- replacement = replacement.replace("<slot />", slot_replacement);
240
+ replacement = replacement.replace("<slot></slot>", &slot_wrapped);
241
+ replacement = replacement.replace("<slot/>", &slot_wrapped);
242
+ replacement = replacement.replace("<slot />", &slot_wrapped);
243
243
 
244
244
  // Keep the wrapper for consistency with runtime (using generic <component> wrapper)
245
245
  // No src attribute = wrapper won't be re-processed
@@ -371,10 +371,10 @@ impl HtmlParser {
371
371
  replacement = replacement.replace(&prop_binding, prop_value);
372
372
  }
373
373
 
374
- let slot_replacement = if slot_content.trim().is_empty() { "" } else { slot_content.as_str() };
375
- replacement = replacement.replace("<slot></slot>", slot_replacement);
376
- replacement = replacement.replace("<slot/>", slot_replacement);
377
- replacement = replacement.replace("<slot />", slot_replacement);
374
+ let slot_wrapped = if slot_content.trim().is_empty() { String::new() } else { format!("<slot>{}</slot>", slot_content) };
375
+ replacement = replacement.replace("<slot></slot>", &slot_wrapped);
376
+ replacement = replacement.replace("<slot/>", &slot_wrapped);
377
+ replacement = replacement.replace("<slot />", &slot_wrapped);
378
378
 
379
379
  let wrapper = format!("<component>{}</component>", replacement);
380
380
  result.replace_range(*start..*end, &wrapper);
@@ -447,10 +447,10 @@ impl HtmlParser {
447
447
  replacement = replacement.replace(&prop_binding, prop_value);
448
448
  }
449
449
 
450
- let slot_replacement = if slot_content.trim().is_empty() { "" } else { slot_content.as_str() };
451
- replacement = replacement.replace("<slot></slot>", slot_replacement);
452
- replacement = replacement.replace("<slot/>", slot_replacement);
453
- replacement = replacement.replace("<slot />", slot_replacement);
450
+ let slot_wrapped = if slot_content.trim().is_empty() { String::new() } else { format!("<slot>{}</slot>", slot_content) };
451
+ replacement = replacement.replace("<slot></slot>", &slot_wrapped);
452
+ replacement = replacement.replace("<slot/>", &slot_wrapped);
453
+ replacement = replacement.replace("<slot />", &slot_wrapped);
454
454
 
455
455
  let wrapper = format!("<component>{}</component>", replacement);
456
456
  result.replace_range(start..end, &wrapper);
package/index.js CHANGED
@@ -5,9 +5,16 @@ import { boot, isBooted, ensureBoot } from './boot.js';
5
5
 
6
6
  // Shared instance for queueing listeners before boot
7
7
  let vibeInstance = null;
8
+ let resolveInstanceReady = null;
8
9
 
9
10
  const createVibeInstance = () => ({
10
11
  _pendingListeners: { afterUpdate: [], afterDomMutation: [], ready: [] },
12
+ // Promise that resolves when the real $.ready resolves post-boot. Lets
13
+ // consumers holding the pre-boot placeholder (e.g. tests awaiting
14
+ // `window.$.ready` before boot has replaced $ with the reactive proxy)
15
+ // wait for readiness without polling. Non-enumerable so it stays out of
16
+ // state snapshots.
17
+ ready: new Promise((resolve) => { resolveInstanceReady = resolve; }),
11
18
  on(event, callback) {
12
19
  // If booted, delegate to window.$
13
20
  if (isBooted() && window.$) {
@@ -66,4 +73,12 @@ const vibe = (state = {}, config, targetSelector) => {
66
73
  // Export function to get pending listeners (used by boot.js)
67
74
  export const getPendingListeners = () => vibeInstance?._pendingListeners || null;
68
75
 
76
+ // Forward post-boot $.ready to the pre-boot placeholder's ready promise.
77
+ // Called by boot.js after main() creates the real reactive proxy.
78
+ export const chainInstanceReady = (realReadyPromise) => {
79
+ if (resolveInstanceReady && realReadyPromise) {
80
+ realReadyPromise.then(() => resolveInstanceReady());
81
+ }
82
+ };
83
+
69
84
  export default vibe;