import { describe, it, expect } from 'vitest'; import { h, Fragment, render, mount, mountCallback, nextTick } from '@/index'; /** * Cases found by porting edge cases from the preact, inferno and React * suites (keys, placeholders, vnode reuse, sibling DOM changes) and by * core-renderFuzz with hoisted nodes, stateful wrappers and batched renews. */ const flush = async () => { for (let i = 0; i < 3; i++) await nextTick(); }; /** A component that records its mounts and unmounts. */ const tracked = (log: string[], name: string) => mount<{ label?: string }>((_renew, props) => { mountCallback(() => { log.push(`mount ${name}`); return () => log.push(`unmount ${name}`); }); return () => {props.label || name}; }); describe('keys', () => { it('keeps keys when the first item of a list is null', async () => { // `rows.map(r => r.hidden ? null :
  • )`: a leading null used // to switch key matching off, so items were matched by position. let order = ['a', 'b', 'c']; let bump = () => {}; const List = mount(renew => { bump = renew; return () => ; }); const el = document.createElement('div'); render(, el); const [a, b, c] = Array.from(el.querySelectorAll('li')); order = ['c', 'a', 'b']; bump(); await flush(); expect(el.innerHTML).toBe(''); expect(Array.from(el.querySelectorAll('li'))).toEqual([c, a, b]); }); it('keeps unkeyed items among keyed ones in place', async () => { // Every unkeyed item shared the key `undefined`, so all but the first // were drawn again on every render. let bump = () => {}; const List = mount(renew => { bump = renew; return () => ( ); }); const el = document.createElement('div'); render(, el); const before = Array.from(el.querySelectorAll('li')); bump(); await flush(); expect(Array.from(el.querySelectorAll('li'))).toEqual(before); }); it('moves an unkeyed item matched by position along with a keyed reorder', async () => { let swapped = false; let bump = () => {}; const List = mount(renew => { bump = renew; return () => (
      {swapped ? [

      p

      , s, null] : [null, s,

      p

      ]}
    ); }); const el = document.createElement('div'); render(, el); swapped = true; bump(); await flush(); expect(el.innerHTML).toBe('

      p

      s
    '); }); it('keeps an input focused while its keyed row redraws itself', async () => { // The row's nodes used to be taken out and put back on every redraw of // its own, which blurs the input (and reloads an iframe). const type: Record void> = {}; const Row = mount<{ id: string; key?: string }>((renew, props) => { let value = ''; type[props.id] = () => { value += 'x'; renew(); }; return () => (
  • {value}
  • ); }); const el = document.createElement('div'); document.body.appendChild(el); render( , el ); const input = el.querySelectorAll('input')[1]; input.focus(); const observer = new MutationObserver(() => {}); observer.observe(el, { subtree: true, childList: true }); type.b(); await flush(); const moved = observer .takeRecords() .filter(record => record.target.nodeName === 'UL'); expect(document.activeElement).toBe(input); expect(moved).toEqual([]); expect(el.querySelectorAll('li')[1].textContent).toBe('x'); el.remove(); }); it('places a kept row whose content changed while the list is reordered', async () => { // Sorting while a row expands: the row's new content was anchored to a // sibling that had not been moved yet. let sorted = false; let bump = () => {}; const Row = mount<{ open: boolean; label: string; key?: string }>( (_r, props) => () => props.open ? ( {props.label} details ) : ( {props.label} ) ); const List = mount(renew => { bump = renew; return () => ( ); }); const el = document.createElement('div'); render(, el); sorted = true; bump(); await flush(); expect(el.innerHTML).toBe(''); }); }); describe('children', () => { it('renders nothing for true, like false', () => { const el = document.createElement('div'); render(
    {true} {0}
    , el ); expect(el.innerHTML).toBe('
    0
    '); }); it('accepts true from h and from a component', () => { const Empty = mount(() => () => true); const el = document.createElement('div'); const destroy = render(h('div', {}, true, , 0), el); expect(el.innerHTML).toBe('
    0
    '); destroy(); }); }); describe('redraw scheduling', () => { it('draws a renew raised for a component already drawn in this flush', async () => { // A new child's mountCallback renews its parent while the parent's own // redraw is being flushed; that renew used to be dropped. let show = false; let reported = 0; let bump = () => {}; const Child = mount<{ report: () => void }>((_r, props) => { mountCallback(() => props.report()); return () => child; }); const Parent = mount(renew => { bump = renew; const report = () => { reported++; renew(); }; return () => (
    {show ? : null} {reported}
    ); }); const el = document.createElement('div'); render(, el); show = true; bump(); await flush(); expect(el.innerHTML).toBe('
    child1
    '); }); }); describe('reused nodes', () => { it('shows a hoisted empty state again after it was replaced', async () => { // Replacing a node used to empty its children, so the constant came // back as an empty

    . const empty =

    No items

    ; let items: string[] = []; let bump = () => {}; const List = mount(renew => { bump = renew; return () => items.length ? (
      {items.map(i => (
    • {i}
    • ))}
    ) : ( empty ); }); const el = document.createElement('div'); render(, el); for (const next of [['a'], [], ['a', 'b'], []]) { items = next; bump(); await flush(); expect(el.innerHTML).toBe( next.length ? `
      ${next.map(i => `
    • ${i}
    • `).join('')}
    ` : '

    No items

    ' ); } }); it('draws one element node in several places', async () => { // A node keeps the state of the place it is drawn in, so a second place // overwrote it and later updates of the first one hit the wrong DOM. const icon = icon; const toggles: (() => void)[] = []; const Row = mount(renew => { let on = true; toggles.push(() => { on = !on; renew(); }); return () => {on ? icon : 'off'}; }); const el = document.createElement('div'); render(
    {icon}
    , el ); expect(el.innerHTML).toBe( '
    iconiconicon
    ' ); toggles[0](); await flush(); expect(el.innerHTML).toBe( '
    officonicon
    ' ); toggles[1](); toggles[0](); await flush(); expect(el.innerHTML).toBe( '
    iconofficon
    ' ); }); it('gives a hoisted component one instance per place', async () => { const log: string[] = []; const Icon = tracked(log, 'icon'); const hoisted = ; let both = true; let bump = () => {}; const App = mount(renew => { bump = renew; return () => (
    {hoisted} {both ? hoisted : null}
    ); }); const el = document.createElement('div'); render(, el); expect(el.innerHTML).toBe('
    iconicon
    '); expect(log).toEqual(['mount icon', 'mount icon']); both = false; bump(); await flush(); expect(el.innerHTML).toBe('
    icon
    '); expect(log).toEqual(['mount icon', 'mount icon', 'unmount icon']); }); it('keeps each reused component under its own parent while its output changes', async () => { const updates: ((step: number) => void)[] = []; const unmounted: number[] = []; const Child = mount(renew => { let step = 0; const id = updates.length; updates.push(next => { step = next; renew(); }); mountCallback(() => () => unmounted.push(id)); return () => { if (step === 1) return changed; if (step === 2) return null; if (step === 3) return [one, two]; return initial; }; }); const shared = ; const el = document.createElement('div'); const destroy = render(
    {shared}
    , el ); const html = [ 'initial', 'changed', '', 'onetwo', 'initial', ]; expect(updates).toHaveLength(2); for (const instance of [0, 1]) { for (let step = 1; step < html.length; step++) { updates[instance](step); await flush(); expect(el.innerHTML).toBe( `
    ${instance === 0 ? html[step] : html[0]}
    ` ); } } expect(unmounted).toEqual([]); destroy(); expect(el.innerHTML).toBe(''); expect(unmounted).toEqual([0, 1]); }); it('keeps a hoisted component its own props next to the same component inline', async () => { // The instance got the hoisted node's props object, which the inline one // then overwrote in place. const Label = mount<{ label: string }>((_r, props) => () => ( {props.label} )); const fallback =