@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 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
- const { parsed } = parse(picked.element);
181
- picked.parsed = parsed;
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
- const affectedElements = affected(parsedTree, $, $);
189
-
190
- hydrate(affectedElements, $, linkList);
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
- // Trigger afterDomMutation hook for removed nodes
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, {
@@ -211,15 +211,9 @@ export const computeDiff = (oldKeys, newKeys, oldArray, newArray) => {
211
211
  });
212
212
  }
213
213
 
214
- // Check if position changed
215
- if (oldIdx !== newIdx) {
216
- operations.push({
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ape-egg/vibe",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "type": "module",
5
5
  "description": "Runtime-first reactivity",
6
6
  "main": "index.js",
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
  }