a post-component library for building user-interfaces on the web.
1import { html, keyed, type Displayable } from 'dhtml'
2import { invalidate } from 'dhtml/client'
3import { assert, assert_eq, test } from '../../../scripts/test/test.ts'
4import { setup } from './setup.ts'
5
6function shuffle<T>(array: T[]) {
7 for (let i = 0; i < array.length; i++) {
8 const j = Math.floor(Math.random() * i)
9 ;[array[i], array[j]] = [array[j], array[i]]
10 }
11}
12
13test('basic list operations work correctly', () => {
14 const { root, el } = setup()
15
16 let items: Displayable[] | null = null
17 const listOfItems = () => html`
18 <ul>
19 <li>Before</li>
20 ${items}
21 <li>After</li>
22 </ul>
23 `
24
25 root.render(listOfItems())
26 assert_eq(el.innerHTML.replace(/\s+/g, ' '), ' <ul> <li>Before</li> <li>After</li> </ul> ')
27
28 items = [html`<li>Item 1</li>`, html`<li>Item 2</li>`, html`<li>Item 3</li>`]
29
30 root.render(listOfItems())
31 assert_eq(
32 el.innerHTML.replace(/\s+/g, ' '),
33 ' <ul> <li>Before</li> <li>Item 1</li><li>Item 2</li><li>Item 3</li> <li>After</li> </ul> ',
34 )
35 const [item1, item2, item3] = el.querySelectorAll('li')
36
37 items.push(html`<li>Item 4</li>`)
38 root.render(listOfItems())
39 assert_eq(
40 el.innerHTML.replace(/\s+/g, ' '),
41 ' <ul> <li>Before</li> <li>Item 1</li><li>Item 2</li><li>Item 3</li><li>Item 4</li> <li>After</li> </ul> ',
42 )
43 const [item1b, item2b, item3b] = el.querySelectorAll('li')
44 assert_eq(item1, item1b)
45 assert_eq(item2, item2b)
46 assert_eq(item3, item3b)
47
48 items.pop()
49 items.pop()
50 root.render(listOfItems())
51 assert_eq(
52 el.innerHTML.replace(/\s+/g, ' '),
53 ' <ul> <li>Before</li> <li>Item 1</li><li>Item 2</li> <li>After</li> </ul> ',
54 )
55 const [item1c, item2c] = el.querySelectorAll('li')
56 assert_eq(item1, item1c)
57 assert_eq(item2, item2c)
58})
59
60test('pop operation works correctly on lists', () => {
61 const { root, el } = setup()
62
63 const items = [html`<p>Item 1</p>`, html`<p>Item 2</p>`, html`<p>Item 3</p>`]
64 const wrapped = html`[${items}]`
65
66 root.render(wrapped)
67 assert_eq(el.innerHTML, '[<p>Item 1</p><p>Item 2</p><p>Item 3</p>]')
68 const [item1, item2] = el.children
69
70 items.pop()
71 root.render(wrapped)
72 assert_eq(el.innerHTML, '[<p>Item 1</p><p>Item 2</p>]')
73 assert_eq(el.children[0], item1)
74 assert_eq(el.children[1], item2)
75
76 items.pop()
77 root.render(wrapped)
78 assert_eq(el.innerHTML, '[<p>Item 1</p>]')
79 assert_eq(el.children[0], item1)
80
81 items.pop()
82 root.render(wrapped)
83 assert_eq(el.innerHTML, '[]')
84})
85
86test('swap operation works correctly on lists', () => {
87 const { root, el } = setup()
88
89 const items = [html`<p>Item 1</p>`, html`<p>Item 2</p>`, html`<p>Item 3</p>`]
90 const wrapped = html`[${items}]`
91
92 root.render(wrapped)
93 assert_eq(el.innerHTML, '[<p>Item 1</p><p>Item 2</p><p>Item 3</p>]')
94 const [item1, item2, item3] = el.children
95
96 // swap the first two items
97 ;[items[0], items[1]] = [items[1], items[0]]
98 root.render(wrapped)
99 assert_eq(el.innerHTML, '[<p>Item 2</p><p>Item 1</p><p>Item 3</p>]')
100 assert_eq(el.children[0], item2)
101 assert_eq(el.children[1], item1)
102 assert_eq(el.children[2], item3)
103
104 // swap the last two items
105 ;[items[1], items[2]] = [items[2], items[1]]
106 root.render(wrapped)
107 assert_eq(el.innerHTML, '[<p>Item 2</p><p>Item 3</p><p>Item 1</p>]')
108 assert_eq(el.children[0], item2)
109 assert_eq(el.children[1], item3)
110 assert_eq(el.children[2], item1)
111
112 // swap the first and last items
113 ;[items[0], items[2]] = [items[2], items[0]]
114 root.render(wrapped)
115 assert_eq(el.innerHTML, '[<p>Item 1</p><p>Item 3</p><p>Item 2</p>]')
116 assert_eq(el.children[0], item1)
117 assert_eq(el.children[1], item3)
118 assert_eq(el.children[2], item2)
119
120 // put things back
121 ;[items[1], items[2]] = [items[2], items[1]]
122 root.render(wrapped)
123 assert_eq(el.innerHTML, '[<p>Item 1</p><p>Item 2</p><p>Item 3</p>]')
124 assert_eq(el.children[0], item1)
125 assert_eq(el.children[1], item2)
126 assert_eq(el.children[2], item3)
127})
128
129test('shift operation works correctly on lists', () => {
130 const { root, el } = setup()
131
132 const items = [html`<p>Item 1</p>`, html`<p>Item 2</p>`, html`<p>Item 3</p>`]
133 const wrapped = html`[${items}]`
134
135 root.render(wrapped)
136 assert_eq(el.innerHTML, '[<p>Item 1</p><p>Item 2</p><p>Item 3</p>]')
137 const [, item2, item3] = el.children
138
139 items.shift()
140 root.render(wrapped)
141 assert_eq(el.innerHTML, '[<p>Item 2</p><p>Item 3</p>]')
142 assert_eq(el.children[0], item2)
143 assert_eq(el.children[1], item3)
144
145 items.shift()
146 root.render(wrapped)
147 assert_eq(el.innerHTML, '[<p>Item 3</p>]')
148 assert_eq(el.children[0], item3)
149
150 items.shift()
151 root.render(wrapped)
152 assert_eq(el.innerHTML, '[]')
153})
154
155test('full then empty then full list renders correctly', () => {
156 const { root, el } = setup()
157
158 root.render([1])
159 assert_eq(el.innerHTML, '1')
160
161 root.render([])
162 assert_eq(el.innerHTML, '')
163
164 root.render([2])
165 assert_eq(el.innerHTML, '2')
166})
167
168test('list can disappear when condition changes', async () => {
169 const { root, el } = setup()
170
171 const app = {
172 show: true,
173 render() {
174 if (!this.show) return null
175 return [1, 2, 3].map(i => html`<div>${i}</div>`)
176 },
177 }
178
179 root.render(app)
180 assert_eq(el.innerHTML, '<div>1</div><div>2</div><div>3</div>')
181
182 app.show = false
183 await invalidate(app)
184 assert_eq(el.innerHTML, '')
185})
186
187test('unkeyed lists recreate elements when reordered', () => {
188 const { root, el } = setup()
189
190 const a = () => html`<h1>Item 1</h1>`
191 const b = () => html`<h2>Item 2</h2>`
192
193 root.render([a(), b()])
194 assert_eq(el.innerHTML, '<h1>Item 1</h1><h2>Item 2</h2>')
195
196 const [h1, h2] = el.children
197 assert_eq(h1.tagName, 'H1')
198 assert_eq(h2.tagName, 'H2')
199
200 root.render([b(), a()])
201 assert_eq(el.innerHTML, '<h2>Item 2</h2><h1>Item 1</h1>')
202
203 // visually they should be swapped
204 assert_eq(el.children[0].innerHTML, h2.innerHTML)
205 assert_eq(el.children[1].innerHTML, h1.innerHTML)
206
207 // but there's no stable identity, so they're recreated
208 assert(el.children[0] !== h2)
209 assert(el.children[1] !== h1)
210})
211
212test('explicit keyed lists preserve identity when reordered', () => {
213 const { root, el } = setup()
214
215 const a = () => keyed(html`<h1>Item 1</h1>`, 1)
216 const b = () => keyed(html`<h2>Item 2</h2>`, 2)
217
218 root.render([a(), b()])
219 assert_eq(el.innerHTML, '<h1>Item 1</h1><h2>Item 2</h2>')
220
221 const [h1, h2] = el.children
222 assert_eq(h1.tagName, 'H1')
223 assert_eq(h2.tagName, 'H2')
224
225 root.render([b(), a()])
226 assert_eq(el.innerHTML, '<h2>Item 2</h2><h1>Item 1</h1>')
227
228 assert_eq(el.children[0], h2)
229 assert_eq(el.children[1], h1)
230})
231
232test('implicit keyed lists preserve identity when reordered', () => {
233 const { root, el } = setup()
234
235 const items = [html`<h1>Item 1</h1>`, html`<h2>Item 2</h2>`]
236
237 root.render(items)
238 assert_eq(el.innerHTML, '<h1>Item 1</h1><h2>Item 2</h2>')
239
240 const [h1, h2] = el.children
241 assert_eq(h1.tagName, 'H1')
242 assert_eq(h2.tagName, 'H2')
243 ;[items[0], items[1]] = [items[1], items[0]]
244
245 root.render(items)
246 assert_eq(el.innerHTML, '<h2>Item 2</h2><h1>Item 1</h1>')
247 assert_eq(el.children[0].tagName, 'H2')
248 assert_eq(el.children[1].tagName, 'H1')
249
250 assert_eq(el.children[0], h2)
251 assert_eq(el.children[1], h1)
252})
253
254test('implicit keyed lists with multiple elements preserve identity when resized', () => {
255 const { root, el } = setup()
256
257 const items = [
258 html`<h1>Item 1</h1>`,
259 html`
260 <h2>Item 2</h2>
261 <p>Body content</p>
262 `,
263 ]
264
265 root.render(items)
266 assert_eq(el.innerHTML.replace(/\s+/g, ' '), '<h1>Item 1</h1> <h2>Item 2</h2> <p>Body content</p> ')
267
268 const [h1, h2, p] = el.children
269 assert_eq(h1.tagName, 'H1')
270 assert_eq(h2.tagName, 'H2')
271 assert_eq(p.tagName, 'P')
272
273 // Swap
274 ;[items[0], items[1]] = [items[1], items[0]]
275 root.render(items)
276 assert_eq(el.innerHTML.replace(/\s+/g, ' '), ' <h2>Item 2</h2> <p>Body content</p> <h1>Item 1</h1>')
277 assert_eq(el.children[0].tagName, 'H2')
278 assert_eq(el.children[1].tagName, 'P')
279 assert_eq(el.children[2].tagName, 'H1')
280
281 assert_eq(el.children[0], h2)
282 assert_eq(el.children[1], p)
283 assert_eq(el.children[2], h1)
284
285 // Swap back
286 ;[items[0], items[1]] = [items[1], items[0]]
287 root.render(items)
288 assert_eq(el.innerHTML.replace(/\s+/g, ' '), '<h1>Item 1</h1> <h2>Item 2</h2> <p>Body content</p> ')
289 assert_eq(el.children[0].tagName, 'H1')
290 assert_eq(el.children[1].tagName, 'H2')
291 assert_eq(el.children[2].tagName, 'P')
292 assert_eq(el.children[0], h1)
293 assert_eq(el.children[1], h2)
294 assert_eq(el.children[2], p)
295})
296
297test('implicit keyed renderable lists preserve identity when reordered', () => {
298 const { root, el } = setup()
299
300 const items = [{ render: () => html`<h1>Item 1</h1>` }, { render: () => html`<h2>Item 2</h2>` }]
301
302 root.render(items)
303 assert_eq(el.innerHTML, '<h1>Item 1</h1><h2>Item 2</h2>')
304
305 const [h1, h2] = el.children
306 assert_eq(h1.tagName, 'H1')
307 assert_eq(h2.tagName, 'H2')
308 ;[items[0], items[1]] = [items[1], items[0]]
309
310 root.render(items)
311 assert_eq(el.innerHTML, '<h2>Item 2</h2><h1>Item 1</h1>')
312 assert_eq(el.children[0].tagName, 'H2')
313 assert_eq(el.children[1].tagName, 'H1')
314
315 assert_eq(el.children[0], h2)
316 assert_eq(el.children[1], h1)
317})
318
319test('many items can be reordered', () => {
320 const { root, el } = setup()
321
322 const items = Array.from({ length: 10 }, (_, i) => [html`<p>Item ${i}</p>`, `<p>Item ${i}</p>`])
323
324 root.render(items.map(([item]) => item))
325 assert_eq(el.innerHTML, items.map(([, html]) => html).join(''))
326
327 shuffle(items)
328
329 root.render(items.map(([item]) => item))
330 assert_eq(el.innerHTML, items.map(([, html]) => html).join(''))
331})
332
333if (__DEV__) {
334 test('keying something twice throws an error', () => {
335 keyed(html``, 1) // make sure this doesn't throw
336
337 try {
338 keyed(keyed(html``, 1), 1)
339 assert(false, 'Expected an error')
340 } catch {}
341 })
342}
343
344test('can render the same item multiple times', () => {
345 const { root, el } = setup()
346
347 const item = html`<p>Item</p>`
348 root.render([item, item])
349 assert_eq(el.innerHTML, '<p>Item</p><p>Item</p>')
350
351 root.render([item, item, item])
352 assert_eq(el.innerHTML, '<p>Item</p><p>Item</p><p>Item</p>')
353
354 root.render([item, item])
355 assert_eq(el.innerHTML, '<p>Item</p><p>Item</p>')
356})