@ape-egg/vibe 1.0.3 → 1.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.0.5] - 2026-01-25
4
+
5
+ ### Fixed
6
+
7
+ - **Expression evaluation**: Normalize whitespace in `@[...]` expressions before evaluation, making bindings resilient to IDE auto-formatting that may break expressions across multiple lines
8
+
9
+ ### Changed
10
+
11
+ - **DRY refactor**: Consolidated expression evaluation into single `evalInScope()` function in utils.js, used by hydrate.js, conditionals.js, and affected.js
12
+
13
+ ---
14
+
15
+ ## [1.0.4] - 2026-01-24
16
+
17
+ ### Fixed
18
+
19
+ - **MutationObserver**: Use `takeRecords()` to preserve pending mutations before disconnecting during state changes, preventing queued DOM mutations (e.g., innerHTML replacements) from being lost when state updates occur simultaneously
20
+
21
+ ---
22
+
3
23
  ## [1.0.3] - 2026-01-23
4
24
 
5
25
  ### Added
package/ROADMAP.md ADDED
@@ -0,0 +1,289 @@
1
+ # Vibe Roadmap
2
+
3
+ Feature proposals and improvements for Vibe's runtime-first reactive framework.
4
+
5
+ ---
6
+
7
+ ## Proposed: Manual Hydration API
8
+
9
+ **Status**: Proposal
10
+ **Priority**: Medium
11
+ **Category**: Core Runtime
12
+
13
+ ### Problem
14
+
15
+ Vibe's MutationObserver automatically hydrates `@[bindings]` on initial page load and incremental DOM changes, but fails when developers perform wholesale DOM replacement via `innerHTML`:
16
+
17
+ ```js
18
+ // This doesn't trigger Vibe's hydration:
19
+ element.innerHTML = '<h1>Welcome, @[race]!</h1>';
20
+ // Result: Literal text "@[race]" instead of evaluated "human"
21
+ ```
22
+
23
+ This happens because:
24
+ 1. `innerHTML` replacement destroys all old DOM nodes and creates new ones
25
+ 2. Vibe's MutationObserver is designed for incremental mutations, not complete replacement
26
+ 3. No mechanism exists to manually trigger re-hydration
27
+
28
+ ### Current Workaround
29
+
30
+ Users must manually evaluate bindings before setting innerHTML:
31
+
32
+ ```js
33
+ const evaluateBindings = (html) => {
34
+ return html.replace(/@\[([^\]]+)\]/g, (match, expression) => {
35
+ const keys = Object.keys(window.$);
36
+ const values = Object.values(window.$);
37
+ const result = new Function(...keys, `return ${expression}`)(...values);
38
+ return result ?? '';
39
+ });
40
+ };
41
+
42
+ element.innerHTML = evaluateBindings(html); // Manually evaluated
43
+ ```
44
+
45
+ **Issues with this approach:**
46
+ - Not DRY - duplicates Vibe's internal evaluation logic
47
+ - Fragile - user's regex might not match Vibe's parser exactly
48
+ - Knowledge burden - users need to know when manual evaluation is needed
49
+ - Inconsistent - some bindings auto-hydrate, others need manual work
50
+
51
+ ### Use Cases
52
+
53
+ This affects multiple real-world scenarios:
54
+
55
+ 1. **Dynamic content replacement** (tutorials, articles, modals)
56
+ 2. **Client-side routing** (replacing page sections with new HTML)
57
+ 3. **Lazy-loaded sections** (loading HTML from server with bindings)
58
+ 4. **Template cloning** (using `<template>` elements with `@[bindings]`)
59
+ 5. **Server-sent HTML** (SSR-like patterns where server sends HTML with bindings)
60
+
61
+ ### Proposed Solution
62
+
63
+ Add a manual hydration API that allows users to trigger Vibe's binding evaluation:
64
+
65
+ #### Option 1: Element Hydration
66
+ ```js
67
+ vibe.hydrate(element);
68
+ ```
69
+
70
+ **Usage:**
71
+ ```js
72
+ tutorial.innerHTML = '<h1>Welcome, @[race]!</h1>';
73
+ vibe.hydrate(tutorial); // Scan tutorial and children for @[bindings]
74
+ ```
75
+
76
+ **Pros:**
77
+ - Most flexible - works with any element
78
+ - Matches web component patterns (`connectedCallback()`)
79
+ - Clear intent - "scan this element"
80
+
81
+ **Cons:**
82
+ - Requires import/reference to vibe library
83
+ - Two-step process (set innerHTML, then hydrate)
84
+
85
+ #### Option 2: HTML String Evaluation
86
+ ```js
87
+ const evaluated = vibe.evaluate(html, state);
88
+ ```
89
+
90
+ **Usage:**
91
+ ```js
92
+ const html = '<h1>Welcome, @[race]!</h1>';
93
+ const evaluated = vibe.evaluate(html, window.$);
94
+ tutorial.innerHTML = evaluated;
95
+ ```
96
+
97
+ **Pros:**
98
+ - Pure function - easier to test
99
+ - Works without DOM access
100
+ - Can be used server-side or in workers
101
+
102
+ **Cons:**
103
+ - Users must manage state passing
104
+ - Doesn't handle nested/dynamic state updates
105
+
106
+ #### Option 3: Safe innerHTML Setter
107
+ ```js
108
+ vibe.setHTML(element, html);
109
+ ```
110
+
111
+ **Usage:**
112
+ ```js
113
+ vibe.setHTML(tutorial, '<h1>Welcome, @[race]!</h1>');
114
+ ```
115
+
116
+ **Pros:**
117
+ - Single operation - set and hydrate in one call
118
+ - Matches platform APIs (`element.setHTML()`)
119
+ - Simplest API surface
120
+
121
+ **Cons:**
122
+ - Yet another setter abstraction
123
+ - Might conflict with future platform APIs
124
+
125
+ ### Recommendation
126
+
127
+ **Implement Option 1** (`vibe.hydrate(element)`):
128
+ - Aligns with Vibe's runtime-first philosophy
129
+ - Gives users explicit control over hydration timing
130
+ - Most flexible for different scenarios
131
+ - Clear and predictable behavior
132
+
133
+ ### Implementation Notes
134
+
135
+ ```js
136
+ // Expose on the state proxy:
137
+ window.$ = state({ race: 'human' });
138
+ window.$.vibe.hydrate(element); // Scan element for @[bindings]
139
+
140
+ // Or as a module export:
141
+ import state, { hydrate } from '@ape-egg/vibe';
142
+ hydrate(element);
143
+ ```
144
+
145
+ Should support:
146
+ - Single element: `hydrate(tutorial)`
147
+ - Multiple elements: `hydrate([el1, el2])`
148
+ - Selector: `hydrate('tutorial')` (convenience)
149
+
150
+ ### Related
151
+
152
+ Compare to other frameworks:
153
+ - **Alpine.js**: `Alpine.initTree(el)` - manual initialization
154
+ - **Vue**: `app.mount(el)` - mount to element
155
+ - **Svelte**: Compiler handles this at build time
156
+ - **HTMX**: `htmx.process(el)` - process element for attributes
157
+
158
+ ---
159
+
160
+ ## Known Issues
161
+
162
+ ### innerHTML Replacement Corrupts Conditional Branches in Iterations
163
+
164
+ **Status**: Bug
165
+ **Priority**: Low (edge case)
166
+ **Category**: Core Runtime
167
+ **Discovered**: 2026-01-25
168
+
169
+ #### Problem
170
+
171
+ When a parent element containing iterations with nested conditionals has its `innerHTML` replaced multiple times with identical HTML, the conditional's `else` branches become `null` after 2-3 replacements. This causes conditionals to fail rendering.
172
+
173
+ **Root cause:**
174
+ 1. Iteration template's `branches` object is shared across all iteration instances (iterate.js:65: `branches, // Branch templates are reused`)
175
+ 2. When `innerHTML` replacement happens, something mutates the shared `branches.else` to `null`
176
+ 3. All instances reference the same corrupted branches object
177
+ 4. Subsequent renders have no `else` branch template to mount
178
+
179
+ #### Reproduction
180
+
181
+ Programmatic test case:
182
+
183
+ ```js
184
+ // HTML structure
185
+ const html = `
186
+ <item-list>
187
+ <!-- each items as item, i -->
188
+ <item-card>
189
+ <span>@[item]</span>
190
+ <!-- if i % 2 === 0 -->
191
+ <badge>Even</badge>
192
+ <!-- else -->
193
+ <badge secondary>Odd</badge>
194
+ <!-- /if -->
195
+ </item-card>
196
+ <!-- /each -->
197
+ </item-list>
198
+ `;
199
+
200
+ // State
201
+ window.$ = state({ items: ['Apple', 'Banana', 'Cherry'] });
202
+
203
+ // Trigger the bug
204
+ const container = document.querySelector('[vibe]');
205
+
206
+ // First replacement: works
207
+ container.innerHTML = html;
208
+ await new Promise(r => setTimeout(r, 100));
209
+
210
+ // Second replacement: works
211
+ container.innerHTML = html;
212
+ await new Promise(r => setTimeout(r, 100));
213
+
214
+ // Third replacement: branches.else becomes NULL
215
+ container.innerHTML = html;
216
+ await new Promise(r => setTimeout(r, 100));
217
+
218
+ // Result: Even/Odd badges fail to render in the third iteration
219
+ ```
220
+
221
+ #### Observations
222
+
223
+ 1. Cloning `branches` object during iteration (shallow copy) doesn't prevent the bug
224
+ 2. The mutation happens BEFORE cloning, meaning the original template is corrupted
225
+ 3. Setting a property trap on `branches.else` doesn't catch the mutation (already null when accessed)
226
+ 4. Only affects conditionals inside iterations - standalone conditionals work fine
227
+ 5. Only triggers with multiple innerHTML replacements - single replacement works
228
+
229
+ #### Affected Patterns
230
+
231
+ This bug only affects:
232
+ - Replacing innerHTML multiple times with identical HTML containing iterations + conditionals
233
+ - Demo infrastructure like tutorial.js that re-renders on state changes
234
+ - Not representative of typical usage patterns
235
+
236
+ Does NOT affect:
237
+ - Normal reactivity (state changes)
238
+ - Single innerHTML replacement
239
+ - Iterations without conditionals
240
+ - Conditionals outside iterations
241
+ - Incremental DOM mutations (appendChild, insertBefore, etc.)
242
+
243
+ #### Workaround
244
+
245
+ Avoid multiple innerHTML replacements on parents containing iteration+conditional templates. Instead:
246
+ 1. Use incremental DOM APIs (appendChild, createElement)
247
+ 2. Replace innerHTML once at initialization only
248
+ 3. Use Vibe's normal reactivity for updates
249
+ 4. Don't wrap demo content in `<tutorial>` that re-renders via innerHTML
250
+
251
+ #### Investigation Log
252
+
253
+ Debugging attempts (2026-01-25):
254
+ - ✓ Confirmed `branches.else` becomes `null` after 3rd innerHTML replacement
255
+ - ✓ Added deep cloning of branches object - didn't help (already null before clone)
256
+ - ✓ Added Object.defineProperty trap - didn't fire (already null)
257
+ - ✓ Checked parsing logic - correctly finds else comments
258
+ - ✗ Unable to identify where mutation occurs
259
+ - ✗ Unable to reproduce with simpler test case (needs tutorial.js pattern)
260
+
261
+ Likely related to:
262
+ - MutationObserver's removedNodes callback cleaning up references
263
+ - Template caching/reuse strategy in iterate.js
264
+ - Interaction between parse → clone → hydrate → render cycle
265
+
266
+ #### Resolution Path
267
+
268
+ **Phase 1 (Current)**: Document and work around
269
+ - Remove `<tutorial>` wrapper from demos
270
+ - Add note in CLAUDE.md about limitation
271
+ - Tests validate core reactivity works correctly
272
+
273
+ **Phase 2+**: Consider fixing if real-world need emerges
274
+ - Deep investigation into branch reference lifecycle
275
+ - Possibly: deep clone branches instead of sharing reference
276
+ - Possibly: rebuild conditional metadata on each innerHTML replacement
277
+ - Possibly: manual hydration API (see "Manual Hydration API" proposal above)
278
+
279
+ This is acceptable technical debt since:
280
+ 1. Edge case not representative of normal usage
281
+ 2. Core reactivity (the 99% case) works correctly
282
+ 3. Can be addressed when/if users report needing this pattern
283
+ 4. Phase 1 goals (iteration + conditionals) are met
284
+
285
+ ---
286
+
287
+ ## Future Proposals
288
+
289
+ *This section reserved for additional feature proposals*
package/affected.js CHANGED
@@ -1,18 +1,10 @@
1
1
  import { resolvePath, deepEqual } from './iteration-utils.js';
