@ape-egg/vibe 1.0.0 → 1.0.3

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,37 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.0.3] - 2026-01-23
4
+
5
+ ### Added
6
+
7
+ - **MutationObserver**: Now processes `<!-- if -->` and `<!-- each -->` comments when new nodes are added to DOM, enabling dynamic content to use vibe's control flow without manual processing
8
+
9
+ ### Fixed
10
+
11
+ - **affected.js**: Only marks elements as affected when their specific bound values change, preventing unnecessary DOM updates when unrelated state changes (fixes `@[color]` in `<style>` updating on every keystroke when `@[firstName]` changes)
12
+
13
+ ---
14
+
15
+ ## [1.0.2] - 2026-01-20
16
+
17
+ ### Fixed
18
+
19
+ - **parse.js**: Root element attribute bindings now captured when parsing individual nodes (fixes dynamically added elements with `value="@[...]"` not being hydrated)
20
+ - **MutationObserver**: Added nodes now only hydrate themselves and descendants, not the entire tree (fixes focus loss and mutation cascade on dynamic DOM updates)
21
+ - **MutationObserver**: Removed unnecessary hydration calls when nodes are removed (cleanup only)
22
+ - **MutationObserver**: Batched hook firing - `afterDomMutation` now fires once after all mutations are processed, not per-node (major performance fix for bulk DOM updates)
23
+ - **MutationObserver**: Parent elements are now re-parsed only once per mutation batch, not per-child (performance optimization)
24
+
25
+ ---
26
+
27
+ ## [1.0.1] - 2026-01-20
28
+
29
+ ### Fixed
30
+
31
+ - Fixed iteration diff bug where MOVE operations corrupted indices, causing wrong items to be deleted
32
+
33
+ ---
34
+
3
35
  ## [1.0.0] - 2026-01-19
4
36
 
5
37
  ### Added
