@ape-egg/vibe 1.0.5 → 1.1.2

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.
Files changed (35) hide show
  1. package/CHANGELOG.md +112 -0
  2. package/README.md +228 -23
  3. package/compiler/bin/vibe-compile.js +109 -0
  4. package/compiler/native/.gitkeep +0 -0
  5. package/compiler/native/vibe-compiler-darwin-arm64 +0 -0
  6. package/compiler/src/Cargo.lock +1885 -0
  7. package/compiler/src/Cargo.toml +29 -0
  8. package/compiler/src/compiler/compile.rs +1209 -0
  9. package/compiler/src/compiler/mod.rs +5 -0
  10. package/compiler/src/config.rs +184 -0
  11. package/compiler/src/main.rs +284 -0
  12. package/compiler/src/parser/element.rs +96 -0
  13. package/compiler/src/parser/html.rs +335 -0
  14. package/compiler/src/parser/mod.rs +8 -0
  15. package/index.js +2 -248
  16. package/package.json +26 -3
  17. package/{affected.js → runtime/affected.js} +64 -4
  18. package/runtime/cleanup.js +59 -0
  19. package/runtime/component.js +116 -0
  20. package/{conditionals.js → runtime/conditionals.js} +25 -11
  21. package/{constants.js → runtime/constants.js} +23 -3
  22. package/runtime/debug.js +91 -0
  23. package/{hydrate.js → runtime/hydrate.js} +57 -7
  24. package/runtime/index.js +614 -0
  25. package/{iterate.js → runtime/iterate.js} +53 -45
  26. package/{iteration-utils.js → runtime/iteration-utils.js} +11 -1
  27. package/{parse.js → runtime/parse.js} +37 -7
  28. package/runtime/state.js +52 -0
  29. package/ROADMAP.md +0 -289
  30. package/llms.txt +0 -279
  31. package/state.js +0 -26
  32. /package/{_vibe-compiled-iteration-batch.js → runtime/_vibe-compiled-iteration-batch.js} +0 -0
  33. /package/{link.js → runtime/manifest.js} +0 -0
  34. /package/{utils.js → runtime/utils.js} +0 -0
  35. /package/{vibe.css → runtime/vibe.css} +0 -0
@@ -126,37 +126,30 @@ export const initializeBlock = (templateNodes, scopedState, cachedTree = null) =
126
126
  let clonedNodes = [];
127
127
  let firstElement = null;
128
128
 
