@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 +32 -0
- package/_vibe-compiled-iteration-batch.js +9 -8
- package/affected.js +45 -15
- package/conditionals.js +13 -12
- package/constants.js +103 -22
- package/hydrate.js +6 -6
- package/index.js +74 -91
- package/iterate.js +54 -31
- package/iteration-utils.js +20 -26
- package/link.js +1 -1
- package/package.json +1 -1
- package/parse.js +54 -32
- package/state.js +5 -5
- package/utils.js +3 -4
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(
|
|
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
|
-
|
|
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 (
|
|
97
|
+
if (shouldAffect) {
|
|
82
98
|
hasAffected = true;
|
|
83
99
|
}
|
|
84
100
|
|
|
85
101
|
checkedMatches.push({
|
|
86
102
|
...m,
|
|
87
|
-
matches:
|
|
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
|
-
|
|
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 (
|
|
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 =
|
|
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 {
|
|
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 =
|
|
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 = [
|
|
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',
|
|
9
|
-
'
|
|
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',
|
|
13
|
-
'
|
|
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',
|
|
17
|
-
'
|
|
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',
|
|
21
|
-
'
|
|
22
|
-
'
|
|
23
|
-
'
|
|
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',
|
|
74
|
+
'alt',
|
|
75
|
+
'label',
|
|
76
|
+
'summary',
|
|
77
|
+
'abbr',
|
|
27
78
|
|
|
28
79
|
// Dimensions and layout
|
|
29
|
-
'width',
|
|
30
|
-
'
|
|
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',
|
|
34
|
-
'
|
|
35
|
-
'
|
|
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',
|
|
111
|
+
'preload',
|
|
112
|
+
'kind',
|
|
113
|
+
'srclang',
|
|
39
114
|
|
|
40
115
|
// Meta
|
|
41
|
-
'content',
|
|
116
|
+
'content',
|
|
117
|
+
'http-equiv',
|
|
42
118
|
|
|
43
119
|
// iframe/embed
|
|
44
|
-
'sandbox',
|
|
120
|
+
'sandbox',
|
|
121
|
+
'allow',
|
|
122
|
+
'srcdoc',
|
|
123
|
+
'credentialless',
|
|
45
124
|
|
|
46
125
|
// Table
|
|
47
|
-
'headers',
|
|
126
|
+
'headers',
|
|
127
|
+
'scope',
|
|
48
128
|
|
|
49
129
|
// Datetime
|
|
50
130
|
'datetime',
|
|
51
131
|
|
|
52
132
|
// Object/embed legacy
|
|
53
|
-
'coords',
|
|
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 =
|
|
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
|
|
2
|
-
import parse from
|
|
3
|
-
import link from
|
|
4
|
-
import hydrate, { setPreviousState } from
|
|
5
|
-
import affected from
|
|
6
|
-
import { deepMerge, hash } from
|
|
7
|
-
import { renderAllIterations, setRenderAllConditionals } from
|
|
8
|
-
import { renderAllConditionals } from
|
|
9
|
-
import { NON_REACTIVE_ELEMENTS } from
|
|
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(
|
|
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 =
|
|
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
|
|
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
|
-
|
|
181
|
-
picked
|
|
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
|
-
|
|
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
|
-
//
|
|
193
|
-
|
|
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
|
-
|
|
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
|
|
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) => {
|
|
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 } =
|
|
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 (
|
|
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':
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
case '
|
|
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) => {
|
|
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 =
|
|
360
|
-
|
|
361
|
-
|
|
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 =>
|
|
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
|
-
?
|
|
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
|
};
|
package/iteration-utils.js
CHANGED
|
@@ -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(
|
|
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 ===
|
|
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 ===
|
|
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(
|
|
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 ===
|
|
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 ===
|
|
49
|
+
else if (text === 'else' && depth === 1 && elseIndex === null) {
|
|
50
50
|
elseIndex = i;
|
|
51
51
|
}
|
|
52
52
|
// Check for /if
|
|
53
|
-
else if (text ===
|
|
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(
|
|
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
|
|
68
|
-
if (typeof 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 !==
|
|
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 ===
|
|
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 !==
|
|
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:
|
|
207
|
+
type: 'UPDATE',
|
|
208
208
|
index: newIdx,
|
|
209
209
|
oldIndex: oldIdx,
|
|
210
210
|
item: newArray[newIdx],
|
|
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
|
|
226
220
|
oldKeys.forEach((key, idx) => {
|
|
227
221
|
if (!processedOld.has(idx)) {
|
|
228
222
|
operations.push({
|
|
229
|
-
type:
|
|
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:
|
|
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 ===
|
|
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
package/package.json
CHANGED
package/parse.js
CHANGED
|
@@ -1,18 +1,23 @@
|
|
|
1
|
-
import { hash } from './utils.js'
|
|
2
|
-
import { findEndComment, findConditionalEnd } from './iteration-utils.js'
|
|
3
|
-
import {
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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 !==
|
|
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 ===
|
|
31
|
-
typeof sourceValue ===
|
|
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
|
-
|