2
2
  import { extractDependencies } from './conditionals.js';
3
3
  import { BINDING_REGEX } from './constants.js';
4
+ import { evalInScope } from './utils.js';
4
5
 
5
6
  // Evaluate conditional expression
6
- const evaluateCondition = (expression, state) => {
7
- try {
8
- const keys = Object.keys(state);
9
- const values = Object.values(state);
10
- const result = new Function(...keys, `'use strict'; return !!(${expression})`)(...values);
11
- return !!result;
12
- } catch (e) {
13
- return false;
14
- }
15
- };
7
+ const evaluateCondition = (expression, state) => !!evalInScope(expression, state);
16
8
 
17
9
  // Helper function to check if a match references a specific key
18
10
  const matchesKey = (matchStr, key) => matchStr === key || matchStr.startsWith(key + '.');
package/conditionals.js CHANGED
@@ -2,20 +2,10 @@ import parse from './parse.js';
2
2
  import affected from './affected.js';
3
3
  import hydrate from './hydrate.js';
4
4
  import { createScopedState, renderAllIterations, initializeBlock } from './iterate.js';
5
+ import { evalInScope } from './utils.js';
5
6
 
6
7
  // Evaluate conditional expression in state context
7
- const evaluateCondition = (expression, state) => {
8
- try {
9
- const keys = Object.keys(state);
10
- const values = Object.values(state);
11
- // Create a function with state keys as parameters and evaluate the expression
12
- const result = new Function(...keys, `'use strict'; return !!(${expression})`)(...values);
13
- return !!result; // Coerce to boolean
14
- } catch (e) {
15
- console.warn(`Error evaluating condition "${expression}":`, e);
16
- return false; // Default to false on error
17
- }
18
- };
8
+ const evaluateCondition = (expression, state) => !!evalInScope(expression, state);
19
9
 
