import { describe, it, expect, vi } from 'vitest' import { createRoot } from '../lib/vdom.ts' import { invariant } from '../lib/invariant.ts' import { Fragment } from '../lib/component.ts' describe('vnode rendering (keys)', () => { describe('keyed list with non-keyed sibling', () => { it('appends keyed component before non-keyed sibling', () => { let container = document.createElement('div') let root = createRoot(container) type CardData = { id: string; title: string } function Card() { return ({ card }: { card: CardData }) =>
{card.title}
} function Column() { return ({ cards, isAddingCard }: { cards: CardData[]; isAddingCard: boolean }) => (
{cards.map((card) => ( ))} {isAddingCard ?
Form
: }
) } // Initial: 2 cards, button visible let cards: CardData[] = [ { id: '1', title: 'Card 1' }, { id: '2', title: 'Card 2' }, ] root.render() let col = container.querySelector('div') invariant(col) expect(col.innerHTML).toBe( '
Card 1
Card 2
', ) // Click "Add" - form appears root.render() expect(col.innerHTML).toBe( '
Card 1
Card 2
Form
', ) // Add a new card while form is visible cards = [...cards, { id: '3', title: 'Card 3' }] root.render() // Regression: The new card must appear BEFORE the form. expect(col.innerHTML).toBe( '
Card 1
Card 2
Card 3
Form
', ) }) }) describe('basic keyed list operations', () => { it('handles prepending items with keys', () => { let container = document.createElement('div') let root = createRoot(container) function List() { return ({ values }: { values: (string | number)[] }) => (
    {values.map((value) => (
  • {value}
  • ))}
) } let values: (string | number)[] = ['b', 'c'] root.render() expect(container.textContent).toBe('bc') values = ['a', ...values] root.render() expect(container.textContent).toBe('abc') let items = Array.from(container.querySelectorAll('li')) expect(items.map((el) => el.getAttribute('data-id'))).toEqual(['a', 'b', 'c']) }) it('handles appending items with keys', () => { let container = document.createElement('div') let root = createRoot(container) function List() { return ({ values }: { values: string[] }) => (
    {values.map((value) => (
  1. {value}
  2. ))}
) } let values = ['a', 'b'] root.render() expect(container.textContent).toBe('ab') let a = container.querySelector('[data-id="a"]') let b = container.querySelector('[data-id="b"]') expect(a).toBeInstanceOf(HTMLLIElement) expect(b).toBeInstanceOf(HTMLLIElement) values = [...values, 'c'] root.render() expect(container.textContent).toBe('abc') let items = Array.from(container.querySelectorAll('li')) expect(items[0]).toBe(a) expect(items[1]).toBe(b) expect(items[2].getAttribute('data-id')).toBe('c') }) it('handles removing items with keys', () => { let container = document.createElement('div') let root = createRoot(container) function List() { return ({ values }: { values: string[] }) => (
    {values.map((value) => (
  • {value}
  • ))}
) } let values = ['a', 'b', 'c', 'd'] root.render() expect(container.textContent).toBe('abcd') let a = container.querySelector('[data-id="a"]') let d = container.querySelector('[data-id="d"]') expect(a).toBeInstanceOf(HTMLLIElement) expect(d).toBeInstanceOf(HTMLLIElement) values = ['a', 'c', 'd'] root.render() expect(container.textContent).toBe('acd') let items = Array.from(container.querySelectorAll('li')) expect(items[0]).toBe(a) expect(items[1].getAttribute('data-id')).toBe('c') expect(items[2]).toBe(d) expect(container.querySelector('[data-id="b"]')).toBe(null) }) it('handles inserting items with keys', () => { let container = document.createElement('div') let root = createRoot(container) function List() { return ({ values }: { values: string[] }) => (
    {values.map((value) => (
  • {value}
  • ))}
) } let values = ['a', 'c'] root.render() expect(container.textContent).toBe('ac') let a = container.querySelector('[data-id="a"]') let c = container.querySelector('[data-id="c"]') expect(a).toBeInstanceOf(HTMLLIElement) expect(c).toBeInstanceOf(HTMLLIElement) values = ['a', 'b', 'c'] root.render() expect(container.textContent).toBe('abc') let items = Array.from(container.querySelectorAll('li')) expect(items[0]).toBe(a) expect(items[1].getAttribute('data-id')).toBe('b') expect(items[2]).toBe(c) }) it('handles swapping adjacent items with keys', () => { let container = document.createElement('div') let root = createRoot(container) function List() { return ({ values }: { values: string[] }) => (
    {values.map((value) => (
  • {value}
  • ))}
) } let values = ['a', 'b'] root.render() expect(container.textContent).toBe('ab') let a = container.querySelector('[data-id="a"]') let b = container.querySelector('[data-id="b"]') expect(a).toBeInstanceOf(HTMLLIElement) expect(b).toBeInstanceOf(HTMLLIElement) values = ['b', 'a'] root.render() expect(container.textContent).toBe('ba') let items = Array.from(container.querySelectorAll('li')) expect(items[0]).toBe(b) expect(items[1]).toBe(a) }) it('handles reversing list order with keys', () => { let container = document.createElement('div') let root = createRoot(container) function List() { return ({ values }: { values: string[] }) => (
    {values.map((value) => (
  • {value}
  • ))}
) } let values = ['a', 'b', 'c', 'd'] root.render() expect(container.textContent).toBe('abcd') let nodes = values.map((value) => { let el = container.querySelector(`[data-id="${value}"]`) invariant(el instanceof HTMLLIElement) return el }) values = [...values].reverse() root.render() expect(container.textContent).toBe('dcba') let items = Array.from(container.querySelectorAll('li')) expect(items[0]).toBe(nodes[3]) expect(items[1]).toBe(nodes[2]) expect(items[2]).toBe(nodes[1]) expect(items[3]).toBe(nodes[0]) }) it('handles complex reordering with keys', () => { let container = document.createElement('div') let root = createRoot(container) function List() { return ({ values }: { values: string[] }) => (
    {values.map((value) => (
  • {value}
  • ))}
) } let values = ['a', 'b', 'c', 'd', 'e', 'f'] root.render() expect(container.textContent).toBe('abcdef') let nodes = values.map((value) => { let el = container.querySelector(`[data-id="${value}"]`) invariant(el instanceof HTMLLIElement) return el }) // move e to near the front, and c towards the end values = ['a', 'e', 'b', 'f', 'c', 'd'] root.render() expect(container.textContent).toBe('aebfcd') let items = Array.from(container.querySelectorAll('li')) expect(items[0]).toBe(nodes[0]) // a expect(items[1]).toBe(nodes[4]) // e expect(items[2]).toBe(nodes[1]) // b expect(items[3]).toBe(nodes[5]) // f expect(items[4]).toBe(nodes[2]) // c expect(items[5]).toBe(nodes[3]) // d }) }) describe('key semantics', () => { it('replaces nodes when keys match but type differs', () => { let container = document.createElement('div') let root = createRoot(container) root.render(
X
, ) let first = container.querySelector('#x') invariant(first instanceof HTMLSpanElement) expect(container.innerHTML).toBe('
X
') root.render(

Y

, ) let second = container.querySelector('#x') invariant(second instanceof HTMLParagraphElement) expect(container.innerHTML).toBe('

Y

') expect(second).not.toBe(first) }) it('handles mixed keyed and unkeyed children', () => { let container = document.createElement('div') let root = createRoot(container) function Item() { return ({ label }: { label: string }) =>
  • {label}
  • } root.render(
    , ) expect(container.textContent).toBe('Aunkeyed-1Bunkeyed-2') root.render(
      {/* swap keyed items and insert another unkeyed between them */}
    , ) expect(container.textContent).toBe('unkeyed-1Bunkeyed-2A') }) it('handles duplicate keys (last one wins)', () => { let container = document.createElement('div') let root = createRoot(container) let warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) function List() { return ({ labels }: { labels: string[] }) => (
      {labels.map((label, index) => (
    • {label}
    • ))}
    ) } try { root.render() expect(container.textContent).toBe('firstsecond') root.render() expect(container.textContent).toBe('only') let items = Array.from(container.querySelectorAll('li')) expect(items.length).toBe(1) expect(items[0].getAttribute('data-index')).toBe('0') expect(warnSpy).toHaveBeenCalledTimes(1) let warning = String(warnSpy.mock.calls[0]?.[0] ?? '') expect(warning).toContain('Duplicate keys detected in siblings') expect(warning).toContain('"dup"') } finally { warnSpy.mockRestore() } }) it('allows any type to be a key', () => { let container = document.createElement('div') let root = createRoot(container) let objKey = {} let symKey = Symbol('k') root.render(
    • one
    • two
    • obj
    • sym
    , ) expect(container.textContent).toBe('onetwoobjsym') root.render(
    • sym*
    • one*
    • obj*
    • two*
    , ) expect(container.textContent).toBe('sym*one*obj*two*') }) it('reorders keyed fragments correctly (moves entire DOM range)', () => { let container = document.createElement('div') let root = createRoot(container) // Each keyed item renders multiple DOM nodes via fragment root.render(
    {['a', 'b', 'c'].map((id) => ( {id} ))}
    , ) expect(container.innerHTML).toBe( '
    abc
    ', ) // Reverse order - entire fragment ranges should move together root.render(
    {['c', 'b', 'a'].map((id) => ( {id} ))}
    , ) expect(container.innerHTML).toBe( '
    cba
    ', ) // Verify DOM nodes were reused, not recreated let spans = container.querySelectorAll('span') let buttons = container.querySelectorAll('button') expect(spans.length).toBe(3) expect(buttons.length).toBe(3) }) it('handles keys in fragments without breaking updates', () => { let container = document.createElement('div') let root = createRoot(container) function Item() { return ({ id, label }: { id: string; label: string }) => ( <> {label} ) } root.render(
    , ) expect(container.textContent).toBe('AclickBclick') // Swap order of items – inner keys should not cause errors and // both items should still render correctly. root.render(
    , ) // Current implementation keeps the DOM order of the two fragment // sections stable for unkeyed components, but updates their // content based on props. expect( container.textContent === 'BclickAclick' || container.textContent === 'AclickBclick', ).toBe(true) let labels = Array.from(container.querySelectorAll('span')) let buttons = Array.from(container.querySelectorAll('button')) expect(labels.length).toBe(2) expect(buttons.length).toBe(2) }) }) })