@@ -58,13 +58,13 @@ const hasNestedStructures = (tree) => {
58
58
  */
59
59
  export const compileBatchFn = (template, itemAlias, indexAlias, stateKeys) => {
60
60
  const templateHtml = template.element.innerHTML.trim();
61
- const escaped = templateHtml
62
- .replace(/\\/g, '\\\\')
63
- .replace(/`/g, '\\`')
64
- .replace(/\$\{/g, '\\${');
61
+ const escaped = templateHtml.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$\{/g, '\\${');
65
62
  const code = escaped.replace(BINDING_REGEX, (_, expr) => '${' + expr + '}');
66
63
 
67
- return new Function('arr', ...stateKeys, `
64
+ return new Function(
65
+ 'arr',
66
+ ...stateKeys,
67
+ `
68
68
  let html = '';
69
69
  const len = arr.length;
70
70
  for (let ${indexAlias} = 0; ${indexAlias} < len; ${indexAlias}++) {
@@ -72,7 +72,8 @@ export const compileBatchFn = (template, itemAlias, indexAlias, stateKeys) => {
72
72
  html += \`${code}\`;
73
73
  }
74
74
  return html;
75
- `);
75
+ `,
76
+ );
76
77
  };
77
78
 
78
79
  /**
@@ -92,7 +93,7 @@ export const renderFast = (iterationNode, array, state, parent, endComment) => {
92
93
  const stateKeys = iterationNode.runtime.stateKeys;
93
94
 
94
95
  // Build HTML string using batch function
95
- const stateValues = stateKeys.map(k => state[k]);
96
+ const stateValues = stateKeys.map((k) => state[k]);
96
97
  const html = batchFn(array, ...stateValues);
97
98
 
98
99
  // Parse with reusable template element
@@ -141,7 +142,7 @@ export const updateFast = (iterationNode, newArray, state, startComment, endComm
141
142
  // Build HTML string using cached batch function
142
143
  const batchFn = iterationNode.runtime.batchFn;
143
144
  const stateKeys = iterationNode.runtime.stateKeys;
144
- const stateValues = stateKeys.map(k => state[k]);
145
+ const stateValues = stateKeys.map((k) => state[k]);
145
146
  const html = batchFn(newArray, ...stateValues);
146
147
 
147
148
  // Parse and insert
package/affected.js CHANGED
@@ -1,6 +1,6 @@
1
- import { resolvePath, deepEqual } from './iteration-utils.js'
2
- import { extractDependencies } from './conditionals.js'
3
- import { BINDING_REGEX } from './constants.js'
1
+ import { resolvePath, deepEqual } from './iteration-utils.js';
2
+ import { extractDependencies } from './conditionals.js';
3
+ import { BINDING_REGEX } from './constants.js';
4
4
 
5
5
  // Evaluate conditional expression
6
6
  const evaluateCondition = (expression, state) => {
@@ -29,7 +29,7 @@ const recursive = (tree, state, newState, affected) => {
29
29
  affected.push({
30
30
  type: 'iteration',
31
31
  node: tree,
32
- changeType: 'array'
32
+ changeType: 'array',
33
33
  });
34
34
  }
35
35
  return affected;
@@ -45,7 +45,7 @@ const recursive = (tree, state, newState, affected) => {
45
45
  affected.push({
46
46
  type: 'conditional',
47
47
  node: tree,
48
- changeType: 'expression'
48
+ changeType: 'expression',
49
49
  });
50
50
  return affected;
51
51
  }
@@ -70,21 +70,37 @@ const recursive = (tree, state, newState, affected) => {
70
70
  if (matches.length) {
71
71
  const shallowState = Object.keys(state);
72
72
  const shallowNewState = Object.keys(newState);
73
+ const isInitialHydration = state === newState;
73
74
 
74
75
  let hasAffected = false;
75
76
  const checkedMatches = [];
76
77
 
77
78
  for (const m of matches) {
78
- const noMatch = !shallowState.some(key => matchesKey(m.inner, key));
79
- const newMatches = shallowNewState.filter(key => matchesKey(m.inner, key));
79
+ const noMatch = !shallowState.some((key) => matchesKey(m.inner, key));
80
+
81
+ let shouldAffect = false;
82
+ let relevantKeys = [];
83
+
84
+ if (isInitialHydration) {
85
+ // Initial hydration: affect all matched keys
86
+ relevantKeys = shallowNewState.filter((key) => matchesKey(m.inner, key));
87
+ shouldAffect = noMatch || relevantKeys.length > 0;
88
+ } else {
89
+ // Update: only affect if value changed
90
+ const changedKeys = shallowNewState.filter((key) =>
91
+ matchesKey(m.inner, key) && state[key] !== newState[key]
92
+ );
93
+ relevantKeys = changedKeys.length > 0 ? changedKeys : shallowState.filter((key) => matchesKey(m.inner, key));
94
+ shouldAffect = noMatch || changedKeys.length > 0;
95
+ }
80
96
 
81
- if (noMatch || newMatches.length) {
97
+ if (shouldAffect) {
82
98
  hasAffected = true;
83
99
  }
84
100
 
85
101
  checkedMatches.push({
86
102
  ...m,
87
- matches: newMatches.length ? newMatches : shallowState.filter(key => matchesKey(m.inner, key))
103
+ matches: relevantKeys,
88
104
  });
89
105
  }
90
106
 
@@ -95,7 +111,7 @@ const recursive = (tree, state, newState, affected) => {
95
111
  matchInner: m.inner,
96
112
  input: m.input,
97
113
  matches: m.matches,
98
- element: tree.element
114
+ element: tree.element,
99
115
  });
100
116
  }
101
117
  }
@@ -105,6 +121,7 @@ const recursive = (tree, state, newState, affected) => {
105
121
  if (tree.attributes) {
106
122
  const shallowState = Object.keys(state);
107
123
  const shallowNewState = Object.keys(newState);
124
+ const isInitialHydration = state === newState;
108
125
 
109
126
  for (const [attrName, attrValue] of Object.entries(tree.attributes)) {
110
127
  BINDING_REGEX.lastIndex = 0;
@@ -115,17 +132,30 @@ const recursive = (tree, state, newState, affected) => {
115
132
  }
116
133
 
117
134
  for (const m of attrMatches) {
118
- const noMatch = !shallowState.some(key => matchesKey(m.inner, key));
119
- const newMatches = shallowNewState.filter(key => matchesKey(m.inner, key));
135
+ const noMatch = !shallowState.some((key) => matchesKey(m.inner, key));
136
+
137
+ let shouldAffect = false;
138
+
139
+ if (isInitialHydration) {
140
+ // Initial hydration: affect all matched keys
141
+ const newMatches = shallowNewState.filter((key) => matchesKey(m.inner, key));
142
+ shouldAffect = noMatch || newMatches.length > 0;
143
+ } else {
144
+ // Update: only affect if value changed
145
+ const changedKeys = shallowNewState.filter((key) =>
146
+ matchesKey(m.inner, key) && state[key] !== newState[key]
147
+ );
148
+ shouldAffect = noMatch || changedKeys.length > 0;
149
+ }
120
150
 
121
- if (noMatch || newMatches.length) {
151
+ if (shouldAffect) {
122
152
  affected.push({
123
153
  type: 'attribute',
124
154
  attrName,
125
155
  attrValue,
126
156
  matchOuter: m.outer,
127
157
  matchInner: m.inner,
128
- element: tree.element
158
+ element: tree.element,
129
159
  });
130
160
  }
131
161
  }
@@ -146,4 +176,4 @@ const recursive = (tree, state, newState, affected) => {
146
176
  return affected;
147
177
  };
148
178
 
149
- export default (tree, state, newState) => recursive(tree, state, newState, [])
179
+ export default (tree, state, newState) => recursive(tree, state, newState, []);
package/conditionals.js CHANGED
@@ -44,7 +44,7 @@ export const renderAllConditionals = (tree, state, linkList, parentScope = {}) =
44
44
 
45
45
  // Recursively render conditionals in child nodes
46
46
  if (tree.children) {
47
- Object.keys(tree.children).forEach(key => {
47
+ Object.keys(tree.children).forEach((key) => {
48
48
  const child = tree.children[key];
49
49
  if (typeof child === 'object' && child !== null) {
50
50
  renderAllConditionals(child, state, linkList, parentScope);
@@ -104,15 +104,18 @@ const mountBranch = (node, branchData, state, linkList, parentScope) => {
104
104
  const parent = startComment.parentNode;
105
105
 
106
106
  // Create scoped state (with parent scope if inside iteration)
107
- const scopedState = Object.keys(parentScope).length > 0
108
- ? createScopedState(state, parentScope)
109
- : state;
107
+ const scopedState =
108
+ Object.keys(parentScope).length > 0 ? createScopedState(state, parentScope) : state;
110
109
 
111
110
  // Initialize block (clone, parse, hydrate)
112
- const { element: firstElement, tree: branchTree, clonedNodes } = initializeBlock(templateContent, scopedState);
111
+ const {
112
+ element: firstElement,
113
+ tree: branchTree,
114
+ clonedNodes,
115
+ } = initializeBlock(templateContent, scopedState);
113
116
 
114
117
  // Insert cloned nodes into DOM
115
- clonedNodes.forEach(clonedNode => parent.insertBefore(clonedNode, endComment));
118
+ clonedNodes.forEach((clonedNode) => parent.insertBefore(clonedNode, endComment));
116
119
 
117
120
  // Recursively render any nested iterations and conditionals
118
121
  if (branchTree) {
@@ -124,7 +127,7 @@ const mountBranch = (node, branchData, state, linkList, parentScope) => {
124
127
  node.runtime.activeInstance = {
125
128
  branch: branchData,
126
129
  nodes: clonedNodes,
127
- parsedTree: branchTree
130
+ parsedTree: branchTree,
128
131
  };
129
132
  };
130
133
 
@@ -135,7 +138,7 @@ const unmountBranch = (node) => {
135
138
  if (!activeInstance) return;
136
139
 
137
140
  // Remove all nodes from DOM
138
- activeInstance.nodes.forEach(domNode => {
141
+ activeInstance.nodes.forEach((domNode) => {
139
142
  if (domNode.parentNode) {
140
143
  domNode.parentNode.removeChild(domNode);
141
144
  }
@@ -170,13 +173,11 @@ export const updateConditional = (node, newState, oldState, linkList, parentScop
170
173
  const { activeInstance } = node.runtime;
171
174
 
172
175
  if (activeInstance && activeInstance.parsedTree) {
173
- const scopedState = Object.keys(parentScope).length > 0
174
- ? createScopedState(newState, parentScope)
175
- : newState;
176
+ const scopedState =
177
+ Object.keys(parentScope).length > 0 ? createScopedState(newState, parentScope) : newState;
176
178
 
177
179
  const affectedElements = affected(activeInstance.parsedTree, oldState, scopedState);
178
180
  hydrate(affectedElements, scopedState);
179
181
  }
180
182
  }
181
183
  };
182
-
package/constants.js CHANGED
@@ -1,56 +1,137 @@
1
1
  // Elements that should not have reactive bindings
2
- export const NON_REACTIVE_ELEMENTS = ["SCRIPT", "HEAD", "PRE"];
2
+ export const NON_REACTIVE_ELEMENTS = ['SCRIPT', 'HEAD', 'PRE'];
3
3
 
4
4
  // Attributes where the string value is meaningful (should NOT be removed when falsy)
5
5
  // All other attributes are treated as boolean-like (removed when falsy, present when truthy)
6
6
  export const VALUE_ATTRS = [
7
7
  // Global attributes
8
- 'class', 'style', 'id', 'title', 'lang', 'dir', 'tabindex', 'accesskey',
9
- 'slot', 'part', 'is', 'nonce', 'popover', 'anchor',
8
+ 'class',
9
+ 'style',
10
+ 'id',
11
+ 'title',
12
+ 'lang',
13
+ 'dir',
14
+ 'tabindex',
15
+ 'accesskey',
16
+ 'slot',
17
+ 'part',
18
+ 'is',
19
+ 'nonce',
20
+ 'popover',
21
+ 'anchor',
10
22
 
11
23
  // Enumerated (take specific string values, not truly boolean)
12
- 'contenteditable', 'draggable', 'spellcheck', 'translate', 'autocapitalize',
13
- 'inputmode', 'enterkeyhint', 'virtualkeyboardpolicy',
24
+ 'contenteditable',
25
+ 'draggable',
26
+ 'spellcheck',
27
+ 'translate',
28
+ 'autocapitalize',
29
+ 'inputmode',
30
+ 'enterkeyhint',
31
+ 'virtualkeyboardpolicy',
14
32
 
15
33
  // URLs and sources
16
- 'href', 'src', 'action', 'cite', 'data', 'poster', 'srcset', 'imagesrcset',
17
- 'formaction', 'ping', 'usemap', 'manifest', 'codebase',
34
+ 'href',
35
+ 'src',
36
+ 'action',
37
+ 'cite',
38
+ 'data',
39
+ 'poster',
40
+ 'srcset',
41
+ 'imagesrcset',
42
+ 'formaction',
43
+ 'ping',
44
+ 'usemap',
45
+ 'manifest',
46
+ 'codebase',
18
47
 
19
48
  // Form attributes
20
- 'name', 'type', 'value', 'placeholder', 'pattern', 'min', 'max', 'step',
21
- 'minlength', 'maxlength', 'size', 'accept', 'autocomplete', 'list', 'form',
22
- 'formmethod', 'formtarget', 'formenctype', 'wrap', 'method', 'enctype',
23
- 'for', 'dirname',
49
+ 'name',
50
+ 'type',
51
+ 'value',
52
+ 'placeholder',
53
+ 'pattern',
54
+ 'min',
55
+ 'max',
56
+ 'step',
57
+ 'minlength',
58
+ 'maxlength',
59
+ 'size',
60
+ 'accept',
61
+ 'autocomplete',
62
+ 'list',
63
+ 'form',
64
+ 'formmethod',
65
+ 'formtarget',
66
+ 'formenctype',
67
+ 'wrap',
68
+ 'method',
69
+ 'enctype',
70
+ 'for',
71
+ 'dirname',
24
72
 
25
73
  // Text/accessibility
26
- 'alt', 'label', 'summary', 'abbr',
74
+ 'alt',
75
+ 'label',
76
+ 'summary',
77
+ 'abbr',
27
78
 
28
79
  // Dimensions and layout
29
- 'width', 'height', 'cols', 'rows', 'span', 'rowspan', 'colspan',
30
- 'low', 'high', 'optimum',
80
+ 'width',
81
+ 'height',
82
+ 'cols',
83
+ 'rows',
84
+ 'span',
85
+ 'rowspan',
86
+ 'colspan',
87
+ 'low',
88
+ 'high',
89
+ 'optimum',
31
90
 
32
91
  // Link/resource hints
33
- 'target', 'rel', 'hreflang', 'download', 'as', 'media', 'type', 'charset',
34
- 'crossorigin', 'integrity', 'loading', 'decoding', 'fetchpriority',
35
- 'referrerpolicy', 'blocking', 'imagesizes', 'sizes',
92
+ 'target',
93
+ 'rel',
94
+ 'hreflang',
95
+ 'download',
96
+ 'as',
97
+ 'media',
98
+ 'type',
99
+ 'charset',
100
+ 'crossorigin',
101
+ 'integrity',
102
+ 'loading',
103
+ 'decoding',
104
+ 'fetchpriority',
105
+ 'referrerpolicy',
106
+ 'blocking',
107
+ 'imagesizes',
108
+ 'sizes',
36
109
 
37
110
  // Media
38
- 'preload', 'kind', 'srclang',
111
+ 'preload',
112
+ 'kind',
113
+ 'srclang',
39
114
 
40
115
  // Meta
41
- 'content', 'http-equiv',
116
+ 'content',
117
+ 'http-equiv',
42
118
 
43
119
  // iframe/embed
44
- 'sandbox', 'allow', 'srcdoc', 'credentialless',
120
+ 'sandbox',
121
+ 'allow',
122
+ 'srcdoc',
123
+ 'credentialless',
45
124
 
46
125
  // Table
47
- 'headers', 'scope',
126
+ 'headers',
127
+ 'scope',
48
128
 
49
129
  // Datetime
50
130
  'datetime',
51
131
 
52
132
  // Object/embed legacy
53
- 'coords', 'shape',
133
+ 'coords',
134
+ 'shape',
54
135
  ];
55
136
 
56
137
  // Properties that should be set directly on the DOM element (not as attributes)
package/hydrate.js CHANGED
@@ -1,6 +1,6 @@
1
- import { updateIteration } from './iterate.js'
2
- import { updateConditional } from './conditionals.js'
3
- import { VALUE_ATTRS, DOM_PROPERTIES, BINDING_REGEX, PURE_BINDING_REGEX } from './constants.js'
1
+ import { updateIteration } from './iterate.js';
2
+ import { updateConditional } from './conditionals.js';
3
+ import { VALUE_ATTRS, DOM_PROPERTIES, BINDING_REGEX, PURE_BINDING_REGEX } from './constants.js';
4
4
 
5
5
  // Keep track of old state for diffing
6
6
  let previousState = {};
@@ -40,12 +40,12 @@ export default (affected, state, linkList = {}) => {
40
40
  if (aff.type === 'attribute') {
41
41
  const { attrName, attrValue, element } = aff;
42
42
  try {
43
-
44
43
  // Check if this is a pure binding (e.g., value="@[inputValue]")
45
44
  const isPureBinding = attrValue.match(PURE_BINDING_REGEX);
46
45
  const isDomProperty = DOM_PROPERTIES.includes(attrName);
47
46
  // Value attrs keep their string value; everything else is boolean-like (removed when falsy)
48
- const isValueAttr = VALUE_ATTRS.includes(attrName) ||
47
+ const isValueAttr =
48
+ VALUE_ATTRS.includes(attrName) ||
49
49
  attrName.startsWith('data-') ||
50
50
  attrName.startsWith('aria-') ||
51
51
  attrName.startsWith('on');
@@ -97,4 +97,4 @@ export default (affected, state, linkList = {}) => {
97
97
 
98
98
  // Update previous state for next diff
99
99
  setPreviousState(state);
100
- }
100
+ };
package/index.js CHANGED
@@ -1,12 +1,12 @@
1
- import state from "./state.js";
2
- import parse from "./parse.js";
3
- import link from "./link.js";
4
- import hydrate, { setPreviousState } from "./hydrate.js";
5
- import affected from "./affected.js";
6
- import { deepMerge, hash } from "./utils.js";
7
- import { renderAllIterations, setRenderAllConditionals } from "./iterate.js";
8
- import { renderAllConditionals } from "./conditionals.js";
9
- import { NON_REACTIVE_ELEMENTS } from "./constants.js";
1
+ import state from './state.js';
2
+ import parse from './parse.js';
3
+ import link from './link.js';
4
+ import hydrate, { setPreviousState } from './hydrate.js';
5
+ import affected from './affected.js';
6
+ import { deepMerge, hash } from './utils.js';
7
+ import { renderAllIterations, setRenderAllConditionals } from './iterate.js';
8
+ import { renderAllConditionals } from './conditionals.js';
9
+ import { NON_REACTIVE_ELEMENTS } from './constants.js';
10
10
 
11
11
  // Wire up cross-module dependency after all modules are loaded
12
12
  setRenderAllConditionals(renderAllConditionals);
@@ -29,12 +29,12 @@ const isNonReactiveOrInside = (node) => {
29
29
  // Navigate tree using dot notation (handles .children at each level)
30
30
  const navigateTree = (tree, path) => {
31
31
  if (!path) return tree;
32
- return path.split(".").reduce((node, key) => node?.children?.[key], tree);
32
+ return path.split('.').reduce((node, key) => node?.children?.[key], tree);
33
33
  };
34
34
 
35
35
  // Get or create a node in the tree at the given path
36
36
  const ensureNode = (tree, path) => {
37
- const keys = path.split(".");
37
+ const keys = path.split('.');
38
38
  return keys.reduce((node, key) => {
39
39
  if (!node.children[key]) {
40
40
  node.children[key] = { children: {} };
@@ -43,14 +43,12 @@ const ensureNode = (tree, path) => {
43
43
  }, tree);
44
44
  };
45
45
 
46
- const main = (s, attrName = "vibe") => {
46
+ const main = (s, attrName = 'vibe') => {
47
47
  // Find element(s) with the specified attribute
48
48
  const elements = document.querySelectorAll(`[${attrName}]`);
49
49
 
50
50
  if (elements.length === 0) {
51
- console.info(
52
- `[vibe] No element found with attribute "${attrName}". Falling back to body.`,
53
- );
51
+ console.info(`[vibe] No element found with attribute "${attrName}". Falling back to body.`);
54
52
  } else if (elements.length > 1) {
55
53
  console.info(
56
54
  `[vibe] Multiple elements (${elements.length}) found with attribute "${attrName}". Hydrating the first one.`,
@@ -90,9 +88,7 @@ const main = (s, attrName = "vibe") => {
90
88
 
91
89
  const prev = structuredClone(previousState);
92
90
  previousState = { ...$, ...newState };
93
- hooks.afterUpdate.forEach((callback) =>
94
- callback(structuredClone({ ...$ }), prev),
95
- );
91
+ hooks.afterUpdate.forEach((callback) => callback(structuredClone({ ...$ }), prev));
96
92
  });
97
93
 
98
94
  // Add hook subscription method (non-enumerable so it won't be spread/cloned with state)
@@ -101,34 +97,7 @@ const main = (s, attrName = "vibe") => {
101
97
  if (hooks[event]) {
102
98
  hooks[event].push(callback);
103
99
  }
104
- return () => hooks[event] = hooks[event].filter((cb) => cb !== callback);
105
- },
106
- enumerable: false,
107
- });
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
- }
100
+ return () => (hooks[event] = hooks[event].filter((cb) => cb !== callback));
132
101
  },
133
102
  enumerable: false,
134
103
  });
@@ -144,19 +113,53 @@ const main = (s, attrName = "vibe") => {
144
113
  renderAllConditionals(parsedTree, $, linkList);
145
114
 
146
115
  observer = new MutationObserver((mutations) => {
116
+ // Early exit if no mutations to process (common case)
117
+ if (mutations.length === 0) return;
118
+
119
+ let hadChanges = false;
120
+ let parsedParents = null; // Lazy init - only create Set when needed
121
+
147
122
  mutations.forEach(({ addedNodes, removedNodes, target }) => {
123
+ // Process removed nodes first (cleanup before additions)
124
+ removedNodes.forEach((node) => {
125
+ const entry = Object.entries(linkList).find(([_, element]) => element === node);
126
+
127
+ // Skip nodes that aren't tracked (e.g., iteration-generated nodes or nodes outside reactive scope)
128
+ if (!entry) return;
129
+
130
+ const [dotAnnotation] = entry;
131
+ delete linkList[dotAnnotation];
132
+
133
+ const dotPath = dotAnnotation.split('.');
134
+ const name = dotPath.pop();
135
+ const parentDotAnnotation = dotPath.join('.');
136
+
137
+ const picked = navigateTree(parsedTree, parentDotAnnotation);
138
+
139
+ // If we can't navigate to the parent, skip
140
+ if (!picked || !picked.element) return;
141
+
142
+ // Update parent's parsed HTML (only once per parent)
143
+ if (!parsedParents) parsedParents = new Set();
144
+ if (!parsedParents.has(picked)) {
145
+ const { parsed } = parse(picked.element);
146
+ picked.parsed = parsed;
147
+ parsedParents.add(picked);
148
+ }
149
+
150
+ // Remove the node from parent's children
151
+ delete picked.children[name];
152
+
153
+ hadChanges = true;
154
+ });
155
+
148
156
  addedNodes.forEach((node) => {
149
157
  // Skip if node itself or any ancestor is non-reactive
150
158
  if (isNonReactiveOrInside(node)) {
151
159
  return;
152
160
  }
153
161
 
154
- const index = [...target.childNodes].findIndex(
155
- (childNode) => childNode === node,
156
- );
157
- const entry = Object.entries(linkList).find(
158
- ([_, element]) => element === target,
159
- );
162
+ const entry = Object.entries(linkList).find(([_, element]) => element === target);
160
163
 
161
164
  // If parent isn't tracked, this node is outside the reactive scope
162
165
  if (!entry) return;
@@ -176,57 +179,37 @@ const main = (s, attrName = "vibe") => {
176
179
  const name = `${node.nodeName.toLowerCase()}_${hash()}`;
177
180
  const parsedNode = parse(node);
178
181
 
179
- // Update parent's parsed HTML
180
- const { parsed } = parse(picked.element);
181
- picked.parsed = parsed;
182
+ // Update parent's parsed HTML (only once per parent)
183
+ if (!parsedParents) parsedParents = new Set();
184
+ if (!parsedParents.has(picked)) {
185
+ const { parsed } = parse(picked.element);
186
+ picked.parsed = parsed;
187
+ parsedParents.add(picked);
188
+ }
182
189
 
183
190
  // Add the parsed node to parent's children
184
191
  picked.children[name] = parsedNode;
185
192
 
186
193
  linkList[`${dotAnnotation}.${name}`] = node;
187
194
 
188
- const affectedElements = affected(parsedTree, $, $);
195
+ // Only hydrate the newly added node and its descendants, not the entire tree
196
+ // Use empty object as "previous state" so all bindings in new node are considered affected
197
+ const affectedElements = affected(parsedNode, {}, $);
189
198
 
190
199
  hydrate(affectedElements, $, linkList);
191
200
 
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
- );
201
+ // Process iterations and conditionals in the newly added node
202
+ renderAllIterations(parsedNode, $, linkList);
203
+ renderAllConditionals(parsedNode, $, linkList);
200
204
 
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];
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
- hydrate(affectedElements, $, linkList);
225
-
226
- // Trigger afterDomMutation hook for removed nodes
227
- hooks.afterDomMutation.forEach((callback) => callback());
205
+ hadChanges = true;
228
206
  });
229
207
  });
208
+
209
+ // Fire hooks once after all mutations are processed (not per-node)
210
+ if (hadChanges) {
211
+ hooks.afterDomMutation.forEach((callback) => callback());
212
+ }
230
213
  });
231
214
 
232
215
  observer.observe(rootElement, {
package/iterate.js CHANGED
@@ -32,7 +32,7 @@ const cloneTreeWithElements = (originalTree, clonedRoot) => {
32
32
  parsed: originalTree.parsed,
33
33
  element: clonedRoot,
34
34
  children: {},
35
- ...(originalTree.attributes && { attributes: originalTree.attributes })
35
+ ...(originalTree.attributes && { attributes: originalTree.attributes }),
36
36
  };
37
37
 
38
38
  if (!originalTree.children) return cloned;
@@ -50,7 +50,9 @@ const cloneTreeWithElements = (originalTree, clonedRoot) => {
50
50
 
51
51
  // Find corresponding comments in cloned DOM
52
52
  const clonedStart = findComment(clonedChildNodes, startComment.textContent);
53
- const clonedElse = elseComment ? findComment(clonedChildNodes, elseComment.textContent) : null;
53
+ const clonedElse = elseComment
54
+ ? findComment(clonedChildNodes, elseComment.textContent)
55
+ : null;
54
56
  const clonedEnd = findComment(clonedChildNodes, endComment.textContent);
55
57
 
56
58
  cloned.children[key] = {
@@ -60,14 +62,14 @@ const cloneTreeWithElements = (originalTree, clonedRoot) => {
60
62
  startComment: clonedStart || startComment,
61
63
  elseComment: clonedElse,
62
64
  endComment: clonedEnd || endComment,
63
- branches // Branch templates are reused (they're cloned during mount)
65
+ branches, // Branch templates are reused (they're cloned during mount)
64
66
  },
65
67
  runtime: {
66
68
  activeBranch: undefined,
67
69
  activeInstance: null,
68
- templateRemoved: false
70
+ templateRemoved: false,
69
71
  },
70
- children: {}
72
+ children: {},
71
73
  };
72
74
  continue;
73
75
  }
@@ -88,13 +90,13 @@ const cloneTreeWithElements = (originalTree, clonedRoot) => {
88
90
  indexAlias: child.meta.indexAlias,
89
91
  startComment: clonedStart || startComment,
90
92
  endComment: clonedEnd || endComment,
91
- template // Template is reused (cloned during render)
93
+ template, // Template is reused (cloned during render)
92
94
  },
93
95
  runtime: {
94
96
  instances: [],
95
- templateRemoved: false
97
+ templateRemoved: false,
96
98
  },
97
- children: {}
99
+ children: {},
98
100
  };
99
101
  continue;
100
102
  }
@@ -150,7 +152,7 @@ export const initializeBlock = (templateNodes, scopedState, cachedTree = null) =
150
152
  // Fallback for text-only templates
151
153
  if (!firstElement && clonedNodes.length > 0) {
152
154
  const container = document.createElement('span');
153
- clonedNodes.forEach(node => container.appendChild(node.cloneNode(true)));
155
+ clonedNodes.forEach((node) => container.appendChild(node.cloneNode(true)));
154
156
  firstElement = container;
155
157
  }
156
158
 
@@ -165,7 +167,7 @@ export const initializeBlock = (templateNodes, scopedState, cachedTree = null) =
165
167
  return {
166
168
  element: firstElement,
167
169
  tree,
168
- clonedNodes
170
+ clonedNodes,
169
171
  };
170
172
  };
171
173
 
@@ -222,7 +224,7 @@ export const createScopedState = (globalState, localVars, parentScope = {}) => {
222
224
  return { configurable: true, enumerable: true, value: parentScope[prop] };
223
225
  }
224
226
  return Reflect.getOwnPropertyDescriptor(target, prop);
225
- }
227
+ },
226
228
  });
227
229
  };
228
230
 
@@ -247,11 +249,14 @@ export const renderAllIterations = (tree, state, linkList, parentScope = {}) =>
247
249
 
248
250
  // Callback for rendering conditionals - set by conditionals.js to avoid circular import
249
251
  let _renderAllConditionals = () => {};
250
- export const setRenderAllConditionals = (fn) => { _renderAllConditionals = fn; };
252
+ export const setRenderAllConditionals = (fn) => {
253
+ _renderAllConditionals = fn;
254
+ };
251
255
 
252
256
  // Initial render of an iteration block
253
257
  export const renderIteration = (iterationNode, state, linkList, parentScope = {}) => {
254
- const { arrayPath, itemAlias, indexAlias, template, startComment, endComment } = iterationNode.meta;
258
+ const { arrayPath, itemAlias, indexAlias, template, startComment, endComment } =
259
+ iterationNode.meta;
255
260
 
256
261
  // Already rendered - updates go through updateIteration
257
262
  if (iterationNode.runtime.instances?.length > 0) return;
@@ -294,7 +299,7 @@ export const renderIteration = (iterationNode, state, linkList, parentScope = {}
294
299
  const { element, tree, clonedNodes } = initializeBlock(templateNodes, scopedState, template);
295
300
 
296
301
  // Insert cloned nodes
297
- clonedNodes.forEach(node => parent.insertBefore(node, endComment));
302
+ clonedNodes.forEach((node) => parent.insertBefore(node, endComment));
298
303
 
299
304
  // Recursively render nested iterations and conditionals
300
305
  if (tree) {
@@ -323,7 +328,11 @@ export const updateIteration = (iterationNode, newState, oldState, linkList, par
323
328
  const isFullToEmpty = oldArray.length > 0 && newArray.length === 0;
324
329
  const isLargeArray = newArray.length > 100 || oldArray.length > 100;
325
330
 
326
- if (window.__VIBE_FAST_ITERATION__ && fastPath.canUseFastPath(template) && (isEmptyToFull || isFullToEmpty || isLargeArray)) {
331
+ if (
332
+ window.__VIBE_FAST_ITERATION__ &&
333
+ fastPath.canUseFastPath(template) &&
334
+ (isEmptyToFull || isFullToEmpty || isLargeArray)
335
+ ) {
327
336
  fastPath.updateFast(iterationNode, newArray, newState, startComment, endComment);
328
337
  return;
329
338
  }
@@ -333,16 +342,26 @@ export const updateIteration = (iterationNode, newState, oldState, linkList, par
333
342
  const newKeys = newArray.map((item, i) => getItemKey(item, i));
334
343
  const operations = computeDiff(oldKeys, newKeys, oldArray, newArray);
335
344
 
336
- operations.forEach(op => {
345
+ operations.forEach((op) => {
337
346
  switch (op.type) {
338
- case 'REMOVE': removeInstance(iterationNode, op.index); break;
339
- case 'ADD': addInstance(iterationNode, op.item, op.index, newState, linkList, parentScope); break;
340
- case 'MOVE': moveInstance(iterationNode, op.from, op.to); break;
341
- case 'UPDATE': updateInstance(iterationNode, op.index, op.item, newState, linkList, parentScope); break;
347
+ case 'REMOVE':
348
+ removeInstance(iterationNode, op.index);
349
+ break;
350
+ case 'ADD':
351
+ addInstance(iterationNode, op.item, op.index, newState, linkList, parentScope);
352
+ break;
353
+ case 'MOVE':
354
+ moveInstance(iterationNode, op.from, op.to);
355
+ break;
356
+ case 'UPDATE':
357
+ updateInstance(iterationNode, op.index, op.item, newState, linkList, parentScope);
358
+ break;
342
359
  }
343
360
  });
344
361
 
345
- iterationNode.runtime.instances.forEach((inst, i) => { inst.index = i; });
362
+ iterationNode.runtime.instances.forEach((inst, i) => {
363
+ inst.index = i;
364
+ });
346
365
  };
347
366
 
348
367
  // Add a new instance at the specified index
@@ -356,12 +375,14 @@ const addInstance = (iterationNode, item, index, state, linkList, parentScope) =
356
375
  const { element, tree, clonedNodes } = initializeBlock(templateNodes, scopedState, template);
357
376
 
358
377
  // Find insertion point
359
- const insertBefore = index < iterationNode.runtime.instances.length
360
- ? iterationNode.runtime.instances[index].clonedNodes?.[0] || iterationNode.runtime.instances[index].element
361
- : endComment;
378
+ const insertBefore =
379
+ index < iterationNode.runtime.instances.length
380
+ ? iterationNode.runtime.instances[index].clonedNodes?.[0] ||
381
+ iterationNode.runtime.instances[index].element
382
+ : endComment;
362
383
 
363
384
  // Insert cloned nodes
364
- clonedNodes.forEach(node => startComment.parentNode.insertBefore(node, insertBefore));
385
+ clonedNodes.forEach((node) => startComment.parentNode.insertBefore(node, insertBefore));
365
386
 
366
387
  // Recursively render nested iterations and conditionals
367
388
  if (tree) {
@@ -380,7 +401,9 @@ const removeInstance = (iterationNode, index) => {
380
401
  const instance = iterationNode.runtime.instances[index];
381
402
 
382
403
  // Remove all cloned nodes from DOM
383
- (instance.clonedNodes || [instance.element]).forEach(node => node?.parentNode?.removeChild(node));
404
+ (instance.clonedNodes || [instance.element]).forEach((node) =>
405
+ node?.parentNode?.removeChild(node),
406
+ );
384
407
 
385
408
  iterationNode.runtime.instances.splice(index, 1);
386
409
  };
@@ -401,11 +424,11 @@ const moveInstance = (iterationNode, fromIndex, toIndex) => {
401
424
  // Find new insertion point
402
425
  const nextInstance = iterationNode.runtime.instances[toIndex + 1];
403
426
  const insertBefore = nextInstance
404
- ? (nextInstance.clonedNodes?.[0] || nextInstance.element)
427
+ ? nextInstance.clonedNodes?.[0] || nextInstance.element
405
428
  : iterationNode.meta.endComment;
406
429
 
407
430
  // Move all nodes
408
- nodes.forEach(node => parent.insertBefore(node, insertBefore));
431
+ nodes.forEach((node) => parent.insertBefore(node, insertBefore));
409
432
  };
410
433
 
411
434
  // Update an instance with new item data
@@ -426,8 +449,8 @@ const updateInstance = (iterationNode, index, newItem, state, linkList, parentSc
426
449
  const insertBefore = oldNodes[oldNodes.length - 1]?.nextSibling;
427
450
  const parent = oldNodes[0]?.parentNode;
428
451
 
429
- oldNodes.forEach(node => node?.parentNode?.removeChild(node));
430
- clonedNodes.forEach(node => parent.insertBefore(node, insertBefore));
452
+ oldNodes.forEach((node) => node?.parentNode?.removeChild(node));
453
+ clonedNodes.forEach((node) => parent.insertBefore(node, insertBefore));
431
454
 
432
455
  // Recursively render nested iterations and conditionals
433
456
  if (tree) {
@@ -447,5 +470,5 @@ export default {
447
470
  renderIteration,
448
471
  updateIteration,
449
472
  renderAllIterations,
450
- createScopedState
473
+ createScopedState,
451
474
  };
@@ -4,7 +4,7 @@ import { ITERATION_START_REGEX, CONDITIONAL_START_REGEX } from './constants.js';
4
4
  // Resolve nested paths in state (e.g., "user.items" -> state.user.items)
5
5
  export const resolvePath = (obj, path) => {
6
6
  if (!path || !obj) return undefined;
7
- return path.split(".").reduce((acc, part) => acc?.[part], obj);
7
+ return path.split('.').reduce((acc, part) => acc?.[part], obj);
8
8
  };
9
9
 
10
10
  // Clone template element preserving structure
@@ -16,11 +16,11 @@ export const cloneTemplate = (templateElement) => {
16
16
  export const findEndComment = (nodes, startIndex) => {
17
17
  let depth = 1;
18
18
  for (let i = startIndex; i < nodes.length; i++) {
19
- if (nodes[i].nodeName === "#comment") {
19
+ if (nodes[i].nodeName === '#comment') {
20
20
  const text = nodes[i].textContent.trim();
21
21
  if (ITERATION_START_REGEX.test(text)) {
22
22
  depth++;
23
- } else if (text === "/each") {
23
+ } else if (text === '/each') {
24
24
  depth--;
25
25
  if (depth === 0) {
26
26
  return i;
@@ -28,7 +28,7 @@ export const findEndComment = (nodes, startIndex) => {
28
28
  }
29
29
  }
30
30
  }
31
- throw new Error("Unmatched <!-- each --> comment: missing <!-- /each -->");
31
+ throw new Error('Unmatched <!-- each --> comment: missing <!-- /each -->');
32
32
  };
33
33
 
34
34
  // Find matching <!-- /if --> and optional <!-- else --> with depth tracking
@@ -38,7 +38,7 @@ export const findConditionalEnd = (nodes, startIndex) => {
38
38
  let elseIndex = null;
39
39
 
40
40
  for (let i = startIndex; i < nodes.length; i++) {
41
- if (nodes[i].nodeName === "#comment") {
41
+ if (nodes[i].nodeName === '#comment') {
42
42
  const text = nodes[i].textContent.trim();
43
43
 
44
44
  // Check for nested if
@@ -46,11 +46,11 @@ export const findConditionalEnd = (nodes, startIndex) => {
46
46
  depth++;
47
47
  }
48
48
  // Check for else at current depth
49
- else if (text === "else" && depth === 1 && elseIndex === null) {
49
+ else if (text === 'else' && depth === 1 && elseIndex === null) {
50
50
  elseIndex = i;
51
51
  }
52
52
  // Check for /if
53
- else if (text === "/if") {
53
+ else if (text === '/if') {
54
54
  depth--;
55
55
  if (depth === 0) {
56
56
  return { elseIndex, endIndex: i };
@@ -59,13 +59,13 @@ export const findConditionalEnd = (nodes, startIndex) => {
59
59
  }
60
60
  }
61
61
 
62
- throw new Error("Unmatched <!-- if --> comment: missing <!-- /if -->");
62
+ throw new Error('Unmatched <!-- if --> comment: missing <!-- /if -->');
63
63
  };
64
64
 
65
65
  // Generate stable hash for objects
66
66
  export const stableHash = (obj) => {
67
- if (obj === null || obj === undefined) return "null";
68
- if (typeof obj !== "object") return String(obj);
67
+ if (obj === null || obj === undefined) return 'null';
68
+ if (typeof obj !== 'object') return String(obj);
69
69
 
70
70
  try {
71
71
  // Sort keys for stable hashing
@@ -96,7 +96,7 @@ export const deepEqual = (a, b) => {
96
96
  return a === b;
97
97
  }
98
98
 
99
- if (typeof a !== "object" || typeof b !== "object") {
99
+ if (typeof a !== 'object' || typeof b !== 'object') {
100
100
  return a === b;
101
101
  }
102
102
 
@@ -166,12 +166,12 @@ export const longestCommonSubsequence = (arr1, arr2) => {
166
166
  // Generate unique key for array items
167
167
  export const getItemKey = (item, index) => {
168
168
  // 1. If item has 'id' property, use it
169
- if (item && typeof item === "object" && "id" in item) {
169
+ if (item && typeof item === 'object' && 'id' in item) {
170
170
  return `id_${item.id}`;
171
171
  }
172
172
 
173
173
  // 2. If item is primitive, use value + index
174
- if (typeof item !== "object" || item === null) {
174
+ if (typeof item !== 'object' || item === null) {
175
175
  return `val_${item}_${index}`;
176
176
  }
177
177
 
@@ -204,29 +204,23 @@ export const computeDiff = (oldKeys, newKeys, oldArray, newArray) => {
204
204
  // Check if item content changed
205
205
  if (!deepEqual(oldArray[oldIdx], newArray[newIdx])) {
206
206
  operations.push({
207
- type: "UPDATE",
207
+ type: 'UPDATE',
208
208
  index: newIdx,
209
209
  oldIndex: oldIdx,
210
210
  item: newArray[newIdx],
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
226
220
  oldKeys.forEach((key, idx) => {
227
221
  if (!processedOld.has(idx)) {
228
222
  operations.push({
229
- type: "REMOVE",
223
+ type: 'REMOVE',
230
224
  index: idx,
231
225
  key,
232
226
  });
@@ -237,7 +231,7 @@ export const computeDiff = (oldKeys, newKeys, oldArray, newArray) => {
237
231
  newKeys.forEach((key, idx) => {
238
232
  if (!processedNew.has(idx)) {
239
233
  operations.push({
240
- type: "ADD",
234
+ type: 'ADD',
241
235
  index: idx,
242
236
  item: newArray[idx],
243
237
  key,
@@ -252,7 +246,7 @@ export const computeDiff = (oldKeys, newKeys, oldArray, newArray) => {
252
246
  return priority[a.type] - priority[b.type];
253
247
  }
254
248
  // For REMOVE, process from end to beginning
255
- if (a.type === "REMOVE") {
249
+ if (a.type === 'REMOVE') {
256
250
  return b.index - a.index;
257
251
  }
258
252
  // For others, process in order
package/link.js CHANGED
@@ -1,5 +1,5 @@
1
1
  const recursive = (tree, results, tagChain) => {
2
- results[tagChain.join(".")] = tree.element;
2
+ results[tagChain.join('.')] = tree.element;
3
3
 
4
4
  if (tree.children && Object.keys(tree.children).length > 0) {
5
5
  Object.keys(tree.children).forEach((tag) => {
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.3",
4
4
  "type": "module",
5
5
  "description": "Runtime-first reactivity",
6
6
  "main": "index.js",
package/parse.js CHANGED
@@ -1,18 +1,23 @@
1
- import { hash } from './utils.js'
2
- import { findEndComment, findConditionalEnd } from './iteration-utils.js'
3
- import { NON_REACTIVE_ELEMENTS, BINDING_REGEX, ITERATION_REGEX, CONDITIONAL_REGEX } from './constants.js'
1
+ import { hash } from './utils.js';
2
+ import { findEndComment, findConditionalEnd } from './iteration-utils.js';
3
+ import {
4
+ NON_REACTIVE_ELEMENTS,
5
+ BINDING_REGEX,
6
+ ITERATION_REGEX,
7
+ CONDITIONAL_REGEX,
8
+ } from './constants.js';
4
9
 
5
10
  const parseHTML = (children, rootKey = undefined) =>
6
11
  children.reduce((s, element, i) => {
7
- const { nodeName, textContent } = element
12
+ const { nodeName, textContent } = element;
8
13
  if (['#comment'].includes(nodeName)) {
9
- return `${s}${nodeName === '#comment' ? `asd` : textContent}`
14
+ return `${s}${nodeName === '#comment' ? `asd` : textContent}`;
10
15
  }
11
- const name = nodeName.startsWith('#') ? nodeName.slice(1) : nodeName
12
- const innerNodeIdentifier = `${name}_${i}`.toLowerCase()
16
+ const name = nodeName.startsWith('#') ? nodeName.slice(1) : nodeName;
17
+ const innerNodeIdentifier = `${name}_${i}`.toLowerCase();
13
18
 
14
- return rootKey || `${s}${`\$[${innerNodeIdentifier}]`}`
15
- }, '')
19
+ return rootKey || `${s}${`\$[${innerNodeIdentifier}]`}`;
20
+ }, '');
16
21
 
17
22
  const recursive = (children, rootKey = undefined, skipIndices = new Set()) => {
18
23
  let result = {};
@@ -44,7 +49,7 @@ const recursive = (children, rootKey = undefined, skipIndices = new Set()) => {
44
49
 
45
50
  // Create a temporary container for the template
46
51
  const templateContainer = document.createElement('div');
47
- templateNodes.forEach(node => {
52
+ templateNodes.forEach((node) => {
48
53
  templateContainer.appendChild(node.cloneNode(true));
49
54
  });
50
55
 
@@ -64,14 +69,14 @@ const recursive = (children, rootKey = undefined, skipIndices = new Set()) => {
64
69
  template: {
65
70
  parsed: parseHTML([...templateContainer.childNodes]),
66
71
  element: templateContainer,
67
- children: templateParsed
68
- }
72
+ children: templateParsed,
73
+ },
69
74
  },
70
75
  runtime: {
71
76
  instances: [],
72
- templateRemoved: false
77
+ templateRemoved: false,
73
78
  },
74
- children: {}
79
+ children: {},
75
80
  };
76
81
 
77
82
  // Mark template indices as processed
@@ -105,7 +110,7 @@ const recursive = (children, rootKey = undefined, skipIndices = new Set()) => {
105
110
 
106
111
  // Create temporary container for true branch
107
112
  const trueBranchContainer = document.createElement('div');
108
- trueBranchNodes.forEach(node => {
113
+ trueBranchNodes.forEach((node) => {
109
114
  trueBranchContainer.appendChild(node.cloneNode(true));
110
115
  });
111
116
 
@@ -118,7 +123,7 @@ const recursive = (children, rootKey = undefined, skipIndices = new Set()) => {
118
123
  if (elseIndex !== null) {
119
124
  const falseBranchNodes = Array.from(children).slice(elseIndex + 1, endIndex);
120
125
  falseBranchContainer = document.createElement('div');
121
- falseBranchNodes.forEach(node => {
126
+ falseBranchNodes.forEach((node) => {
122
127
  falseBranchContainer.appendChild(node.cloneNode(true));
123
128
  });
124
129
  falseBranchParsed = recursive([...falseBranchContainer.childNodes]);
@@ -137,21 +142,23 @@ const recursive = (children, rootKey = undefined, skipIndices = new Set()) => {
137
142
  if: {
138
143
  parsed: parseHTML([...trueBranchContainer.childNodes]),
139
144
  element: trueBranchContainer,
140
- children: trueBranchParsed
145
+ children: trueBranchParsed,
141
146
  },
142
- else: falseBranchContainer ? {
143
- parsed: parseHTML([...falseBranchContainer.childNodes]),
144
- element: falseBranchContainer,
145
- children: falseBranchParsed
146
- } : null
147
- }
147
+ else: falseBranchContainer
148
+ ? {
149
+ parsed: parseHTML([...falseBranchContainer.childNodes]),
150
+ element: falseBranchContainer,
151
+ children: falseBranchParsed,
152
+ }
153
+ : null,
154
+ },
148
155
  },
149
156
  runtime: {
150
157
  activeBranch: undefined,
151
158
  activeInstance: null,
152
- templateRemoved: false
159
+ templateRemoved: false,
153
160
  },
154
- children: {}
161
+ children: {},
155
162
  };
156
163
 
157
164
  // Mark template indices as processed
@@ -199,27 +206,42 @@ const recursive = (children, rootKey = undefined, skipIndices = new Set()) => {
199
206
  parsed,
200
207
  element,
201
208
  children: recursive(iteratableChildren),
202
- ...(hasAttributeBindings && { attributes })
209
+ ...(hasAttributeBindings && { attributes }),
203
210
  };
204
211
  } else {
205
212
  result[nodeIdentifier] = {
206
213
  parsed: innerHTML || textContent,
207
214
  element,
208
215
  children: {},
209
- ...(hasAttributeBindings && { attributes })
216
+ ...(hasAttributeBindings && { attributes }),
210
217
  };
211
218
  }
212
219
  }
213
220
 
214
221
  return result;
215
- }
222
+ };
216
223
 
217
224
  export default (root, rootKey = undefined) => {
218
- const { childNodes } = root
225
+ const { childNodes } = root;
226
+
227
+ // Check for attribute bindings on the root element itself (only if element has attributes)
228
+ let attributes = null;
229
+ if (root.attributes && root.attributes.length > 0) {
230
+ for (let j = 0; j < root.attributes.length; j++) {
231
+ const attr = root.attributes[j];
232
+ BINDING_REGEX.lastIndex = 0;
233
+ if (BINDING_REGEX.test(attr.value)) {
234
+ if (!attributes) attributes = {};
235
+ attributes[attr.name] = attr.value;
236
+ }
237
+ }
238
+ }
239
+
219
240
  return {
220
241
  // html: root.outerHTML,
221
242
  parsed: parseHTML([...childNodes], rootKey),
222
243
  element: root,
223
- children: recursive(Array.from(childNodes), rootKey)
224
- }
225
- }
244
+ children: recursive(Array.from(childNodes), rootKey),
245
+ ...(attributes && { attributes }),
246
+ };
247
+ };
package/state.js CHANGED
@@ -1,12 +1,12 @@
1
1
  export default (state, rerender) =>
2
2
  new Proxy(state, {
3
3
  set(obj, prop, value) {
4
- const ref = Reflect.set(...arguments)
4
+ const ref = Reflect.set(...arguments);
5
5
  rerender({
6
6
  [prop]: value,
7
- })
7
+ });
8
8
  // console.info("state change", { obj, prop, value });
9
- return ref
9
+ return ref;
10
10
  },
11
11
  get(target, prop) {
12
12
  // if (typeof target[prop] === 'function') {
@@ -21,6 +21,6 @@ export default (state, rerender) =>
21
21
 
22
22
  // }
23
23
 
24
- return Reflect.get(...arguments)
24
+ return Reflect.get(...arguments);
25
25
  },
26
- })
26
+ });
package/utils.js CHANGED
@@ -9,7 +9,7 @@ export const deepMerge = (target, source) => {
9
9
  if (target == null) return source;
10
10
 
11
11
  // If source is not an object, return it
12
- if (typeof source !== "object") return source;
12
+ if (typeof source !== 'object') return source;
13
13
 
14
14
  // If source is an array, replace target array (don't merge arrays)
15
15
  if (Array.isArray(source)) return source;
@@ -27,8 +27,8 @@ export const deepMerge = (target, source) => {
27
27
  if (
28
28
  targetValue != null &&
29
29
  sourceValue != null &&
30
- typeof targetValue === "object" &&
31
- typeof sourceValue === "object" &&
30
+ typeof targetValue === 'object' &&
31
+ typeof sourceValue === 'object' &&
32
32
  !Array.isArray(targetValue) &&
33
33
  !Array.isArray(sourceValue)
34
34
  ) {
@@ -42,4 +42,3 @@ export const deepMerge = (target, source) => {
42
42
 
43
43
  return result;
44
44
  };
45
-