129
- // Fast path: clone cached tree with new element references
130
- if (cachedTree) {
131
- // Clone nodes directly
132
- for (let i = 0; i < templateNodes.length; i++) {
133
- const cloned = templateNodes[i].cloneNode(true);
134
- clonedNodes.push(cloned);
135
- if (!firstElement && cloned.nodeType === 1) {
136
- firstElement = cloned;
137
- }
138
- }
139
- // Use virtual root to match template structure (avoids creating DOM container)
140
- // cloneTreeWithElements only needs childNodes property
141
- tree = cloneTreeWithElements(cachedTree, { childNodes: clonedNodes });
142
- } else {
143
- // Slow path: clone and parse from scratch
144
- for (let i = 0; i < templateNodes.length; i++) {
145
- const cloned = templateNodes[i].cloneNode(true);
146
- clonedNodes.push(cloned);
147
- if (!firstElement && cloned.nodeType === 1) {
148
- firstElement = cloned;
149
- }
129
+ // Always use slow path: clone and parse from scratch
130
+ // TODO: Re-enable fast path once cloneTreeWithElements properly handles nested conditionals
131
+
132
+ // Create a container for parsing (to capture all nodes including comment nodes like <!-- if -->)
133
+ const parseContainer = document.createElement('div');
134
+
135
+ // Clone all template nodes into the container
136
+ for (let i = 0; i < templateNodes.length; i++) {
137
+ const cloned = templateNodes[i].cloneNode(true);
138
+ parseContainer.appendChild(cloned);
139
+ if (!firstElement && cloned.nodeType === 1) {
140
+ firstElement = cloned;
150
141
  }
142
+ }
151
143
 
152
- // Fallback for text-only templates
153
- if (!firstElement && clonedNodes.length > 0) {
154
- const container = document.createElement('span');
155
- clonedNodes.forEach((node) => container.appendChild(node.cloneNode(true)));
156
- firstElement = container;
157
- }
144
+ // Parse the entire container (includes all nodes + conditionals)
145
+ tree = parse(parseContainer);
146
+
147
+ // Extract the cloned nodes from the container (these are the same nodes the tree references)
148
+ clonedNodes = Array.from(parseContainer.childNodes);
158
149
 
159
- tree = firstElement ? parse(firstElement) : null;
150
+ // If no firstElement found, use parseContainer as fallback
151
+ if (!firstElement) {
152
+ firstElement = parseContainer;
160
153
  }
161
154
 
162
155
  if (tree) {
@@ -229,11 +222,13 @@ export const createScopedState = (globalState, localVars, parentScope = {}) => {
229
222
  };
230
223
 
231
224
  // Render all iterations in the parsed tree
232
- export const renderAllIterations = (tree, state, linkList, parentScope = {}) => {
225
+ export const renderAllIterations = (tree, state, manifest, parentScope = {}) => {
226
+ let count = 0;
227
+
233
228
  // If this is an iteration node, render it
234
229
  if (tree.type === 'iteration') {
235
- renderIteration(tree, state, linkList, parentScope);
236
- return;
230
+ renderIteration(tree, state, manifest, parentScope);
231
+ return 1;
237
232
  }
238
233
 
239
234
  // Recursively render iterations in child nodes
@@ -241,10 +236,12 @@ export const renderAllIterations = (tree, state, linkList, parentScope = {}) =>
241
236
  for (const key in tree.children) {
242
237
  const child = tree.children[key];
243
238
  if (child && typeof child === 'object') {
244
- renderAllIterations(child, state, linkList, parentScope);
239
+ count += renderAllIterations(child, state, manifest, parentScope);
245
240
  }
246
241
  }
247
242
  }
243
+
244
+ return count;
248
245
  };
249
246
 
250
247
  // Callback for rendering conditionals - set by conditionals.js to avoid circular import
@@ -254,13 +251,20 @@ export const setRenderAllConditionals = (fn) => {
254
251
  };
255
252
 
256
253
  // Initial render of an iteration block
257
- export const renderIteration = (iterationNode, state, linkList, parentScope = {}) => {
254
+ export const renderIteration = (iterationNode, state, manifest, parentScope = {}) => {
258
255
  const { arrayPath, itemAlias, indexAlias, template, startComment, endComment } =
259
256
  iterationNode.meta;
260
257
 
261
258
  // Already rendered - updates go through updateIteration
262
259
  if (iterationNode.runtime.instances?.length > 0) return;
263
260
 
261
+ // Check if this iteration has already been rendered
262
+ // We use a marker on the startComment node itself (survives re-parsing)
263
+ // @ts-ignore - adding custom property to comment node
264
+ if (startComment.__vibeRendered) {
265
+ return;
266
+ }
267
+
264
268
  // Remove template nodes from DOM on first render
265
269
  if (!iterationNode.runtime.templateRemoved) {
266
270
  let node = startComment.nextSibling;
@@ -304,18 +308,22 @@ export const renderIteration = (iterationNode, state, linkList, parentScope = {}
304
308
  // Recursively render nested iterations and conditionals
305
309
  if (tree) {
306
310
  const nestedScope = { ...parentScope, ...localVars };
307
- renderAllIterations(tree, scopedState, linkList, nestedScope);
308
- _renderAllConditionals(tree, scopedState, linkList, nestedScope);
311
+ renderAllIterations(tree, scopedState, manifest, nestedScope);
312
+ _renderAllConditionals(tree, scopedState, manifest, nestedScope);
309
313
  }
310
314
 
311
- instances.push({ element, tree, item, index: i, clonedNodes });
315
+ instances.push({ element, tree, item, index: i, clonedNodes, scopedState });
312
316
  }
313
317
 
314
318
  iterationNode.runtime.instances = instances;
319
+
320
+ // Mark comment as rendered (survives re-parsing since comment nodes persist in DOM)
321
+ // @ts-ignore - adding custom property to comment node
322
+ startComment.__vibeRendered = true;
315
323
  };
316
324
 
317
325
  // Update an iteration block when array changes
318
- export const updateIteration = (iterationNode, newState, oldState, linkList, parentScope = {}) => {
326
+ export const updateIteration = (iterationNode, newState, oldState, manifest, parentScope = {}) => {
319
327
  if (!iterationNode.runtime.instances || !iterationNode.runtime.templateRemoved) return;
320
328
 
321
329
  const { arrayPath, template, startComment, endComment } = iterationNode.meta;
@@ -348,13 +356,13 @@ export const updateIteration = (iterationNode, newState, oldState, linkList, par
348
356
  removeInstance(iterationNode, op.index);
349
357
  break;
350
358
  case 'ADD':
351
- addInstance(iterationNode, op.item, op.index, newState, linkList, parentScope);
359
+ addInstance(iterationNode, op.item, op.index, newState, manifest, parentScope);
352
360
  break;
353
361
  case 'MOVE':
354
362
  moveInstance(iterationNode, op.from, op.to);
355
363
  break;
356
364
  case 'UPDATE':
357
- updateInstance(iterationNode, op.index, op.item, newState, linkList, parentScope);
365
+ updateInstance(iterationNode, op.index, op.item, newState, manifest, parentScope);
358
366
  break;
359
367
  }
360
368
  });
@@ -365,7 +373,7 @@ export const updateIteration = (iterationNode, newState, oldState, linkList, par
365
373
  };
366
374
 
367
375
  // Add a new instance at the specified index
368
- const addInstance = (iterationNode, item, index, state, linkList, parentScope) => {
376
+ const addInstance = (iterationNode, item, index, state, manifest, parentScope) => {
369
377
  const { itemAlias, indexAlias, template, startComment, endComment } = iterationNode.meta;
370
378
 
371
379
  const localVars = { [itemAlias]: item, [indexAlias]: index };
@@ -387,8 +395,8 @@ const addInstance = (iterationNode, item, index, state, linkList, parentScope) =
387
395
  // Recursively render nested iterations and conditionals
388
396
  if (tree) {
389
397
  const nestedScope = { ...parentScope, ...localVars };
390
- renderAllIterations(tree, scopedState, linkList, nestedScope);
391
- _renderAllConditionals(tree, scopedState, linkList, nestedScope);
398
+ renderAllIterations(tree, scopedState, manifest, nestedScope);
399
+ _renderAllConditionals(tree, scopedState, manifest, nestedScope);
392
400
  }
393
401
 
394
402
  iterationNode.runtime.instances.splice(index, 0, { element, tree, item, index, clonedNodes });
@@ -432,7 +440,7 @@ const moveInstance = (iterationNode, fromIndex, toIndex) => {
432
440
  };
433
441
 
434
442
  // Update an instance with new item data
435
- const updateInstance = (iterationNode, index, newItem, state, linkList, parentScope = {}) => {
443
+ const updateInstance = (iterationNode, index, newItem, state, manifest, parentScope = {}) => {
436
444
  if (index < 0 || index >= iterationNode.runtime.instances.length) return;
437
445
 
438
446
  const { itemAlias, indexAlias, template } = iterationNode.meta;
@@ -455,8 +463,8 @@ const updateInstance = (iterationNode, index, newItem, state, linkList, parentSc
455
463
  // Recursively render nested iterations and conditionals
456
464
  if (tree) {
457
465
  const nestedScope = { ...parentScope, ...localVars };
458
- renderAllIterations(tree, scopedState, linkList, nestedScope);
459
- _renderAllConditionals(tree, scopedState, linkList, nestedScope);
466
+ renderAllIterations(tree, scopedState, manifest, nestedScope);
467
+ _renderAllConditionals(tree, scopedState, manifest, nestedScope);
460
468
  }
461
469
 
462
470
  // Update instance
@@ -59,7 +59,17 @@ export const findConditionalEnd = (nodes, startIndex) => {
59
59
  }
60
60
  }
61
61
 
62
- throw new Error('Unmatched <!-- if --> comment: missing <!-- /if -->');
62
+ // Build error message with context
63
+ const nodeTypes = nodes.slice(startIndex - 1, Math.min(startIndex + 10, nodes.length)).map((n, idx) => {
64
+ const actualIdx = startIndex - 1 + idx;
65
+ const prefix = actualIdx === startIndex - 1 ? '→ ' : ' ';
66
+ if (n.nodeName === '#comment') {
67
+ return `${prefix}[${actualIdx}] #comment: "${n.textContent.trim()}"`;
68
+ }
69
+ return `${prefix}[${actualIdx}] ${n.nodeName}`;
70
+ });
71
+
72
+ throw new Error(`Unmatched <!-- if --> comment: missing <!-- /if -->\nSearching from index ${startIndex} in ${nodes.length} nodes\nContext:\n${nodeTypes.join('\n')}`);
63
73
  };
64
74
 
65
75
  // Generate stable hash for objects
@@ -19,7 +19,7 @@ const parseHTML = (children, rootKey = undefined) =>
19
19
  return rootKey || `${s}${`\$[${innerNodeIdentifier}]`}`;
20
20
  }, '');
21
21
 
22
- const recursive = (children, rootKey = undefined, skipIndices = new Set()) => {
22
+ const recursive = (children, rootKey = undefined, skipIndices = new Set(), stats = { skipped: 0 }) => {
23
23
  let result = {};
24
24
 
25
25
  for (let i = 0; i < children.length; i++) {
@@ -31,7 +31,10 @@ const recursive = (children, rootKey = undefined, skipIndices = new Set()) => {
31
31
 
32
32
  // Skip non-reactive elements and dehydrated elements
33
33
  if (NON_REACTIVE_ELEMENTS.includes(nodeName)) continue;
34
- if (element.hasAttribute?.('dehydrate')) continue;
34
+ if (element.hasAttribute?.('dehydrate')) {
35
+ stats.skipped++;
36
+ continue;
37
+ }
35
38
 
36
39
  // Handle iteration comments
37
40
  if (nodeName === '#comment') {
@@ -54,7 +57,7 @@ const recursive = (children, rootKey = undefined, skipIndices = new Set()) => {
54
57
  });
55
58
 
56
59
  // Parse the template recursively
57
- const templateParsed = recursive([...templateContainer.childNodes]);
60
+ const templateParsed = recursive([...templateContainer.childNodes], undefined, new Set(), stats);
58
61
 
59
62
  // Store iteration metadata
60
63
  const iterationKey = `iteration_${hash()}`;
@@ -115,7 +118,7 @@ const recursive = (children, rootKey = undefined, skipIndices = new Set()) => {
115
118
  });
116
119
 
117
120
  // Parse the true branch recursively
118
- const trueBranchParsed = recursive([...trueBranchContainer.childNodes]);
121
+ const trueBranchParsed = recursive([...trueBranchContainer.childNodes], undefined, new Set(), stats);
119
122
 
120
123
  // Extract false branch nodes if else exists
121
124
  let falseBranchParsed = null;
@@ -126,7 +129,7 @@ const recursive = (children, rootKey = undefined, skipIndices = new Set()) => {
126
129
  falseBranchNodes.forEach((node) => {
127
130
  falseBranchContainer.appendChild(node.cloneNode(true));
128
131
  });
129
- falseBranchParsed = recursive([...falseBranchContainer.childNodes]);
132
+ falseBranchParsed = recursive([...falseBranchContainer.childNodes], undefined, new Set(), stats);
130
133
  }
131
134
 
132
135
  // Store conditional metadata
@@ -184,17 +187,28 @@ const recursive = (children, rootKey = undefined, skipIndices = new Set()) => {
184
187
 
185
188
  // Check for attribute bindings
186
189
  const attributes = {};
190
+ const nameBindings = [];
187
191
  if (element.attributes) {
188
192
  for (let j = 0; j < element.attributes.length; j++) {
189
193
  const attr = element.attributes[j];
190
194
  // Reset lastIndex before test - BINDING_REGEX has 'g' flag which persists state
191
195
  BINDING_REGEX.lastIndex = 0;
196
+
197
+ // Check if attribute name contains binding (e.g., @[section.icon])
198
+ if (BINDING_REGEX.test(attr.name)) {
199
+ nameBindings.push(attr.name);
200
+ continue; // Don't process as regular attribute
201
+ }
202
+
203
+ // Check if attribute value contains binding
204
+ BINDING_REGEX.lastIndex = 0;
192
205
  if (BINDING_REGEX.test(attr.value)) {
193
206
  attributes[attr.name] = attr.value;
194
207
  }
195
208
  }
196
209
  }
197
210
  const hasAttributeBindings = Object.keys(attributes).length > 0;
211
+ const hasNameBindings = nameBindings.length > 0;
198
212
 
199
213
  const hasChildren = childNodes.length;
200
214
 
@@ -205,8 +219,9 @@ const recursive = (children, rootKey = undefined, skipIndices = new Set()) => {
205
219
  result[rootKey || nodeIdentifier] = {
206
220
  parsed,
207
221
  element,
208
- children: recursive(iteratableChildren),
222
+ children: recursive(iteratableChildren, undefined, new Set(), stats),
209
223
  ...(hasAttributeBindings && { attributes }),
224
+ ...(hasNameBindings && { nameBindings }),
210
225
  };
211
226
  } else {
212
227
  result[nodeIdentifier] = {
@@ -214,6 +229,7 @@ const recursive = (children, rootKey = undefined, skipIndices = new Set()) => {
214
229
  element,
215
230
  children: {},
216
231
  ...(hasAttributeBindings && { attributes }),
232
+ ...(hasNameBindings && { nameBindings }),
217
233
  };
218
234
  }
219
235
  }
@@ -223,13 +239,25 @@ const recursive = (children, rootKey = undefined, skipIndices = new Set()) => {
223
239
 
224
240
  export default (root, rootKey = undefined) => {
225
241
  const { childNodes } = root;
242
+ const stats = { skipped: 0 };
226
243
 
227
244
  // Check for attribute bindings on the root element itself (only if element has attributes)
228
245
  let attributes = null;
246
+ let nameBindings = null;
229
247
  if (root.attributes && root.attributes.length > 0) {
230
248
  for (let j = 0; j < root.attributes.length; j++) {
231
249
  const attr = root.attributes[j];
232
250
  BINDING_REGEX.lastIndex = 0;
251
+
252
+ // Check if attribute name contains binding
253
+ if (BINDING_REGEX.test(attr.name)) {
254
+ if (!nameBindings) nameBindings = [];
255
+ nameBindings.push(attr.name);
256
+ continue;
257
+ }
258
+
259
+ // Check if attribute value contains binding
260
+ BINDING_REGEX.lastIndex = 0;
233
261
  if (BINDING_REGEX.test(attr.value)) {
234
262
  if (!attributes) attributes = {};
235
263
  attributes[attr.name] = attr.value;
@@ -241,7 +269,9 @@ export default (root, rootKey = undefined) => {
241
269
  // html: root.outerHTML,
242
270
  parsed: parseHTML([...childNodes], rootKey),
243
271
  element: root,
244
- children: recursive(Array.from(childNodes), rootKey),
272
+ children: recursive(Array.from(childNodes), rootKey, new Set(), stats),
245
273
  ...(attributes && { attributes }),
274
+ ...(nameBindings && { nameBindings }),
275
+ stats,
246
276
  };
247
277
  };
@@ -0,0 +1,52 @@
1
+ // Track which objects are already proxied to avoid double-wrapping
2
+ const proxyCache = new WeakMap();
3
+
4
+ // Deep proxy: recursively wrap nested objects and arrays
5
+ const createDeepProxy = (target, rerender, rootState = null, rootProp = null) => {
6
+ // For root level, rootState is the target itself
7
+ if (rootState === null) {
8
+ rootState = target;
9
+ }
10
+
11
+ // Check cache first
12
+ if (proxyCache.has(target)) {
13
+ return proxyCache.get(target);
14
+ }
15
+
16
+ const proxy = new Proxy(target, {
17
+ set(obj, prop, value) {
18
+ const oldValue = obj[prop];
19
+ const ref = Reflect.set(obj, prop, value);
20
+
21
+ // Only trigger rerender if value actually changed
22
+ if (oldValue !== value) {
23
+ // If we're at root level, use the prop being set
24
+ // If we're nested, use the root prop that contains this nested object
25
+ const changedProp = rootProp || prop;
26
+ rerender({ [changedProp]: rootState[changedProp] });
27
+ }
28
+
29
+ return ref;
30
+ },
31
+
32
+ get(target, prop) {
33
+ const value = Reflect.get(target, prop);
34
+
35
+ // Don't proxy non-objects, functions, or null
36
+ if (value === null || typeof value !== 'object' || typeof value === 'function') {
37
+ return value;
38
+ }
39
+
40
+ // Recursively wrap nested objects/arrays
41
+ // If rootProp is null (we're at root level), set it to current prop
42
+ // Otherwise keep passing down the same rootProp
43
+ const topProp = rootProp === null ? prop : rootProp;
44
+ return createDeepProxy(value, rerender, rootState, topProp);
45
+ },
46
+ });
47
+
48
+ proxyCache.set(target, proxy);
49
+ return proxy;
50
+ };
51
+
52
+ export default (state, rerender) => createDeepProxy(state, rerender);
package/ROADMAP.md DELETED
@@ -1,289 +0,0 @@
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*