@ape-egg/vibe 1.0.0 → 1.0.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 +20 -0
- package/index.js +57 -70
- package/iteration-utils.js +3 -9
- package/package.json +1 -1
- package/parse.js +16 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.0.2] - 2026-01-20
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- **parse.js**: Root element attribute bindings now captured when parsing individual nodes (fixes dynamically added elements with `value="@[...]"` not being hydrated)
|
|
8
|
+
- **MutationObserver**: Added nodes now only hydrate themselves and descendants, not the entire tree (fixes focus loss and mutation cascade on dynamic DOM updates)
|
|
9
|
+
- **MutationObserver**: Removed unnecessary hydration calls when nodes are removed (cleanup only)
|
|
10
|
+
- **MutationObserver**: Batched hook firing - `afterDomMutation` now fires once after all mutations are processed, not per-node (major performance fix for bulk DOM updates)
|
|
11
|
+
- **MutationObserver**: Parent elements are now re-parsed only once per mutation batch, not per-child (performance optimization)
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## [1.0.1] - 2026-01-20
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
|
|
19
|
+
- Fixed iteration diff bug where MOVE operations corrupted indices, causing wrong items to be deleted
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
3
23
|
## [1.0.0] - 2026-01-19
|
|
4
24
|
|
|
5
25
|
### Added
|
package/index.js
CHANGED
|
@@ -106,33 +106,6 @@ const main = (s, attrName = "vibe") => {
|
|
|
106
106
|
enumerable: false,
|
|
107
107
|
});
|
|
108
108
|
|
|
109
|
-
// Batch DOM changes: disconnects observer, runs fn, re-parses everything
|
|
110
|
-
Object.defineProperty($, 'batch', {
|
|
111
|
-
value: (fn) => {
|
|
112
|
-
if (observer) observer.disconnect();
|
|
113
|
-
try {
|
|
114
|
-
fn();
|
|
115
|
-
} finally {
|
|
116
|
-
parsedTree = parse(rootElement);
|
|
117
|
-
linkList = link(parsedTree);
|
|
118
|
-
const affectedElements = affected(parsedTree, $, $);
|
|
119
|
-
hydrate(affectedElements, $, linkList);
|
|
120
|
-
setPreviousState($);
|
|
121
|
-
renderAllIterations(parsedTree, $, linkList);
|
|
122
|
-
renderAllConditionals(parsedTree, $, linkList);
|
|
123
|
-
if (observer) {
|
|
124
|
-
observer.observe(rootElement, {
|
|
125
|
-
attributes: false,
|
|
126
|
-
characterData: false,
|
|
127
|
-
childList: true,
|
|
128
|
-
subtree: true,
|
|
129
|
-
});
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
},
|
|
133
|
-
enumerable: false,
|
|
134
|
-
});
|
|
135
|
-
|
|
136
109
|
// Initial hydration
|
|
137
110
|
const affectedElements = affected(parsedTree, $, $);
|
|
138
111
|
hydrate(affectedElements, $, linkList);
|
|
@@ -144,16 +117,54 @@ const main = (s, attrName = "vibe") => {
|
|
|
144
117
|
renderAllConditionals(parsedTree, $, linkList);
|
|
145
118
|
|
|
146
119
|
observer = new MutationObserver((mutations) => {
|
|
120
|
+
// Early exit if no mutations to process (common case)
|
|
121
|
+
if (mutations.length === 0) return;
|
|
122
|
+
|
|
123
|
+
let hadChanges = false;
|
|
124
|
+
let parsedParents = null; // Lazy init - only create Set when needed
|
|
125
|
+
|
|
147
126
|
mutations.forEach(({ addedNodes, removedNodes, target }) => {
|
|
127
|
+
// Process removed nodes first (cleanup before additions)
|
|
128
|
+
removedNodes.forEach((node) => {
|
|
129
|
+
const entry = Object.entries(linkList).find(
|
|
130
|
+
([_, element]) => element === node,
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
// Skip nodes that aren't tracked (e.g., iteration-generated nodes or nodes outside reactive scope)
|
|
134
|
+
if (!entry) return;
|
|
135
|
+
|
|
136
|
+
const [dotAnnotation] = entry;
|
|
137
|
+
delete linkList[dotAnnotation];
|
|
138
|
+
|
|
139
|
+
const dotPath = dotAnnotation.split(".");
|
|
140
|
+
const name = dotPath.pop();
|
|
141
|
+
const parentDotAnnotation = dotPath.join(".");
|
|
142
|
+
|
|
143
|
+
const picked = navigateTree(parsedTree, parentDotAnnotation);
|
|
144
|
+
|
|
145
|
+
// If we can't navigate to the parent, skip
|
|
146
|
+
if (!picked || !picked.element) return;
|
|
147
|
+
|
|
148
|
+
// Update parent's parsed HTML (only once per parent)
|
|
149
|
+
if (!parsedParents) parsedParents = new Set();
|
|
150
|
+
if (!parsedParents.has(picked)) {
|
|
151
|
+
const { parsed } = parse(picked.element);
|
|
152
|
+
picked.parsed = parsed;
|
|
153
|
+
parsedParents.add(picked);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Remove the node from parent's children
|
|
157
|
+
delete picked.children[name];
|
|
158
|
+
|
|
159
|
+
hadChanges = true;
|
|
160
|
+
});
|
|
161
|
+
|
|
148
162
|
addedNodes.forEach((node) => {
|
|
149
163
|
// Skip if node itself or any ancestor is non-reactive
|
|
150
164
|
if (isNonReactiveOrInside(node)) {
|
|
151
165
|
return;
|
|
152
166
|
}
|
|
153
167
|
|
|
154
|
-
const index = [...target.childNodes].findIndex(
|
|
155
|
-
(childNode) => childNode === node,
|
|
156
|
-
);
|
|
157
168
|
const entry = Object.entries(linkList).find(
|
|
158
169
|
([_, element]) => element === target,
|
|
159
170
|
);
|
|
@@ -176,57 +187,33 @@ const main = (s, attrName = "vibe") => {
|
|
|
176
187
|
const name = `${node.nodeName.toLowerCase()}_${hash()}`;
|
|
177
188
|
const parsedNode = parse(node);
|
|
178
189
|
|
|
179
|
-
// Update parent's parsed HTML
|
|
180
|
-
|
|
181
|
-
picked
|
|
190
|
+
// Update parent's parsed HTML (only once per parent)
|
|
191
|
+
if (!parsedParents) parsedParents = new Set();
|
|
192
|
+
if (!parsedParents.has(picked)) {
|
|
193
|
+
const { parsed } = parse(picked.element);
|
|
194
|
+
picked.parsed = parsed;
|
|
195
|
+
parsedParents.add(picked);
|
|
196
|
+
}
|
|
182
197
|
|
|
183
198
|
// Add the parsed node to parent's children
|
|
184
199
|
picked.children[name] = parsedNode;
|
|
185
200
|
|
|
186
201
|
linkList[`${dotAnnotation}.${name}`] = node;
|
|
187
202
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
// Trigger afterDomMutation hook for added nodes
|
|
193
|
-
hooks.afterDomMutation.forEach((callback) => callback());
|
|
194
|
-
});
|
|
195
|
-
|
|
196
|
-
removedNodes.forEach((node) => {
|
|
197
|
-
const entry = Object.entries(linkList).find(
|
|
198
|
-
([_, element]) => element === node,
|
|
199
|
-
);
|
|
200
|
-
|
|
201
|
-
// Skip nodes that aren't tracked (e.g., iteration-generated nodes or nodes outside reactive scope)
|
|
202
|
-
if (!entry) return;
|
|
203
|
-
|
|
204
|
-
const [dotAnnotation] = entry;
|
|
205
|
-
delete linkList[dotAnnotation];
|
|
203
|
+
// Only hydrate the newly added node and its descendants, not the entire tree
|
|
204
|
+
// Use empty object as "previous state" so all bindings in new node are considered affected
|
|
205
|
+
const affectedElements = affected(parsedNode, {}, $);
|
|
206
206
|
|
|
207
|
-
const dotPath = dotAnnotation.split(".");
|
|
208
|
-
const name = dotPath.pop();
|
|
209
|
-
const parentDotAnnotation = dotPath.join(".");
|
|
210
|
-
|
|
211
|
-
const picked = navigateTree(parsedTree, parentDotAnnotation);
|
|
212
|
-
|
|
213
|
-
// If we can't navigate to the parent, skip
|
|
214
|
-
if (!picked || !picked.element) return;
|
|
215
|
-
|
|
216
|
-
// Update parent's parsed HTML
|
|
217
|
-
const { parsed } = parse(picked.element);
|
|
218
|
-
picked.parsed = parsed;
|
|
219
|
-
|
|
220
|
-
// Remove the node from parent's children
|
|
221
|
-
delete picked.children[name];
|
|
222
|
-
|
|
223
|
-
const affectedElements = affected(parsedTree, $, $);
|
|
224
207
|
hydrate(affectedElements, $, linkList);
|
|
225
208
|
|
|
226
|
-
|
|
227
|
-
hooks.afterDomMutation.forEach((callback) => callback());
|
|
209
|
+
hadChanges = true;
|
|
228
210
|
});
|
|
229
211
|
});
|
|
212
|
+
|
|
213
|
+
// Fire hooks once after all mutations are processed (not per-node)
|
|
214
|
+
if (hadChanges) {
|
|
215
|
+
hooks.afterDomMutation.forEach((callback) => callback());
|
|
216
|
+
}
|
|
230
217
|
});
|
|
231
218
|
|
|
232
219
|
observer.observe(rootElement, {
|
package/iteration-utils.js
CHANGED
|
@@ -211,15 +211,9 @@ export const computeDiff = (oldKeys, newKeys, oldArray, newArray) => {
|
|
|
211
211
|
});
|
|
212
212
|
}
|
|
213
213
|
|
|
214
|
-
//
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
type: "MOVE",
|
|
218
|
-
from: oldIdx,
|
|
219
|
-
to: newIdx,
|
|
220
|
-
key,
|
|
221
|
-
});
|
|
222
|
-
}
|
|
214
|
+
// Note: We don't generate MOVE for index changes here.
|
|
215
|
+
// Items in LCS maintain relative order - after REMOVEs and ADDs,
|
|
216
|
+
// they'll naturally be in correct positions.
|
|
223
217
|
});
|
|
224
218
|
|
|
225
219
|
// Second pass: identify removals
|
package/package.json
CHANGED
package/parse.js
CHANGED
|
@@ -216,10 +216,25 @@ const recursive = (children, rootKey = undefined, skipIndices = new Set()) => {
|
|
|
216
216
|
|
|
217
217
|
export default (root, rootKey = undefined) => {
|
|
218
218
|
const { childNodes } = root
|
|
219
|
+
|
|
220
|
+
// Check for attribute bindings on the root element itself (only if element has attributes)
|
|
221
|
+
let attributes = null;
|
|
222
|
+
if (root.attributes && root.attributes.length > 0) {
|
|
223
|
+
for (let j = 0; j < root.attributes.length; j++) {
|
|
224
|
+
const attr = root.attributes[j];
|
|
225
|
+
BINDING_REGEX.lastIndex = 0;
|
|
226
|
+
if (BINDING_REGEX.test(attr.value)) {
|
|
227
|
+
if (!attributes) attributes = {};
|
|
228
|
+
attributes[attr.name] = attr.value;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
219
233
|
return {
|
|
220
234
|
// html: root.outerHTML,
|
|
221
235
|
parsed: parseHTML([...childNodes], rootKey),
|
|
222
236
|
element: root,
|
|
223
|
-
children: recursive(Array.from(childNodes), rootKey)
|
|
237
|
+
children: recursive(Array.from(childNodes), rootKey),
|
|
238
|
+
...(attributes && { attributes })
|
|
224
239
|
}
|
|
225
240
|
}
|