Skip to content

Commit 13e7332

Browse files
committed
fancy list algorithm
1 parent 972dcdb commit 13e7332

File tree

3 files changed

+237
-55
lines changed

3 files changed

+237
-55
lines changed

src/client/parts.ts

Lines changed: 61 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import {
1818
type CompiledTemplate,
1919
} from './compiler.ts'
2020
import { controllers, get_controller } from './controller.ts'
21-
import { create_span_after, delete_contents, extract_contents, insert_node, type Span } from './span.ts'
21+
import { create_span_after, delete_contents, insert_node, insert_span_before, type Span } from './span.ts'
2222
import type { Cleanup } from './util.ts'
2323

2424
export type Part = (value: unknown) => void
@@ -109,59 +109,83 @@ export function create_child_part(
109109
// given it can yield different values but have the same identity. (e.g. arrays)
110110
if (is_iterable(value)) {
111111
if (!entries) {
112-
// we previously rendered a single value, so we need to clear it.
113112
disconnect_root()
114113
delete_contents(span)
115114
entries = []
116115
}
117116

118-
// create or update a root for every item.
119-
let i = 0
120-
let end = span._start
117+
const items: Array<{ _key: Key; _item: Displayable }> = []
121118
for (const item of value) {
122119
const key = is_keyed(item) ? item._key : (item as Key)
123-
if (entries.length <= i) {
124-
const span = create_span_after(end)
125-
entries[i] = { _span: span, _part: create_child_part(span), _key: key }
126-
}
127-
128-
if (key !== undefined && entries[i]._key !== key) {
129-
for (let j = i + 1; j < entries.length; j++) {
130-
const entry1 = entries[i]
131-
const entry2 = entries[j]
120+
items.push({ _key: key, _item: item as Displayable })
121+
}
132122

133-
if (entry2._key === key) {
134-
// swap the contents of the spans
135-
const tmp_content = extract_contents(entry1._span)
136-
insert_node(entry1._span, extract_contents(entry2._span))
137-
insert_node(entry2._span, tmp_content)
123+
const old_index_by_key = new Map<Key, number[]>()
124+
for (let i = 0; i < entries.length; i++) {
125+
const key = entries[i]._key
126+
if (key !== undefined) {
127+
const indices = old_index_by_key.get(key)
128+
if (indices) indices.push(i)
129+
else old_index_by_key.set(key, [i])
130+
}
131+
}
138132

139-
// swap the spans back
140-
const tmp_span = { ...entry1._span }
141-
Object.assign(entry1._span, entry2._span)
142-
Object.assign(entry2._span, tmp_span)
133+
type Entry = { _span: Span; _part: Part; _key: Key }
134+
const new_entries: Entry[] = new Array(items.length)
135+
const source_index: number[] = new Array(items.length).fill(-1)
136+
137+
for (let i = 0; i < items.length; i++) {
138+
const { _key: key } = items[i]
139+
const arr = old_index_by_key.get(key)
140+
if (key !== undefined && arr?.length) {
141+
const j = arr.shift()!
142+
new_entries[i] = entries[j]
143+
source_index[i] = j
144+
}
145+
}
143146

144-
// swap the roots
145-
entries[j] = entry1
146-
entries[i] = entry2
147+
const positions: number[] = []
148+
const predecessors: number[] = new Array(items.length).fill(-1)
149+
for (let i = 0; i < items.length; i++) {
150+
const v = source_index[i]
151+
if (v === -1) continue // skip new items
152+
let lo = 0
153+
let hi = positions.length
154+
while (lo < hi) {
155+
const mid = (lo + hi) >> 1
156+
if (source_index[positions[mid]] < v) lo = mid + 1
157+
else hi = mid
158+
}
159+
if (lo > 0) predecessors[i] = positions[lo - 1]
160+
if (lo === positions.length) positions.push(i)
161+
else positions[lo] = i
162+
}
147163

148-
break
149-
}
150-
}
164+
const keep = new Set<Entry>(new_entries)
165+
const to_remove = entries.filter(entry => !keep.has(entry))
151166

152-
entries[i]._key = key
167+
for (let i = items.length - 1; i >= 0; i--) {
168+
const anchor: Node = i + 1 < items.length ? new_entries[i + 1]._span._start : span._end
169+
if (source_index[i] === -1) {
170+
const s = create_span_after(anchor.previousSibling!)
171+
new_entries[i] = { _span: s, _part: create_child_part(s), _key: items[i]._key }
172+
} else {
173+
insert_span_before(new_entries[i]._span, anchor)
153174
}
175+
}
154176

155-
entries[i]._part(item as Displayable)
156-
end = entries[i]._span._end
157-
i++
177+
entries = new_entries
178+
for (let i = 0; i < new_entries.length; i++) {
179+
const { _item: item, _key: key } = items[i]
180+
const entry = new_entries[i]
181+
entry._key = key
182+
// only render new items
183+
if (source_index[i] === -1) entry._part(item)
158184
}
159185

160-
// and now remove excess parts if the iterable has shrunk.
161-
while (entries.length > i) {
162-
const entry = entries.pop()
163-
assert(entry)
186+
for (const entry of to_remove) {
164187
entry._part(null)
188+
delete_contents(entry._span)
165189
}
166190

167191
old_value = undefined

src/client/span.ts

Lines changed: 10 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,6 @@ export interface Span {
55
readonly _end: Node
66
}
77

8-
export function create_span(node: Node): Span {
9-
assert(node.parentNode !== null)
10-
11-
return {
12-
_start: node.parentNode.insertBefore(new Text(), node),
13-
_end: node.parentNode.insertBefore(new Text(), node.nextSibling),
14-
}
15-
}
16-
178
export function create_span_into(parent: Node): Span {
189
return { _start: parent.appendChild(new Text()), _end: parent.appendChild(new Text()) }
1910
}
@@ -31,19 +22,20 @@ export function insert_node(span: Span, node: Node): void {
3122
span._end.parentNode!.insertBefore(node, span._end)
3223
}
3324

34-
export function extract_contents(span: Span): DocumentFragment {
35-
const fragment = document.createDocumentFragment()
25+
export function insert_span_before(span: Span, before: Node): void {
26+
const parent = before.parentNode
27+
assert(parent)
28+
const after = span._end.nextSibling
3629

37-
let node = span._start.nextSibling
38-
for (;;) {
39-
assert(node)
40-
if (node === span._end) break
30+
if (after === before) return
31+
32+
let node = span._start
33+
while (node !== after) {
4134
const next = node.nextSibling
42-
fragment.appendChild(node)
35+
assert(next)
36+
parent.insertBefore(node, before)
4337
node = next
4438
}
45-
46-
return fragment
4739
}
4840

4941
export function delete_contents(span: Span): void {

src/client/tests/lists.test.ts

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,3 +354,169 @@ test('can render the same item multiple times', () => {
354354
root.render([item, item])
355355
assert_eq(el.innerHTML, '<p>Item</p><p>Item</p>')
356356
})
357+
358+
test('keyed list insertion at beginning preserves existing elements', () => {
359+
const { root, el } = setup()
360+
361+
const items = [keyed(html`<p>Item B</p>`, 'b'), keyed(html`<p>Item C</p>`, 'c')]
362+
root.render(items)
363+
assert_eq(el.innerHTML, '<p>Item B</p><p>Item C</p>')
364+
365+
const [elemB, elemC] = el.children
366+
367+
// Insert at beginning
368+
items.unshift(keyed(html`<p>Item A</p>`, 'a'))
369+
root.render(items)
370+
assert_eq(el.innerHTML, '<p>Item A</p><p>Item B</p><p>Item C</p>')
371+
372+
// Existing elements should be preserved
373+
assert_eq(el.children[1], elemB)
374+
assert_eq(el.children[2], elemC)
375+
})
376+
377+
test('keyed list insertion at middle preserves existing elements', () => {
378+
const { root, el } = setup()
379+
380+
const items = [keyed(html`<p>Item A</p>`, 'a'), keyed(html`<p>Item C</p>`, 'c')]
381+
root.render(items)
382+
assert_eq(el.innerHTML, '<p>Item A</p><p>Item C</p>')
383+
384+
const [elemA, elemC] = el.children
385+
386+
// Insert in middle
387+
items.splice(1, 0, keyed(html`<p>Item B</p>`, 'b'))
388+
root.render(items)
389+
assert_eq(el.innerHTML, '<p>Item A</p><p>Item B</p><p>Item C</p>')
390+
391+
// Existing elements should be preserved
392+
assert_eq(el.children[0], elemA)
393+
assert_eq(el.children[2], elemC)
394+
})
395+
396+
test('keyed list deletion preserves remaining elements', () => {
397+
const { root, el } = setup()
398+
399+
const items = [keyed(html`<p>Item A</p>`, 'a'), keyed(html`<p>Item B</p>`, 'b'), keyed(html`<p>Item C</p>`, 'c')]
400+
root.render(items)
401+
assert_eq(el.innerHTML, '<p>Item A</p><p>Item B</p><p>Item C</p>')
402+
403+
const [elemA, , elemC] = el.children
404+
405+
// Delete middle item
406+
items.splice(1, 1)
407+
root.render(items)
408+
assert_eq(el.innerHTML, '<p>Item A</p><p>Item C</p>')
409+
410+
// Remaining elements should be preserved
411+
assert_eq(el.children[0], elemA)
412+
assert_eq(el.children[1], elemC)
413+
})
414+
415+
test('large keyed list reordering minimizes DOM moves', () => {
416+
const { root, el } = setup()
417+
418+
// Create 20 items in order
419+
const items = Array.from({ length: 20 }, (_, i) => keyed(html`<div>Item ${i}</div>`, i))
420+
421+
root.render(items)
422+
const elements = [...el.children]
423+
424+
// Move only item 0 to the end (should be minimal moves due to LIS)
425+
const first = items.shift()!
426+
items.push(first)
427+
root.render(items)
428+
429+
// Elements 1-19 should stay in place (part of LIS)
430+
for (let i = 1; i < 20; i++) {
431+
assert_eq(el.children[i - 1], elements[i])
432+
}
433+
// Item 0 should be at the end
434+
assert_eq(el.children[19], elements[0])
435+
})
436+
437+
test('reverse large keyed list preserves all elements', () => {
438+
const { root, el } = setup()
439+
440+
// Create 10 items
441+
const items = Array.from({ length: 10 }, (_, i) => keyed(html`<div>Item ${i}</div>`, i))
442+
443+
root.render(items)
444+
const elements = [...el.children]
445+
446+
// Reverse the array
447+
items.reverse()
448+
root.render(items)
449+
450+
// All elements should be preserved, just reordered
451+
for (let i = 0; i < 10; i++) {
452+
assert_eq(el.children[i], elements[9 - i])
453+
}
454+
})
455+
456+
test('mixed keyed and unkeyed items work correctly', () => {
457+
const { root, el } = setup()
458+
459+
// Mix keyed and unkeyed items
460+
const items = [
461+
keyed(html`<p>Keyed A</p>`, 'a'),
462+
html`<p>Unkeyed 1</p>`,
463+
keyed(html`<p>Keyed B</p>`, 'b'),
464+
html`<p>Unkeyed 2</p>`,
465+
]
466+
467+
root.render(items)
468+
assert_eq(el.innerHTML, '<p>Keyed A</p><p>Unkeyed 1</p><p>Keyed B</p><p>Unkeyed 2</p>')
469+
470+
const keyedA = el.children[0]
471+
const keyedB = el.children[2]
472+
473+
// Reorder: move keyed items but keep unkeyed in new positions
474+
const newItems = [keyed(html`<p>Keyed B</p>`, 'b'), html`<p>New Unkeyed</p>`, keyed(html`<p>Keyed A</p>`, 'a')]
475+
476+
root.render(newItems)
477+
assert_eq(el.innerHTML, '<p>Keyed B</p><p>New Unkeyed</p><p>Keyed A</p>')
478+
479+
// Keyed elements should be preserved
480+
assert_eq(el.children[0], keyedB)
481+
assert_eq(el.children[2], keyedA)
482+
})
483+
484+
test('reversing a list keeps identity', () => {
485+
const { root, el } = setup()
486+
487+
// All unkeyed items
488+
const items = [html`<p>Item 1</p>`, html`<p>Item 2</p>`, html`<p>Item 3</p>`]
489+
490+
root.render(items)
491+
assert_eq(el.innerHTML, '<p>Item 1</p><p>Item 2</p><p>Item 3</p>')
492+
493+
const [elem1, elem2, elem3] = el.children
494+
495+
// Reorder unkeyed items (should recreate elements)
496+
items.reverse()
497+
root.render(items)
498+
assert_eq(el.innerHTML, '<p>Item 3</p><p>Item 2</p><p>Item 1</p>')
499+
500+
// Elements should be moved
501+
assert(el.children[0] === elem3)
502+
assert(el.children[1] === elem2)
503+
assert(el.children[2] === elem1)
504+
})
505+
506+
test('keyed list with duplicate keys handles gracefully', () => {
507+
const { root, el } = setup()
508+
509+
// First occurrence of duplicate key should win
510+
const items = [keyed(html`<p>First A</p>`, 'a'), keyed(html`<p>Second A</p>`, 'a'), keyed(html`<p>Item B</p>`, 'b')]
511+
512+
root.render(items)
513+
const firstA = el.children[0]
514+
515+
// Reorder with duplicate keys
516+
const newItems = [keyed(html`<p>Item B</p>`, 'b'), keyed(html`<p>First A</p>`, 'a'), keyed(html`<p>Third A</p>`, 'a')]
517+
518+
root.render(newItems)
519+
520+
// First keyed 'a' element should be preserved and moved
521+
assert_eq(el.children[1], firstA)
522+
})

0 commit comments

Comments
 (0)