custom-elements-ts 0.0.17 → 0.2.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/.eslintrc.json +47 -0
- package/.github/workflows/ci.yml +49 -0
- package/.prettierrc +7 -0
- package/LICENSE +20 -0
- package/README.md +279 -37
- package/assets/readme-header.png +0 -0
- package/assets/social-preview.jpg +0 -0
- package/demos/counter/counter.element.html +1 -0
- package/demos/counter/counter.element.scss +234 -0
- package/demos/counter/counter.element.ts +68 -0
- package/demos/counter/index.html +205 -0
- package/demos/counter/index.ts +1 -0
- package/demos/site/code-example/code-example.element.scss +168 -0
- package/demos/site/code-example/code-example.element.ts +88 -0
- package/demos/site/event-log/event-log.element.scss +179 -0
- package/demos/site/event-log/event-log.element.ts +123 -0
- package/demos/site/favicon.svg +14 -0
- package/demos/site/index.html +368 -0
- package/demos/site/index.ts +19 -0
- package/demos/site/llms.txt +53 -0
- package/demos/site/message/message.element.scss +75 -0
- package/demos/site/message/message.element.ts +76 -0
- package/demos/site/og-image.png +0 -0
- package/demos/site/scroll-restoration.ts +28 -0
- package/demos/site/source-toggle/source-toggle.ts +88 -0
- package/demos/site/source-viewer/source-viewer.element.scss +292 -0
- package/demos/site/source-viewer/source-viewer.element.ts +138 -0
- package/demos/site/source-viewer/sources.generated.ts +11 -0
- package/demos/site/styles/site.css +1135 -0
- package/demos/site/styles/tokens.css +56 -0
- package/demos/site/toast/toast.element.scss +110 -0
- package/demos/site/toast/toast.element.ts +63 -0
- package/demos/todo-dashboard/index.html +141 -0
- package/demos/todo-dashboard/index.ts +4 -0
- package/demos/todo-dashboard/todo-dashboard.element.scss +1150 -0
- package/demos/todo-dashboard/todo-dashboard.element.ts +333 -0
- package/demos/todo-dashboard/todo-filters.element.ts +54 -0
- package/demos/todo-dashboard/todo-item.element.ts +127 -0
- package/demos/todo-dashboard/todo-stats.element.ts +189 -0
- package/package.json +73 -24
- package/src/custom-element.ts +206 -0
- package/src/index.ts +8 -0
- package/src/listen.ts +70 -0
- package/src/prop.ts +92 -0
- package/src/signal.ts +66 -0
- package/src/state.ts +141 -0
- package/src/template-runtime.ts +783 -0
- package/src/toggle.ts +66 -0
- package/src/tsconfig.json +24 -0
- package/src/util.ts +33 -0
- package/src/watch.ts +14 -0
- package/tests/basic.spec.ts +70 -0
- package/tests/custom-element.spec.ts +77 -0
- package/tests/dispatch.spec.ts +52 -0
- package/tests/init.spec.ts +94 -0
- package/tests/listen.spec.ts +118 -0
- package/tests/map-shallow-state.spec.ts +184 -0
- package/tests/prop.spec.ts +118 -0
- package/tests/signal.spec.ts +117 -0
- package/tests/templating-runtime.spec.ts +575 -0
- package/tests/toggle.spec.ts +92 -0
- package/tests/watch.spec.ts +183 -0
- package/tools/build.js +128 -0
- package/tools/bundle.js +167 -0
- package/tools/generate-sources.js +76 -0
- package/tools/rollup-config.js +70 -0
- package/tools/start.js +194 -0
- package/tsconfig.json +38 -0
- package/vite.config.mts +30 -0
- package/bundles/custom-elements-ts.umd.js +0 -359
- package/bundles/custom-elements-ts.umd.js.map +0 -1
- package/esm2015/custom-elements-ts.js +0 -283
- package/esm2015/custom-elements-ts.js.map +0 -1
- package/esm5/custom-element.d.ts +0 -12
- package/esm5/custom-elements-ts.js +0 -344
- package/esm5/custom-elements-ts.js.map +0 -1
- package/esm5/index.d.ts +0 -5
- package/esm5/listen.d.ts +0 -17
- package/esm5/prop.d.ts +0 -2
- package/esm5/toggle.d.ts +0 -1
- package/esm5/util.d.ts +0 -4
- package/esm5/watch.d.ts +0 -1
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach, vi } from 'vitest';
|
|
2
|
+
import { CustomElement, State, TemplateResult, html, map } from 'custom-elements-ts';
|
|
3
|
+
|
|
4
|
+
const nextMicrotask = () => Promise.resolve();
|
|
5
|
+
|
|
6
|
+
interface Item {
|
|
7
|
+
id: number;
|
|
8
|
+
label: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const rowSpy = vi.fn();
|
|
12
|
+
|
|
13
|
+
@CustomElement({
|
|
14
|
+
tag: 'mapped-list-element',
|
|
15
|
+
shadow: false,
|
|
16
|
+
})
|
|
17
|
+
class MappedListElement extends HTMLElement {
|
|
18
|
+
@State({ deep: false }) items: Item[] = [];
|
|
19
|
+
|
|
20
|
+
render(): TemplateResult {
|
|
21
|
+
return html`<ul>
|
|
22
|
+
${map(this.items, (item) => {
|
|
23
|
+
rowSpy(item.id);
|
|
24
|
+
return html`<li data-id=${item.id}>${item.label}</li>`;
|
|
25
|
+
})}
|
|
26
|
+
</ul>`;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('map() identity-based list rendering', () => {
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
document.body.innerHTML = '';
|
|
33
|
+
rowSpy.mockClear();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const mount = async (): Promise<MappedListElement> => {
|
|
37
|
+
const el = document.createElement('mapped-list-element') as MappedListElement;
|
|
38
|
+
document.body.appendChild(el);
|
|
39
|
+
await nextMicrotask();
|
|
40
|
+
return el;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
it('renders mapped items and skips rows whose item is identical on rerender', async () => {
|
|
44
|
+
const el = await mount();
|
|
45
|
+
const a = { id: 1, label: 'one' };
|
|
46
|
+
const b = { id: 2, label: 'two' };
|
|
47
|
+
el.items = [a, b];
|
|
48
|
+
await nextMicrotask();
|
|
49
|
+
|
|
50
|
+
const listItems = el.querySelectorAll('li');
|
|
51
|
+
expect(listItems.length).toBe(2);
|
|
52
|
+
expect(listItems[1].textContent).toBe('two');
|
|
53
|
+
expect(rowSpy).toHaveBeenCalledTimes(2);
|
|
54
|
+
|
|
55
|
+
// Replace only the second item; the first row must be skipped entirely.
|
|
56
|
+
rowSpy.mockClear();
|
|
57
|
+
el.items = [a, { id: 2, label: 'TWO' }];
|
|
58
|
+
await nextMicrotask();
|
|
59
|
+
|
|
60
|
+
expect(rowSpy).toHaveBeenCalledTimes(1);
|
|
61
|
+
expect(rowSpy).toHaveBeenCalledWith(2);
|
|
62
|
+
expect(el.querySelectorAll('li')[1].textContent).toBe('TWO');
|
|
63
|
+
expect(el.querySelectorAll('li')[0].textContent).toBe('one');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('detaches only the removed row on a contiguous middle removal', async () => {
|
|
67
|
+
const el = await mount();
|
|
68
|
+
const items = [
|
|
69
|
+
{ id: 1, label: 'one' },
|
|
70
|
+
{ id: 2, label: 'two' },
|
|
71
|
+
{ id: 3, label: 'three' },
|
|
72
|
+
{ id: 4, label: 'four' },
|
|
73
|
+
];
|
|
74
|
+
el.items = items;
|
|
75
|
+
await nextMicrotask();
|
|
76
|
+
const before = Array.from(el.querySelectorAll('li'));
|
|
77
|
+
expect(before.length).toBe(4);
|
|
78
|
+
|
|
79
|
+
// Remove the second item: the surviving rows must keep their exact DOM
|
|
80
|
+
// nodes and the row function must not run at all.
|
|
81
|
+
rowSpy.mockClear();
|
|
82
|
+
el.items = [items[0], items[2], items[3]];
|
|
83
|
+
await nextMicrotask();
|
|
84
|
+
|
|
85
|
+
const after = Array.from(el.querySelectorAll('li'));
|
|
86
|
+
expect(after.length).toBe(3);
|
|
87
|
+
expect(rowSpy).not.toHaveBeenCalled();
|
|
88
|
+
expect(after[0]).toBe(before[0]);
|
|
89
|
+
expect(after[1]).toBe(before[2]);
|
|
90
|
+
expect(after[2]).toBe(before[3]);
|
|
91
|
+
expect(after.map((li) => li.textContent)).toEqual(['one', 'three', 'four']);
|
|
92
|
+
|
|
93
|
+
// A later unrelated update still works with the spliced bookkeeping.
|
|
94
|
+
el.items = [items[0], { id: 9, label: 'nine' }, items[3]];
|
|
95
|
+
await nextMicrotask();
|
|
96
|
+
expect(Array.from(el.querySelectorAll('li')).map((li) => li.textContent)).toEqual([
|
|
97
|
+
'one',
|
|
98
|
+
'nine',
|
|
99
|
+
'four',
|
|
100
|
+
]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('grows, shrinks, and fast-clears mapped lists', async () => {
|
|
104
|
+
const el = await mount();
|
|
105
|
+
const items = [
|
|
106
|
+
{ id: 1, label: 'one' },
|
|
107
|
+
{ id: 2, label: 'two' },
|
|
108
|
+
{ id: 3, label: 'three' },
|
|
109
|
+
];
|
|
110
|
+
el.items = items;
|
|
111
|
+
await nextMicrotask();
|
|
112
|
+
expect(el.querySelectorAll('li').length).toBe(3);
|
|
113
|
+
|
|
114
|
+
// Shrink: first item kept by identity, rest removed.
|
|
115
|
+
rowSpy.mockClear();
|
|
116
|
+
el.items = [items[0]];
|
|
117
|
+
await nextMicrotask();
|
|
118
|
+
expect(el.querySelectorAll('li').length).toBe(1);
|
|
119
|
+
expect(rowSpy).not.toHaveBeenCalled();
|
|
120
|
+
|
|
121
|
+
// Grow again.
|
|
122
|
+
el.items = [items[0], { id: 9, label: 'nine' }];
|
|
123
|
+
await nextMicrotask();
|
|
124
|
+
const grown = el.querySelectorAll('li');
|
|
125
|
+
expect(grown.length).toBe(2);
|
|
126
|
+
expect(grown[1].textContent).toBe('nine');
|
|
127
|
+
|
|
128
|
+
// Clear to empty, then repopulate.
|
|
129
|
+
el.items = [];
|
|
130
|
+
await nextMicrotask();
|
|
131
|
+
expect(el.querySelectorAll('li').length).toBe(0);
|
|
132
|
+
|
|
133
|
+
el.items = [{ id: 5, label: 'five' }];
|
|
134
|
+
await nextMicrotask();
|
|
135
|
+
expect(el.querySelectorAll('li').length).toBe(1);
|
|
136
|
+
expect(el.querySelector('li')!.textContent).toBe('five');
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
@CustomElement({
|
|
141
|
+
tag: 'shallow-state-element',
|
|
142
|
+
shadow: false,
|
|
143
|
+
})
|
|
144
|
+
class ShallowStateElement extends HTMLElement {
|
|
145
|
+
@State({ deep: false }) data: Item[] = [];
|
|
146
|
+
renderCount = 0;
|
|
147
|
+
|
|
148
|
+
render(): TemplateResult {
|
|
149
|
+
this.renderCount++;
|
|
150
|
+
return html`<p>${this.data.length ? this.data[0].label : 'empty'}</p>`;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
describe('@State({ deep: false })', () => {
|
|
155
|
+
afterEach(() => {
|
|
156
|
+
document.body.innerHTML = '';
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('does not proxy values and only rerenders on reassignment', async () => {
|
|
160
|
+
const el = document.createElement('shallow-state-element') as ShallowStateElement;
|
|
161
|
+
document.body.appendChild(el);
|
|
162
|
+
await nextMicrotask();
|
|
163
|
+
|
|
164
|
+
const raw = [{ id: 1, label: 'one' }];
|
|
165
|
+
el.data = raw;
|
|
166
|
+
await nextMicrotask();
|
|
167
|
+
// Shallow state stores the value as-is (no proxy wrapper).
|
|
168
|
+
expect(el.data).toBe(raw);
|
|
169
|
+
expect(el.querySelector('p')!.textContent).toBe('one');
|
|
170
|
+
const renders = el.renderCount;
|
|
171
|
+
|
|
172
|
+
// Nested mutation must not schedule a render.
|
|
173
|
+
el.data[0].label = 'mutated';
|
|
174
|
+
await nextMicrotask();
|
|
175
|
+
expect(el.renderCount).toBe(renders);
|
|
176
|
+
expect(el.querySelector('p')!.textContent).toBe('one');
|
|
177
|
+
|
|
178
|
+
// Reassignment rerenders.
|
|
179
|
+
el.data = el.data.slice();
|
|
180
|
+
await nextMicrotask();
|
|
181
|
+
expect(el.renderCount).toBe(renders + 1);
|
|
182
|
+
expect(el.querySelector('p')!.textContent).toBe('mutated');
|
|
183
|
+
});
|
|
184
|
+
});
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { CustomElement, Prop } from 'custom-elements-ts';
|
|
3
|
+
|
|
4
|
+
@CustomElement({
|
|
5
|
+
tag: 'prop-element',
|
|
6
|
+
template: '<span>my element</span>',
|
|
7
|
+
style: ':host{border:0}',
|
|
8
|
+
})
|
|
9
|
+
class PropElement extends HTMLElement {
|
|
10
|
+
@Prop() name: any;
|
|
11
|
+
@Prop() maxFileSize: any;
|
|
12
|
+
|
|
13
|
+
@Prop() objectProp: any;
|
|
14
|
+
@Prop() init = 'blue';
|
|
15
|
+
|
|
16
|
+
@Prop() kebabCase = 'kebab';
|
|
17
|
+
|
|
18
|
+
@Prop() fnProp: any;
|
|
19
|
+
@Prop() classProp: any;
|
|
20
|
+
|
|
21
|
+
@Prop() zero = 0;
|
|
22
|
+
@Prop() enabled = false;
|
|
23
|
+
@Prop() empty = '';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe('prop decorator', () => {
|
|
27
|
+
let myElementInstance: any;
|
|
28
|
+
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
const myElement = document.createElement('prop-element');
|
|
31
|
+
myElementInstance = document.body.appendChild(myElement);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
afterEach(() => {
|
|
35
|
+
document.body.innerHTML = '';
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('should reflect as property', () => {
|
|
39
|
+
myElementInstance.setAttribute('name', 'Mario');
|
|
40
|
+
expect(myElementInstance.getAttribute('name')).toEqual('Mario');
|
|
41
|
+
myElementInstance.setAttribute('name', 'Luigi');
|
|
42
|
+
expect(myElementInstance.name).toEqual('Luigi');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('should reflect as attribute', () => {
|
|
46
|
+
myElementInstance.name = 'Aivan';
|
|
47
|
+
expect(myElementInstance.getAttribute('name')).toEqual('Aivan');
|
|
48
|
+
myElementInstance.name = 'Custom Elements';
|
|
49
|
+
expect(myElementInstance.getAttribute('name')).toEqual('Custom Elements');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('should not reflect as attribute', () => {
|
|
53
|
+
myElementInstance.name = {};
|
|
54
|
+
expect(myElementInstance.getAttribute('name')).toBeFalsy();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('should properly set camelcase properties', () => {
|
|
58
|
+
myElementInstance.maxFileSize = 1;
|
|
59
|
+
expect(myElementInstance.maxFileSize).toEqual(1);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('should properly get camelcase properties as kebabcase attributes', () => {
|
|
63
|
+
myElementInstance.maxFileSize = 1;
|
|
64
|
+
expect(myElementInstance.getAttribute('max-file-size')).toEqual('1');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('should properly set kebabcase attributes as camelcase properties', () => {
|
|
68
|
+
myElementInstance.setAttribute('max-file-size', 2);
|
|
69
|
+
expect(myElementInstance.maxFileSize).toEqual(2);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('should evaluate property', () => {
|
|
73
|
+
myElementInstance.objectProp = { name: 'Name' };
|
|
74
|
+
expect(myElementInstance.objectProp.name).toEqual('Name');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('should change attribute on attribute change', () => {
|
|
78
|
+
myElementInstance.setAttribute('init', 'red');
|
|
79
|
+
expect(myElementInstance.init).toEqual('red');
|
|
80
|
+
expect(myElementInstance.getAttribute('init')).toEqual('red');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('should change property via kebab attribute', () => {
|
|
84
|
+
myElementInstance.setAttribute('kebab-case', 'shawarma');
|
|
85
|
+
expect(myElementInstance.kebabCase).toEqual('shawarma');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('should not reflect function values as attributes and preserve reference', () => {
|
|
89
|
+
const fn = () => 42;
|
|
90
|
+
myElementInstance.fnProp = fn;
|
|
91
|
+
expect(myElementInstance.getAttribute('fn-prop')).toBeFalsy();
|
|
92
|
+
expect(typeof myElementInstance.fnProp).toEqual('function');
|
|
93
|
+
expect(myElementInstance.fnProp()).toEqual(42);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('should not reflect class/constructor values as attributes and preserve reference', () => {
|
|
97
|
+
class Foo {}
|
|
98
|
+
myElementInstance.classProp = Foo;
|
|
99
|
+
expect(myElementInstance.getAttribute('class-prop')).toBeFalsy();
|
|
100
|
+
expect(myElementInstance.classProp).toBe(Foo);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('should initialize falsy default property values', () => {
|
|
104
|
+
expect(myElementInstance.zero).toBe(0);
|
|
105
|
+
expect(myElementInstance.enabled).toBe(false);
|
|
106
|
+
expect(myElementInstance.empty).toBe('');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('should preserve boolean prop type when reflected through attributes', () => {
|
|
110
|
+
myElementInstance.enabled = true;
|
|
111
|
+
expect(myElementInstance.enabled).toBe(true);
|
|
112
|
+
expect(myElementInstance.getAttribute('enabled')).toBe('true');
|
|
113
|
+
|
|
114
|
+
myElementInstance.enabled = false;
|
|
115
|
+
expect(myElementInstance.enabled).toBe(false);
|
|
116
|
+
expect(myElementInstance.getAttribute('enabled')).toBe('false');
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach } from 'vitest';
|
|
2
|
+
import { CustomElement, Signal, State, TemplateResult, html, signal } from 'custom-elements-ts';
|
|
3
|
+
|
|
4
|
+
const nextMicrotask = () => Promise.resolve();
|
|
5
|
+
|
|
6
|
+
interface Row {
|
|
7
|
+
id: number;
|
|
8
|
+
label: Signal<string>;
|
|
9
|
+
selected: Signal<string | null>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
@CustomElement({
|
|
13
|
+
tag: 'signal-row-element',
|
|
14
|
+
shadow: false,
|
|
15
|
+
})
|
|
16
|
+
class SignalRowElement extends HTMLElement {
|
|
17
|
+
@State({ deep: false }) rows: Row[] = [];
|
|
18
|
+
renderCount = 0;
|
|
19
|
+
|
|
20
|
+
render(): TemplateResult {
|
|
21
|
+
this.renderCount++;
|
|
22
|
+
return html`<ul>
|
|
23
|
+
${this.rows.map(
|
|
24
|
+
(row) => html`<li class=${row.selected} .title=${row.label}>${row.label}</li>`
|
|
25
|
+
)}
|
|
26
|
+
</ul>`;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('signal bindings', () => {
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
document.body.innerHTML = '';
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('updates text, attribute, and property directly without re-rendering', async () => {
|
|
36
|
+
const el = document.createElement('signal-row-element') as SignalRowElement;
|
|
37
|
+
document.body.appendChild(el);
|
|
38
|
+
await nextMicrotask();
|
|
39
|
+
|
|
40
|
+
const row: Row = { id: 1, label: signal('one'), selected: signal<string | null>(null) };
|
|
41
|
+
el.rows = [row];
|
|
42
|
+
await nextMicrotask();
|
|
43
|
+
|
|
44
|
+
const li = el.querySelector('li')!;
|
|
45
|
+
expect(li.textContent).toBe('one');
|
|
46
|
+
expect(li.hasAttribute('class')).toBe(false);
|
|
47
|
+
expect(li.title).toBe('one');
|
|
48
|
+
const renders = el.renderCount;
|
|
49
|
+
|
|
50
|
+
// Signal writes go straight to the DOM — no render pass.
|
|
51
|
+
row.label.value = 'uno';
|
|
52
|
+
row.selected.value = 'danger';
|
|
53
|
+
expect(el.renderCount).toBe(renders);
|
|
54
|
+
expect(li.textContent).toBe('uno');
|
|
55
|
+
expect(li.getAttribute('class')).toBe('danger');
|
|
56
|
+
expect(li.title).toBe('uno');
|
|
57
|
+
|
|
58
|
+
// Null removes the attribute again.
|
|
59
|
+
row.selected.value = null;
|
|
60
|
+
expect(li.hasAttribute('class')).toBe(false);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('keeps bindings across re-renders with the same signal and rebinds on a new one', async () => {
|
|
64
|
+
const el = document.createElement('signal-row-element') as SignalRowElement;
|
|
65
|
+
document.body.appendChild(el);
|
|
66
|
+
await nextMicrotask();
|
|
67
|
+
|
|
68
|
+
const row: Row = { id: 1, label: signal('a'), selected: signal<string | null>(null) };
|
|
69
|
+
el.rows = [row];
|
|
70
|
+
await nextMicrotask();
|
|
71
|
+
|
|
72
|
+
// Re-render with the same signal instances: binding must survive.
|
|
73
|
+
el.rows = [row];
|
|
74
|
+
await nextMicrotask();
|
|
75
|
+
row.label.value = 'b';
|
|
76
|
+
expect(el.querySelector('li')!.textContent).toBe('b');
|
|
77
|
+
|
|
78
|
+
// Re-render with a different signal: old one must be detached.
|
|
79
|
+
const oldLabel = row.label;
|
|
80
|
+
const replacement: Row = { id: 1, label: signal('fresh'), selected: row.selected };
|
|
81
|
+
el.rows = [replacement];
|
|
82
|
+
await nextMicrotask();
|
|
83
|
+
const li = el.querySelector('li')!;
|
|
84
|
+
expect(li.textContent).toBe('fresh');
|
|
85
|
+
oldLabel.value = 'stale write';
|
|
86
|
+
expect(li.textContent).toBe('fresh');
|
|
87
|
+
replacement.label.value = 'newer';
|
|
88
|
+
expect(li.textContent).toBe('newer');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('unsubscribes disposed parts and skips notifications with equal values', async () => {
|
|
92
|
+
const el = document.createElement('signal-row-element') as SignalRowElement;
|
|
93
|
+
document.body.appendChild(el);
|
|
94
|
+
await nextMicrotask();
|
|
95
|
+
|
|
96
|
+
const label = signal('x');
|
|
97
|
+
const row: Row = { id: 1, label, selected: signal<string | null>(null) };
|
|
98
|
+
el.rows = [row];
|
|
99
|
+
await nextMicrotask();
|
|
100
|
+
expect(el.querySelector('li')).not.toBeNull();
|
|
101
|
+
|
|
102
|
+
let calls = 0;
|
|
103
|
+
label.subscribe(() => calls++);
|
|
104
|
+
label.value = 'x'; // equal value: no notification
|
|
105
|
+
expect(calls).toBe(0);
|
|
106
|
+
label.value = 'y';
|
|
107
|
+
expect(calls).toBe(1);
|
|
108
|
+
|
|
109
|
+
// Clearing the list disposes parts; writes afterwards must not throw.
|
|
110
|
+
el.rows = [];
|
|
111
|
+
await nextMicrotask();
|
|
112
|
+
expect(el.querySelector('li')).toBeNull();
|
|
113
|
+
expect(() => {
|
|
114
|
+
label.value = 'z';
|
|
115
|
+
}).not.toThrow();
|
|
116
|
+
});
|
|
117
|
+
});
|