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.
@@ -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
+ });