20
10
  // Extract state dependencies from an expression
21
11
  // e.g., "count > 5" → ["count"]
package/hydrate.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { updateIteration } from './iterate.js';
2
2
  import { updateConditional } from './conditionals.js';
3
3
  import { VALUE_ATTRS, DOM_PROPERTIES, BINDING_REGEX, PURE_BINDING_REGEX } from './constants.js';
4
+ import { evalInScope } from './utils.js';
4
5
 
5
6
  // Keep track of old state for diffing
6
7
  let previousState = {};
@@ -9,19 +10,6 @@ export const setPreviousState = (state) => {
9
10
  previousState = { ...state };
10
11
  };
11
12
 
12
- // Evaluate expression in the context of state
13
- const evalInScope = (expr, state) => {
14
- try {
15
- const keys = Object.keys(state);
16
- const values = Object.values(state);
17
- // Create a function with state keys as parameters and evaluate the expression
18
- return new Function(...keys, `'use strict'; return (${expr})`)(...values);
19
- } catch (e) {
20
- // Fallback to undefined if evaluation fails
21
- return undefined;
22
- }
23
- };
24
-
25
13
  export default (affected, state, linkList = {}) => {
26
14
  affected.forEach((aff) => {
27
15
  // Handle iteration updates
package/index.js CHANGED
@@ -68,15 +68,23 @@ const main = (s, attrName = 'vibe') => {
68
68
  afterDomMutation: [],
69
69
  };
70
70
 
71
- // Observer reference - set after observer is created
71
+ // Observer reference and callback - defined here so state handler can access processMutations
72
72
  let observer = null;
73
+ let processMutations = null;
73
74
 
74
75
  const $ = state(s, (newState) => {
75
76
  const mergedState = deepMerge($, newState);
76
77
  const affectedElements = affected(parsedTree, previousState, mergedState);
77
78
 
78
- if (observer) observer.disconnect();
79
+ // Capture pending mutations before disconnecting (takeRecords clears the queue)
80
+ let pendingMutations = [];
81
+ if (observer) {
82
+ pendingMutations = observer.takeRecords();
83
+ observer.disconnect();
84
+ }
85
+
79
86
  hydrate(affectedElements, mergedState, linkList);
87
+
80
88
  if (observer) {
81
89
  observer.observe(rootElement, {
82
90
  attributes: false,
@@ -84,6 +92,11 @@ const main = (s, attrName = 'vibe') => {
84
92
  childList: true,
85
93
  subtree: true,
86
94
  });
95
+
96
+ // Process mutations that were pending before we disconnected
97
+ if (pendingMutations.length > 0 && processMutations) {
98
+ processMutations(pendingMutations);
99
+ }
87
100
  }
88
101
 
89
102
  const prev = structuredClone(previousState);
@@ -112,7 +125,8 @@ const main = (s, attrName = 'vibe') => {
112
125
  renderAllIterations(parsedTree, $, linkList);
113
126
  renderAllConditionals(parsedTree, $, linkList);
114
127
 
115
- observer = new MutationObserver((mutations) => {
128
+ // Define observer callback as named function so we can call it manually for pending mutations
129
+ processMutations = (mutations) => {
116
130
  // Early exit if no mutations to process (common case)
117
131
  if (mutations.length === 0) return;
118
132
 
@@ -120,7 +134,6 @@ const main = (s, attrName = 'vibe') => {
120
134
  let parsedParents = null; // Lazy init - only create Set when needed
121
135
 
122
136
  mutations.forEach(({ addedNodes, removedNodes, target }) => {
123
- // Process removed nodes first (cleanup before additions)
124
137
  removedNodes.forEach((node) => {
125
138
  const entry = Object.entries(linkList).find(([_, element]) => element === node);
126
139
 
@@ -210,7 +223,9 @@ const main = (s, attrName = 'vibe') => {
210
223
  if (hadChanges) {
211
224
  hooks.afterDomMutation.forEach((callback) => callback());
212
225
  }
213
- });
226
+ };
227
+
228
+ observer = new MutationObserver(processMutations);
214
229
 
215
230
  observer.observe(rootElement, {
216
231
  attributes: false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ape-egg/vibe",
3
- "version": "1.0.3",
3
+ "version": "1.0.5",
4
4
  "type": "module",
5
5
  "description": "Runtime-first reactivity",
6
6
  "main": "index.js",
package/utils.js CHANGED
@@ -2,6 +2,19 @@
2
2
  let hashCounter = 0;
3
3
  export const hash = () => `_${hashCounter++}`;
4
4
 
5
+ // Evaluate expression in the context of state
6
+ export const evalInScope = (expr, state) => {
7
+ try {
8
+ // Normalize whitespace - collapse newlines/spaces to single space (resilient to IDE formatting)
9
+ const normalized = expr.replace(/\s+/g, ' ').trim();
10
+ const keys = Object.keys(state);
11
+ const values = Object.values(state);
12
+ return new Function(...keys, `'use strict'; return (${normalized})`)(...values);
13
+ } catch (e) {
14
+ return undefined;
15
+ }
16
+ };
17
+
5
18
  // Instead of using lodash-es as a dependency, we run our own deepMerge (mergeWith in lodash)
6
19
  export const deepMerge = (target, source) => {
7
20
  // Handle null/undefined