import { describe, expect, it } from 'vitest'; import { Fragment, h, mount, mountCallback, nextTick, render, portal, } from '@/index'; // Shared with the concurrent suite. Observe DOM effects and lifecycle visibility, // rather than depending on the renderer's internal removal helpers. describe('removing nested component lists', () => { it('removes all roots in one mutation after cleanup and cancels stale renews', async () => { const host = document.createElement('div'); let ids = [1, 2, 3]; let renewList = () => {}; const renewRows: (() => void)[] = []; const cleanup: [number, string | null][] = []; const Empty = mount(() => () => null); const Row = mount<{ id: number; key: number }>((renew, props) => { renewRows.push(renew); mountCallback(() => () => cleanup.push([props.id, host.textContent])); return () => ( {props.id} {':' + props.id} ); }); const List = mount(renew => { renewList = renew; return () => (
{ids.map(id => ( ))}
); }); const destroy = render(, host); const parent = host.firstElementChild!; const original = [...parent.childNodes]; const mutations: MutationRecord[] = []; const observer = new MutationObserver(records => mutations.push(...records) ); observer.observe(parent, { childList: true }); ids = []; renewList(); renewRows.forEach(renew => renew()); await nextTick(); mutations.push(...observer.takeRecords()); observer.disconnect(); expect(cleanup).toEqual([ [1, '1:12:23:3'], [2, '1:12:23:3'], [3, '1:12:23:3'], ]); expect(parent.childNodes.length).toBe(0); // jsdom also reports an empty Fragment insertion. Only the removal // changes DOM; all six roots must be in that single removal record. const removals = mutations.filter(record => record.removedNodes.length); expect(removals).toHaveLength(1); expect([...removals[0].removedNodes]).toEqual(original); expect(mutations.every(record => record.addedNodes.length === 0)).toBe( true ); ids = [4, 5]; renewList(); await nextTick(); expect(parent.textContent).toBe('4:45:5'); destroy(); expect(cleanup.map(x => x[0])).toEqual([1, 2, 3, 4, 5]); }); it('preserves outside siblings and keyed-removal callback visibility', async () => { const host = document.createElement('div'); let ids = [1, 2, 3]; let renewList = () => {}; const cleanup: (string | null)[] = []; const Row = mount<{ id: number; key: number }>((_renew, props) => { mountCallback(() => () => cleanup.push(host.textContent)); return () => ( {props.id} {':' + props.id} ); }); const List = mount(renew => { renewList = renew; return () => (
head {ids.map(id => ( ))} tail
); }); const destroy = render(, host); const [head, tail] = host.querySelectorAll('i'); const kept = host.querySelectorAll('b')[2]; ids = [3]; renewList(); await nextTick(); expect(cleanup).toEqual(['head1:12:23:3tail', 'head2:23:3tail']); expect(host.querySelector('b')).toBe(kept); ids = []; renewList(); await nextTick(); expect([...host.firstElementChild!.children]).toEqual([head, tail]); expect(host.textContent).toBe('headtail'); destroy(); }); it('does not clear a foreign node that replaces one of its DOM children', async () => { const host = document.createElement('div'); let show = true; let renew = () => {}; const List = mount(update => { renew = update; return () => (
{show ? [a, b] : []}
); }); const destroy = render(, host); const foreign = document.createElement('i'); foreign.textContent = 'foreign'; host.querySelector('b')!.replaceWith(foreign); show = false; renew(); await nextTick(); expect([...host.firstElementChild!.childNodes]).toEqual([foreign]); destroy(); }); it('falls back when a row renders a portal and keeps the target content it does not own', async () => { const host = document.createElement('div'); const target = document.createElement('section'); const outside = document.createElement('i'); outside.textContent = 'outside'; target.appendChild(outside); document.body.append(host, target); let ids = [1, 2, 3]; let renewList = () => {}; const Row = mount<{ id: number; key: number }>( (_renew, props) => () => props.id === 2 ? ( portal({'p' + props.id}, target) ) : ( {props.id} ) ); const List = mount(renew => { renewList = renew; return () => (
{ids.map(id => ( ))}
); }); const destroy = render(, host); expect(host.textContent).toBe('13'); expect(target.textContent).toBe('outsidep2'); ids = []; renewList(); await nextTick(); expect(host.firstElementChild!.childNodes.length).toBe(0); expect([...target.childNodes]).toEqual([outside]); expect(target.parentNode).toBe(document.body); destroy(); host.remove(); target.remove(); }); it('still clears a plain element list in one mutation', async () => { const host = document.createElement('div'); let ids = [1, 2, 3]; let renewList = () => {}; const List = mount(renew => { renewList = renew; return () => (
    {ids.map(id => (
  • {id}
  • ))}
); }); const destroy = render(, host); const parent = host.firstElementChild!; const original = [...parent.childNodes]; const mutations: MutationRecord[] = []; const observer = new MutationObserver(records => mutations.push(...records) ); observer.observe(parent, { childList: true }); ids = []; renewList(); await nextTick(); mutations.push(...observer.takeRecords()); observer.disconnect(); const removals = mutations.filter(record => record.removedNodes.length); expect(removals).toHaveLength(1); expect([...removals[0].removedNodes]).toEqual(original); destroy(); }); it('clears nested roots in a portal without removing the caller-owned host', async () => { const owner = document.createElement('div'); const target = document.createElement('section'); owner.appendChild(target); const host = document.createElement('div'); let show = true; let renew = () => {}; const Pair = mount(() => () => ( a b )); const App = mount(update => { renew = update; return () => show ? portal( , target ) : null; }); const destroy = render(, host); expect(target.textContent).toBe('abab'); show = false; renew(); await nextTick(); expect(target.textContent).toBe(''); expect(target.parentNode).toBe(owner); destroy(); }); });