From 5da79a4b09fe49e6f2f686732ca5cc4a19259ae0 Mon Sep 17 00:00:00 2001 From: Thomas Stokes Date: Tue, 12 Aug 2025 18:32:37 +0800 Subject: [PATCH] fancy list algorithm --- src/client/parts.ts | 98 +++++++++++-------- src/client/span.ts | 19 ++-- src/client/tests/lists.test.ts | 166 +++++++++++++++++++++++++++++++++ 3 files changed, 237 insertions(+), 46 deletions(-) diff --git a/src/client/parts.ts b/src/client/parts.ts index 8e561a3e..f1643ddb 100644 --- a/src/client/parts.ts +++ b/src/client/parts.ts @@ -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 @@ -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() + 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(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 diff --git a/src/client/span.ts b/src/client/span.ts index 72dcdbb0..2a30da1f 100644 --- a/src/client/span.ts +++ b/src/client/span.ts @@ -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 { diff --git a/src/client/tests/lists.test.ts b/src/client/tests/lists.test.ts index fa0a2c56..54ae7483 100644 --- a/src/client/tests/lists.test.ts +++ b/src/client/tests/lists.test.ts @@ -354,3 +354,169 @@ test('can render the same item multiple times', () => { root.render([item, item]) assert_eq(el.innerHTML, '

Item

Item

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

Item B

`, 'b'), keyed(html`

Item C

`, 'c')] + root.render(items) + assert_eq(el.innerHTML, '

Item B

Item C

') + + const [elemB, elemC] = el.children + + // Insert at beginning + items.unshift(keyed(html`

Item A

`, 'a')) + root.render(items) + assert_eq(el.innerHTML, '

Item A

Item B

Item C

') + + // 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`

Item A

`, 'a'), keyed(html`

Item C

`, 'c')] + root.render(items) + assert_eq(el.innerHTML, '

Item A

Item C

') + + const [elemA, elemC] = el.children + + // Insert in middle + items.splice(1, 0, keyed(html`

Item B

`, 'b')) + root.render(items) + assert_eq(el.innerHTML, '

Item A

Item B

Item C

') + + // 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`

Item A

`, 'a'), keyed(html`

Item B

`, 'b'), keyed(html`

Item C

`, 'c')] + root.render(items) + assert_eq(el.innerHTML, '

Item A

Item B

Item C

') + + const [elemA, , elemC] = el.children + + // Delete middle item + items.splice(1, 1) + root.render(items) + assert_eq(el.innerHTML, '

Item A

Item C

') + + // 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`
Item ${i}
`, 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`
Item ${i}
`, 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`

Keyed A

`, 'a'), + html`

Unkeyed 1

`, + keyed(html`

Keyed B

`, 'b'), + html`

Unkeyed 2

`, + ] + + root.render(items) + assert_eq(el.innerHTML, '

Keyed A

Unkeyed 1

Keyed B

Unkeyed 2

') + + const keyedA = el.children[0] + const keyedB = el.children[2] + + // Reorder: move keyed items but keep unkeyed in new positions + const newItems = [keyed(html`

Keyed B

`, 'b'), html`

New Unkeyed

`, keyed(html`

Keyed A

`, 'a')] + + root.render(newItems) + assert_eq(el.innerHTML, '

Keyed B

New Unkeyed

Keyed A

') + + // 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`

Item 1

`, html`

Item 2

`, html`

Item 3

`] + + root.render(items) + assert_eq(el.innerHTML, '

Item 1

Item 2

Item 3

') + + const [elem1, elem2, elem3] = el.children + + // Reorder unkeyed items (should recreate elements) + items.reverse() + root.render(items) + assert_eq(el.innerHTML, '

Item 3

Item 2

Item 1

') + + // 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`

First A

`, 'a'), keyed(html`

Second A

`, 'a'), keyed(html`

Item B

`, 'b')] + + root.render(items) + const firstA = el.children[0] + + // Reorder with duplicate keys + const newItems = [keyed(html`

Item B

`, 'b'), keyed(html`

First A

`, 'a'), keyed(html`

Third A

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