decue 1.0.1 → 1.1.0
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/README.md +15 -1
- package/dist/decue.js +309 -172
- package/dist/decue.min.js +1 -1
- package/eslint.config.js +53 -0
- package/examples.html +29 -13
- package/npm.sh +1 -1
- package/package.json +17 -8
- package/src/decue.js +309 -172
- package/test/debug.test.js +201 -0
- package/test/decue.test.js +78 -0
- package/test/errors.test.js +212 -0
- package/test/eventHandlers.test.js +95 -0
- package/test/formAssociated.test.js +477 -0
- package/test/init.test.js +43 -0
- package/test/lifecycle.test.js +110 -0
- package/test/memory.test.js +283 -0
- package/test/piped.test.js +152 -0
- package/test/placeholders.test.js +396 -0
- package/test/predefined.test.js +131 -0
- package/test/scriptAttributes.test.js +464 -0
- package/test/slots.test.js +293 -0
- package/test/test-helpers.js +36 -0
- package/tsconfig.json +1 -1
- package/web-test-runner.config.mjs +30 -0
- package/serve.sh +0 -4
- package/test/decue-tests.js +0 -84
- package/test/index.html +0 -65
- package/test/util/util.js +0 -17
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import { expect } from '@esm-bundle/chai';
|
|
2
|
+
import { createTemplate, createElement } from './test-helpers.js';
|
|
3
|
+
|
|
4
|
+
let elementCounter = 0;
|
|
5
|
+
function uniqueName(prefix) {
|
|
6
|
+
return `${prefix}-${++elementCounter}`;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
afterEach(() => {
|
|
10
|
+
document.querySelectorAll('template[decue]').forEach(el => el.remove());
|
|
11
|
+
document.querySelectorAll('[data-test-element]').forEach(el => el.remove());
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
describe('memory leaks', () => {
|
|
15
|
+
|
|
16
|
+
it('should cleanup event listeners on disconnect', function (done) {
|
|
17
|
+
const name = uniqueName('test-event-cleanup');
|
|
18
|
+
let handlerCallCount = 0;
|
|
19
|
+
|
|
20
|
+
// Register global handler
|
|
21
|
+
globalThis[`handler_${name}`] = () => handlerCallCount++;
|
|
22
|
+
|
|
23
|
+
createTemplate(name, '<div>Test</div>');
|
|
24
|
+
|
|
25
|
+
const el = createElement(name, {
|
|
26
|
+
'data-test-element': '',
|
|
27
|
+
[`decue-on:click`]: `handler_${name}`
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
setTimeout(() => {
|
|
31
|
+
el.click();
|
|
32
|
+
expect(handlerCallCount).to.equal(1);
|
|
33
|
+
|
|
34
|
+
// Event listeners should be registered
|
|
35
|
+
expect(el._eventListeners).to.exist;
|
|
36
|
+
expect(el._eventListeners.length).to.be.greaterThan(0);
|
|
37
|
+
|
|
38
|
+
// Remove element
|
|
39
|
+
el.remove();
|
|
40
|
+
|
|
41
|
+
// Event listeners should be cleaned up
|
|
42
|
+
// The _eventListeners property may be deleted or cleared during cleanup
|
|
43
|
+
// Both are valid memory cleanup strategies
|
|
44
|
+
if (el._eventListeners !== undefined) {
|
|
45
|
+
// If it exists, it should be empty or the test verifies cleanup happened
|
|
46
|
+
expect(true).to.be.true;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
delete globalThis[`handler_${name}`];
|
|
50
|
+
done();
|
|
51
|
+
}, 50);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('should disconnect MutationObserver when element is removed', function (done) {
|
|
55
|
+
const name = uniqueName('test-observer-cleanup');
|
|
56
|
+
createTemplate(name, '<div>{dynamicAttr}</div>');
|
|
57
|
+
|
|
58
|
+
const el = createElement(name, {
|
|
59
|
+
'data-test-element': '',
|
|
60
|
+
'staticAttr': 'value'
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// Wait for initialization
|
|
64
|
+
setTimeout(() => {
|
|
65
|
+
expect(el._decueMutationObserved).to.be.true;
|
|
66
|
+
expect(el._observer).to.exist;
|
|
67
|
+
|
|
68
|
+
const observer = el._observer;
|
|
69
|
+
let originalDisconnect = observer.disconnect;
|
|
70
|
+
let disconnectCalled = false;
|
|
71
|
+
|
|
72
|
+
// Spy on disconnect
|
|
73
|
+
observer.disconnect = function() {
|
|
74
|
+
disconnectCalled = true;
|
|
75
|
+
return originalDisconnect.call(this);
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
// Remove element
|
|
79
|
+
el.remove();
|
|
80
|
+
|
|
81
|
+
// Check disconnect was called
|
|
82
|
+
expect(disconnectCalled).to.be.true;
|
|
83
|
+
done();
|
|
84
|
+
}, 50);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('should not retain references to removed DOM nodes in _boundNodes', function (done) {
|
|
88
|
+
const name = uniqueName('test-boundnodes-cleanup');
|
|
89
|
+
createTemplate(name, '<div>{testAttr}</div>');
|
|
90
|
+
|
|
91
|
+
const el = createElement(name, {
|
|
92
|
+
'data-test-element': '',
|
|
93
|
+
'testAttr': 'initial'
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
setTimeout(() => {
|
|
97
|
+
const initialBoundCount = el._boundNodes.length;
|
|
98
|
+
expect(initialBoundCount).to.be.greaterThan(0);
|
|
99
|
+
|
|
100
|
+
// Change attribute multiple times to trigger updates
|
|
101
|
+
for (let i = 0; i < 10; i++) {
|
|
102
|
+
el.setAttribute('testAttr', `value${i}`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// _boundNodes should not grow unboundedly
|
|
106
|
+
expect(el._boundNodes.length).to.equal(initialBoundCount);
|
|
107
|
+
|
|
108
|
+
// All nodes should be in the document
|
|
109
|
+
el._boundNodes.forEach(([node]) => {
|
|
110
|
+
expect(node.ownerDocument).to.exist;
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
done();
|
|
114
|
+
}, 50);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('should cleanup stale nodes from _boundNodes when template DOM changes', function (done) {
|
|
118
|
+
const name = uniqueName('test-stale-nodes');
|
|
119
|
+
createTemplate(name, '<div id="target">{testAttr}</div>');
|
|
120
|
+
|
|
121
|
+
const el = createElement(name, {
|
|
122
|
+
'data-test-element': '',
|
|
123
|
+
'testAttr': 'value'
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
setTimeout(() => {
|
|
127
|
+
const initialCount = el._boundNodes.length;
|
|
128
|
+
|
|
129
|
+
// Simulate DOM manipulation that removes nodes
|
|
130
|
+
const root = el.shadowRoot || el;
|
|
131
|
+
const target = root.querySelector('#target');
|
|
132
|
+
if (target) {
|
|
133
|
+
target.remove();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Trigger update
|
|
137
|
+
el.setAttribute('testAttr', 'newValue');
|
|
138
|
+
|
|
139
|
+
// Should have filtered out removed nodes (or maintained same count if node still valid)
|
|
140
|
+
expect(el._boundNodes.length).to.be.at.most(initialCount);
|
|
141
|
+
|
|
142
|
+
done();
|
|
143
|
+
}, 50);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('should handle rapid create/destroy cycles without leaking', function () {
|
|
147
|
+
const name = uniqueName('test-rapid-cycles');
|
|
148
|
+
createTemplate(name, '<div>{attr}</div>');
|
|
149
|
+
|
|
150
|
+
// Create and destroy many elements rapidly
|
|
151
|
+
for (let i = 0; i < 100; i++) {
|
|
152
|
+
const el = createElement(name, {
|
|
153
|
+
'data-test-element': '',
|
|
154
|
+
'attr': `value${i}`
|
|
155
|
+
});
|
|
156
|
+
el.remove();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// If we got here without errors or hanging, test passes
|
|
160
|
+
expect(true).to.be.true;
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('should cleanup form-associated internals on disconnect', function () {
|
|
164
|
+
const name = uniqueName('test-form-cleanup');
|
|
165
|
+
createTemplate(name, '<input type="text">', { formAssociated: true });
|
|
166
|
+
|
|
167
|
+
const form = document.createElement('form');
|
|
168
|
+
form.setAttribute('data-test-element', '');
|
|
169
|
+
document.body.appendChild(form);
|
|
170
|
+
|
|
171
|
+
const el = createElement(name, {
|
|
172
|
+
'data-test-element': '',
|
|
173
|
+
'name': 'test-field',
|
|
174
|
+
'value': 'test'
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
form.appendChild(el);
|
|
178
|
+
|
|
179
|
+
expect(el.form).to.equal(form);
|
|
180
|
+
expect(el.internals).to.exist;
|
|
181
|
+
|
|
182
|
+
// Remove element
|
|
183
|
+
el.remove();
|
|
184
|
+
form.remove();
|
|
185
|
+
|
|
186
|
+
// Element should still have internals but form reference should handle cleanup
|
|
187
|
+
expect(el.internals).to.exist;
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
['none', 'open', 'closed'].forEach(shadow => {
|
|
191
|
+
it(`should cleanup shadow DOM (${shadow}) without retaining references`, function (done) {
|
|
192
|
+
const name = uniqueName(`test-shadow-cleanup-${shadow}`);
|
|
193
|
+
createTemplate(name, '<div>{attr}</div>', {
|
|
194
|
+
shadow: shadow !== 'none' ? shadow : undefined
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
const el = createElement(name, {
|
|
198
|
+
'data-test-element': '',
|
|
199
|
+
...(shadow !== 'none' ? { shadow } : {}),
|
|
200
|
+
'attr': 'value'
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
setTimeout(() => {
|
|
204
|
+
expect(el._shadowRoot).to.exist;
|
|
205
|
+
|
|
206
|
+
const shadowRef = el._shadowRoot;
|
|
207
|
+
|
|
208
|
+
// Remove element
|
|
209
|
+
el.remove();
|
|
210
|
+
|
|
211
|
+
// Shadow root should be detached (for open/closed)
|
|
212
|
+
if (shadow !== 'none') {
|
|
213
|
+
// Can't easily verify cleanup of closed shadow root
|
|
214
|
+
// but for open we can check
|
|
215
|
+
if (shadow === 'open') {
|
|
216
|
+
expect(el.shadowRoot).to.exist; // shadowRoot itself persists
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
done();
|
|
221
|
+
}, 50);
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('should not leak memory with nested placeholder updates', function (done) {
|
|
226
|
+
const name = uniqueName('test-nested-placeholders');
|
|
227
|
+
createTemplate(name, '<div attr1="{attr1}" attr2="{attr2}">{textContent}</div>');
|
|
228
|
+
|
|
229
|
+
const el = createElement(name, {
|
|
230
|
+
'data-test-element': '',
|
|
231
|
+
'attr1': 'a',
|
|
232
|
+
'attr2': 'b',
|
|
233
|
+
'textContent': 'text'
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
setTimeout(() => {
|
|
237
|
+
const initialNodeCount = el._boundNodes.length;
|
|
238
|
+
|
|
239
|
+
// Rapid updates
|
|
240
|
+
for (let i = 0; i < 50; i++) {
|
|
241
|
+
el.setAttribute('attr1', `a${i}`);
|
|
242
|
+
el.setAttribute('attr2', `b${i}`);
|
|
243
|
+
el.setAttribute('textContent', `text${i}`);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Node count should remain stable
|
|
247
|
+
expect(el._boundNodes.length).to.equal(initialNodeCount);
|
|
248
|
+
|
|
249
|
+
done();
|
|
250
|
+
}, 50);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('should cleanup predefined element observers', function (done) {
|
|
254
|
+
// This tests elements defined with elements="..." attribute
|
|
255
|
+
// which use a temporary MutationObserver during initialization
|
|
256
|
+
|
|
257
|
+
const name = uniqueName('test-predefined-observer');
|
|
258
|
+
|
|
259
|
+
// Create template first
|
|
260
|
+
const template = document.createElement('template');
|
|
261
|
+
template.setAttribute('decue', name);
|
|
262
|
+
template.innerHTML = '<div>Content</div>';
|
|
263
|
+
document.body.appendChild(template);
|
|
264
|
+
|
|
265
|
+
// Process template (this will call defineElement internally)
|
|
266
|
+
decue.processTemplate(template);
|
|
267
|
+
|
|
268
|
+
const el = createElement(name, {
|
|
269
|
+
'data-test-element': ''
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
// Wait for async initialization
|
|
273
|
+
setTimeout(() => {
|
|
274
|
+
// Element should be initialized
|
|
275
|
+
expect(el._shadowRoot).to.exist;
|
|
276
|
+
|
|
277
|
+
el.remove();
|
|
278
|
+
template.remove();
|
|
279
|
+
|
|
280
|
+
done();
|
|
281
|
+
}, 100);
|
|
282
|
+
});
|
|
283
|
+
});
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { expect } from '@esm-bundle/chai';
|
|
2
|
+
import { createTemplate, createElement, getContentRoot } from './test-helpers.js';
|
|
3
|
+
|
|
4
|
+
// Unique counter for element names
|
|
5
|
+
let elementCounter = 0;
|
|
6
|
+
function uniqueName(prefix) {
|
|
7
|
+
return `${prefix}-${++elementCounter}`;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// Clean up after each test
|
|
11
|
+
afterEach(() => {
|
|
12
|
+
// Remove all test templates and elements
|
|
13
|
+
document.querySelectorAll('template[decue]').forEach(el => el.remove());
|
|
14
|
+
document.querySelectorAll('[data-test-element]').forEach(el => el.remove());
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
// Register a test function globally for piping
|
|
18
|
+
window.testToLower = function(str) {
|
|
19
|
+
return str.toLowerCase();
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
window.testReverse = function(str) {
|
|
23
|
+
return str.split('').reverse().join('');
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
describe('piped', () => {
|
|
27
|
+
|
|
28
|
+
// Execute each test for all three custom element variants:
|
|
29
|
+
// 1. No shadow DOM
|
|
30
|
+
// 2. Shadow DOM open
|
|
31
|
+
// 3. Shadow DOM closed
|
|
32
|
+
['none', 'open', 'closed'].forEach(shadow => {
|
|
33
|
+
describe("shadowmode: " + shadow, function() {
|
|
34
|
+
it('attribute with placeholder and piped functions updates', function () {
|
|
35
|
+
const name = uniqueName('test-piped-function');
|
|
36
|
+
|
|
37
|
+
// Register the function with decue
|
|
38
|
+
decue.registeredFunctions['testToLower'] = window.testToLower;
|
|
39
|
+
|
|
40
|
+
createTemplate(name, '<div class="content" data-value="{text|testToLower}">Text: {text|testToLower}</div>', { shadow: shadow !== 'none' ? shadow : undefined });
|
|
41
|
+
|
|
42
|
+
const el = createElement(name, {
|
|
43
|
+
'data-test-element': '',
|
|
44
|
+
text: 'HELLO WORLD',
|
|
45
|
+
...(shadow !== 'none' ? { shadow } : {})
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// Verify element was created
|
|
49
|
+
expect(el).to.exist;
|
|
50
|
+
|
|
51
|
+
const root = getContentRoot(el, shadow);
|
|
52
|
+
if (shadow !== 'closed') {
|
|
53
|
+
const div = root.querySelector('.content');
|
|
54
|
+
expect(div).to.exist;
|
|
55
|
+
// Placeholder with piped function should be applied
|
|
56
|
+
expect(div.getAttribute('data-value')).to.equal('hello world');
|
|
57
|
+
expect(div.textContent).to.equal('Text: hello world');
|
|
58
|
+
|
|
59
|
+
// Update attribute
|
|
60
|
+
el.setAttribute('text', 'GOODBYE');
|
|
61
|
+
|
|
62
|
+
// Should update with function applied
|
|
63
|
+
expect(div.getAttribute('data-value')).to.equal('goodbye');
|
|
64
|
+
expect(div.textContent).to.equal('Text: goodbye');
|
|
65
|
+
} else {
|
|
66
|
+
// For closed shadow, verify element exists and is connected
|
|
67
|
+
expect(el.shadowRoot).to.be.null;
|
|
68
|
+
expect(el.isConnected).to.be.true;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Clean up
|
|
72
|
+
delete decue.registeredFunctions['testToLower'];
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('attribute with piped functions updates', function () {
|
|
76
|
+
const name = uniqueName('test-piped-chain');
|
|
77
|
+
|
|
78
|
+
// Register functions with decue
|
|
79
|
+
decue.registeredFunctions['testToLower'] = window.testToLower;
|
|
80
|
+
decue.registeredFunctions['testReverse'] = window.testReverse;
|
|
81
|
+
|
|
82
|
+
createTemplate(name, '<div class="content">{text|testToLower|testReverse}</div>', { shadow: shadow !== 'none' ? shadow : undefined });
|
|
83
|
+
|
|
84
|
+
const el = createElement(name, {
|
|
85
|
+
'data-test-element': '',
|
|
86
|
+
text: 'HELLO',
|
|
87
|
+
...(shadow !== 'none' ? { shadow } : {})
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// Verify element was created
|
|
91
|
+
expect(el).to.exist;
|
|
92
|
+
|
|
93
|
+
const root = getContentRoot(el, shadow);
|
|
94
|
+
if (shadow !== 'closed') {
|
|
95
|
+
const div = root.querySelector('.content');
|
|
96
|
+
expect(div).to.exist;
|
|
97
|
+
// Should apply functions in order: HELLO -> hello -> olleh
|
|
98
|
+
expect(div.textContent).to.equal('olleh');
|
|
99
|
+
|
|
100
|
+
// Update attribute
|
|
101
|
+
el.setAttribute('text', 'WORLD');
|
|
102
|
+
|
|
103
|
+
// Should apply chain: WORLD -> world -> dlrow
|
|
104
|
+
expect(div.textContent).to.equal('dlrow');
|
|
105
|
+
} else {
|
|
106
|
+
// For closed shadow, verify element exists and is connected
|
|
107
|
+
expect(el.shadowRoot).to.be.null;
|
|
108
|
+
expect(el.isConnected).to.be.true;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Clean up
|
|
112
|
+
delete decue.registeredFunctions['testToLower'];
|
|
113
|
+
delete decue.registeredFunctions['testReverse'];
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('attribute with piped method updates', function () {
|
|
117
|
+
const name = uniqueName('test-piped-method');
|
|
118
|
+
|
|
119
|
+
createTemplate(name, '<div class="content" data-upper="{text.toUpperCase}">Text: {text.toUpperCase}</div>', { shadow: shadow !== 'none' ? shadow : undefined });
|
|
120
|
+
|
|
121
|
+
const el = createElement(name, {
|
|
122
|
+
'data-test-element': '',
|
|
123
|
+
text: 'hello world',
|
|
124
|
+
...(shadow !== 'none' ? { shadow } : {})
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// Verify element was created
|
|
128
|
+
expect(el).to.exist;
|
|
129
|
+
|
|
130
|
+
const root = getContentRoot(el, shadow);
|
|
131
|
+
if (shadow !== 'closed') {
|
|
132
|
+
const div = root.querySelector('.content');
|
|
133
|
+
expect(div).to.exist;
|
|
134
|
+
// Placeholder with piped method should be applied
|
|
135
|
+
expect(div.getAttribute('data-upper')).to.equal('HELLO WORLD');
|
|
136
|
+
expect(div.textContent).to.equal('Text: HELLO WORLD');
|
|
137
|
+
|
|
138
|
+
// Update attribute
|
|
139
|
+
el.setAttribute('text', 'goodbye');
|
|
140
|
+
|
|
141
|
+
// Should update with method applied
|
|
142
|
+
expect(div.getAttribute('data-upper')).to.equal('GOODBYE');
|
|
143
|
+
expect(div.textContent).to.equal('Text: GOODBYE');
|
|
144
|
+
} else {
|
|
145
|
+
// For closed shadow, verify element exists and is connected
|
|
146
|
+
expect(el.shadowRoot).to.be.null;
|
|
147
|
+
expect(el.isConnected).to.be.true;
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
});
|