@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.
- package/CHANGELOG.md +112 -0
- package/README.md +228 -23
- package/compiler/bin/vibe-compile.js +109 -0
- package/compiler/native/.gitkeep +0 -0
- package/compiler/native/vibe-compiler-darwin-arm64 +0 -0
- package/compiler/src/Cargo.lock +1885 -0
- package/compiler/src/Cargo.toml +29 -0
- package/compiler/src/compiler/compile.rs +1209 -0
- package/compiler/src/compiler/mod.rs +5 -0
- package/compiler/src/config.rs +184 -0
- package/compiler/src/main.rs +284 -0
- package/compiler/src/parser/element.rs +96 -0
- package/compiler/src/parser/html.rs +335 -0
- package/compiler/src/parser/mod.rs +8 -0
- package/index.js +2 -248
- package/package.json +26 -3
- package/{affected.js → runtime/affected.js} +64 -4
- package/runtime/cleanup.js +59 -0
- package/runtime/component.js +116 -0
- package/{conditionals.js → runtime/conditionals.js} +25 -11
- package/{constants.js → runtime/constants.js} +23 -3
- package/runtime/debug.js +91 -0
- package/{hydrate.js → runtime/hydrate.js} +57 -7
- package/runtime/index.js +614 -0
- package/{iterate.js → runtime/iterate.js} +53 -45
- package/{iteration-utils.js → runtime/iteration-utils.js} +11 -1
- package/{parse.js → runtime/parse.js} +37 -7
- package/runtime/state.js +52 -0
- package/ROADMAP.md +0 -289
- package/llms.txt +0 -279
- package/state.js +0 -26
- /package/{_vibe-compiled-iteration-batch.js → runtime/_vibe-compiled-iteration-batch.js} +0 -0
- /package/{link.js → runtime/manifest.js} +0 -0
- /package/{utils.js → runtime/utils.js} +0 -0
- /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
|
-
//
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
308
|
-
_renderAllConditionals(tree, scopedState,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
391
|
-
_renderAllConditionals(tree, scopedState,
|
|
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,
|
|
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,
|
|
459
|
-
_renderAllConditionals(tree, scopedState,
|
|
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
|
-
|
|
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'))
|
|
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
|
};
|
package/runtime/state.js
ADDED
|
@@ -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*
|