Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 61 additions & 37 deletions src/client/parts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import {
type CompiledTemplate,
} from './compiler.ts'
import { controllers, get_controller } from './controller.ts'
import { create_span_after, delete_contents, extract_contents, insert_node, type Span } from './span.ts'
import { create_span_after, delete_contents, insert_node, insert_span_before, type Span } from './span.ts'
import type { Cleanup } from './util.ts'

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

// create or update a root for every item.
let i = 0
let end = span._start
const items: Array<{ _key: Key; _item: Displayable }> = []
for (const item of value) {
const key = is_keyed(item) ? item._key : (item as Key)
if (entries.length <= i) {
const span = create_span_after(end)
entries[i] = { _span: span, _part: create_child_part(span), _key: key }
}

if (key !== undefined && entries[i]._key !== key) {
for (let j = i + 1; j < entries.length; j++) {
const entry1 = entries[i]
const entry2 = entries[j]
items.push({ _key: key, _item: item as Displayable })
}

if (entry2._key === key) {
// swap the contents of the spans
const tmp_content = extract_contents(entry1._span)
insert_node(entry1._span, extract_contents(entry2._span))
insert_node(entry2._span, tmp_content)
const old_index_by_key = new Map<Key, number[]>()
for (let i = 0; i < entries.length; i++) {
const key = entries[i]._key
if (key !== undefined) {
const indices = old_index_by_key.get(key)
if (indices) indices.push(i)
else old_index_by_key.set(key, [i])
}
}

// swap the spans back
const tmp_span = { ...entry1._span }
Object.assign(entry1._span, entry2._span)
Object.assign(entry2._span, tmp_span)
type Entry = { _span: Span; _part: Part; _key: Key }
const new_entries: Entry[] = new Array(items.length)
const source_index: number[] = new Array(items.length).fill(-1)

for (let i = 0; i < items.length; i++) {
const { _key: key } = items[i]
const arr = old_index_by_key.get(key)
if (key !== undefined && arr?.length) {
const j = arr.shift()!
new_entries[i] = entries[j]
source_index[i] = j
}
}

// swap the roots
entries[j] = entry1
entries[i] = entry2
const positions: number[] = []
const predecessors: number[] = new Array(items.length).fill(-1)
for (let i = 0; i < items.length; i++) {
const v = source_index[i]
if (v === -1) continue // skip new items
let lo = 0
let hi = positions.length
while (lo < hi) {
const mid = (lo + hi) >> 1
if (source_index[positions[mid]] < v) lo = mid + 1
else hi = mid
}
if (lo > 0) predecessors[i] = positions[lo - 1]
if (lo === positions.length) positions.push(i)
else positions[lo] = i
}

break
}
}
const keep = new Set<Entry>(new_entries)
const to_remove = entries.filter(entry => !keep.has(entry))

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

entries[i]._part(item as Displayable)
end = entries[i]._span._end
i++
entries = new_entries
for (let i = 0; i < new_entries.length; i++) {
const { _item: item, _key: key } = items[i]
const entry = new_entries[i]
entry._key = key
// only render new items
if (source_index[i] === -1) entry._part(item)
}

// and now remove excess parts if the iterable has shrunk.
while (entries.length > i) {
const entry = entries.pop()
assert(entry)
for (const entry of to_remove) {
entry._part(null)
delete_contents(entry._span)
}

old_value = undefined
Expand Down
19 changes: 10 additions & 9 deletions src/client/span.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,19 +22,20 @@ export function insert_node(span: Span, node: Node): void {
span._end.parentNode!.insertBefore(node, span._end)
}

export function extract_contents(span: Span): DocumentFragment {
const fragment = document.createDocumentFragment()
export function insert_span_before(span: Span, before: Node): void {
const parent = before.parentNode
assert(parent)
const after = span._end.nextSibling

let node = span._start.nextSibling
for (;;) {
assert(node)
if (node === span._end) break
if (after === before) return

let node = span._start
while (node !== after) {
const next = node.nextSibling
fragment.appendChild(node)
assert(next)
parent.insertBefore(node, before)
node = next
}

return fragment
}

export function delete_contents(span: Span): void {
Expand Down
166 changes: 166 additions & 0 deletions src/client/tests/lists.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -354,3 +354,169 @@ test('can render the same item multiple times', () => {
root.render([item, item])
assert_eq(el.innerHTML, '<p>Item</p><p>Item</p>')
})

test('keyed list insertion at beginning preserves existing elements', () => {
const { root, el } = setup()

const items = [keyed(html`<p>Item B</p>`, 'b'), keyed(html`<p>Item C</p>`, 'c')]
root.render(items)
assert_eq(el.innerHTML, '<p>Item B</p><p>Item C</p>')

const [elemB, elemC] = el.children

// Insert at beginning
items.unshift(keyed(html`<p>Item A</p>`, 'a'))
root.render(items)
assert_eq(el.innerHTML, '<p>Item A</p><p>Item B</p><p>Item C</p>')

// Existing elements should be preserved
assert_eq(el.children[1], elemB)
assert_eq(el.children[2], elemC)
})

test('keyed list insertion at middle preserves existing elements', () => {
const { root, el } = setup()

const items = [keyed(html`<p>Item A</p>`, 'a'), keyed(html`<p>Item C</p>`, 'c')]
root.render(items)
assert_eq(el.innerHTML, '<p>Item A</p><p>Item C</p>')

const [elemA, elemC] = el.children

// Insert in middle
items.splice(1, 0, keyed(html`<p>Item B</p>`, 'b'))
root.render(items)
assert_eq(el.innerHTML, '<p>Item A</p><p>Item B</p><p>Item C</p>')

// Existing elements should be preserved
assert_eq(el.children[0], elemA)
assert_eq(el.children[2], elemC)
})

test('keyed list deletion preserves remaining elements', () => {
const { root, el } = setup()

const items = [keyed(html`<p>Item A</p>`, 'a'), keyed(html`<p>Item B</p>`, 'b'), keyed(html`<p>Item C</p>`, 'c')]
root.render(items)
assert_eq(el.innerHTML, '<p>Item A</p><p>Item B</p><p>Item C</p>')

const [elemA, , elemC] = el.children

// Delete middle item
items.splice(1, 1)
root.render(items)
assert_eq(el.innerHTML, '<p>Item A</p><p>Item C</p>')

// Remaining elements should be preserved
assert_eq(el.children[0], elemA)
assert_eq(el.children[1], elemC)
})

test('large keyed list reordering minimizes DOM moves', () => {
const { root, el } = setup()

// Create 20 items in order
const items = Array.from({ length: 20 }, (_, i) => keyed(html`<div>Item ${i}</div>`, i))

root.render(items)
const elements = [...el.children]

// Move only item 0 to the end (should be minimal moves due to LIS)
const first = items.shift()!
items.push(first)
root.render(items)

// Elements 1-19 should stay in place (part of LIS)
for (let i = 1; i < 20; i++) {
assert_eq(el.children[i - 1], elements[i])
}
// Item 0 should be at the end
assert_eq(el.children[19], elements[0])
})

test('reverse large keyed list preserves all elements', () => {
const { root, el } = setup()

// Create 10 items
const items = Array.from({ length: 10 }, (_, i) => keyed(html`<div>Item ${i}</div>`, i))

root.render(items)
const elements = [...el.children]

// Reverse the array
items.reverse()
root.render(items)

// All elements should be preserved, just reordered
for (let i = 0; i < 10; i++) {
assert_eq(el.children[i], elements[9 - i])
}
})

test('mixed keyed and unkeyed items work correctly', () => {
const { root, el } = setup()

// Mix keyed and unkeyed items
const items = [
keyed(html`<p>Keyed A</p>`, 'a'),
html`<p>Unkeyed 1</p>`,
keyed(html`<p>Keyed B</p>`, 'b'),
html`<p>Unkeyed 2</p>`,
]

root.render(items)
assert_eq(el.innerHTML, '<p>Keyed A</p><p>Unkeyed 1</p><p>Keyed B</p><p>Unkeyed 2</p>')

const keyedA = el.children[0]
const keyedB = el.children[2]

// Reorder: move keyed items but keep unkeyed in new positions
const newItems = [keyed(html`<p>Keyed B</p>`, 'b'), html`<p>New Unkeyed</p>`, keyed(html`<p>Keyed A</p>`, 'a')]

root.render(newItems)
assert_eq(el.innerHTML, '<p>Keyed B</p><p>New Unkeyed</p><p>Keyed A</p>')

// Keyed elements should be preserved
assert_eq(el.children[0], keyedB)
assert_eq(el.children[2], keyedA)
})

test('reversing a list keeps identity', () => {
const { root, el } = setup()

// All unkeyed items
const items = [html`<p>Item 1</p>`, html`<p>Item 2</p>`, html`<p>Item 3</p>`]

root.render(items)
assert_eq(el.innerHTML, '<p>Item 1</p><p>Item 2</p><p>Item 3</p>')

const [elem1, elem2, elem3] = el.children

// Reorder unkeyed items (should recreate elements)
items.reverse()
root.render(items)
assert_eq(el.innerHTML, '<p>Item 3</p><p>Item 2</p><p>Item 1</p>')

// Elements should be moved
assert(el.children[0] === elem3)
assert(el.children[1] === elem2)
assert(el.children[2] === elem1)
})

test('keyed list with duplicate keys handles gracefully', () => {
const { root, el } = setup()

// First occurrence of duplicate key should win
const items = [keyed(html`<p>First A</p>`, 'a'), keyed(html`<p>Second A</p>`, 'a'), keyed(html`<p>Item B</p>`, 'b')]

root.render(items)
const firstA = el.children[0]

// Reorder with duplicate keys
const newItems = [keyed(html`<p>Item B</p>`, 'b'), keyed(html`<p>First A</p>`, 'a'), keyed(html`<p>Third A</p>`, 'a')]

root.render(newItems)

// First keyed 'a' element should be preserved and moved
assert_eq(el.children[1], firstA)
})