import { describe, expect, it } from 'vitest'; import { Fragment, h, mount, nextTick, portal, render } from '@/index'; // A portal node's element is its target, which is not a child of the parent // the portal sits in. Siblings around a portal must never use that target as // an insertion anchor or move it (BUG-1). const setup = (view: (target: HTMLElement) => unknown) => { const host = document.createElement('div'); const target = document.createElement('section'); document.body.append(host, target); let refresh = () => {}; const App = mount(renew => { refresh = renew; return () =>
{view(target)}
; }); const dispose = render(, host); const update = async () => { refresh(); await nextTick(); }; const cleanup = () => { dispose(); host.remove(); target.remove(); }; return { host, target, update, cleanup }; }; describe('portal siblings (BUG-1)', () => { it('inserts a component that appears right before a portal', async () => { let show = false; const Child = mount(() => () => ( <> x y )); const t = setup(target => [

head

, show ? : null, portal({String(show)}, target), tail, ]); expect(t.host.innerHTML).toBe('

head

tail
'); show = true; await t.update(); expect(t.host.innerHTML).toBe( '

head

xytail
' ); expect(t.target.innerHTML).toBe('true'); expect(t.target.parentNode).toBe(document.body); show = false; await t.update(); expect(t.host.innerHTML).toBe('

head

tail
'); expect(t.target.innerHTML).toBe('false'); t.cleanup(); }); it('inserts an element that appears right before a portal', async () => { let show = false; const t = setup(target => [

head

, show ? x : null, portal(p, target), tail, ]); show = true; await t.update(); expect(t.host.innerHTML).toBe( '

head

xtail
' ); show = false; await t.update(); expect(t.host.innerHTML).toBe('

head

tail
'); expect(t.target.innerHTML).toBe('p'); t.cleanup(); }); it('places a new last child after a trailing portal', async () => { let show = false; const t = setup(target => [

head

, show ? x : null, portal(p, target), ]); show = true; await t.update(); expect(t.host.innerHTML).toBe('

head

x
'); expect(t.target.innerHTML).toBe('p'); t.cleanup(); }); it('moves keyed siblings around an unmoved portal', async () => { let order = ['a', 'b', 'c']; const t = setup(target => order.map(key => key === 'b' ? portal(p, target) : {key} ) ); expect(t.host.innerHTML).toBe('
ac
'); order = ['c', 'b', 'a']; await t.update(); expect(t.host.innerHTML).toBe('
ca
'); expect(t.target.innerHTML).toBe('p'); expect(t.target.parentNode).toBe(document.body); t.cleanup(); }); it('moves a keyed component that renders a portal without taking the target', async () => { let order = [1, 2, 3]; const Row = mount<{ key?: unknown; id: number; target: HTMLElement }>( () => ({ id, target }) => ( <> {id} {id === 2 ? portal(p{id}, target) : null} ) ); const t = setup(target => order.map(id => ) ); expect(t.host.innerHTML).toBe('
123
'); order = [3, 2, 1]; await t.update(); expect(t.host.innerHTML).toBe('
321
'); expect(t.target.innerHTML).toBe('p2'); expect(t.target.parentNode).toBe(document.body); order = [2, 3, 1]; await t.update(); expect(t.host.innerHTML).toBe('
231
'); expect(t.target.parentNode).toBe(document.body); t.cleanup(); }); it('removes a portal without touching siblings of the same count', async () => { let show = true; const t = setup(target => [

a

,

b

, show ? portal( <> 1 2 , target ) : null, ]); expect(t.host.innerHTML).toBe('

a

b

'); expect(t.target.innerHTML).toBe('12'); show = false; await t.update(); expect(t.host.innerHTML).toBe('

a

b

'); expect(t.target.innerHTML).toBe(''); t.cleanup(); }); it('switches between a portal and an element in the same place', async () => { let usePortal = true; const t = setup(target => [

head

, usePortal ? portal(p, target) : inline, tail, ]); expect(t.host.innerHTML).toBe('

head

tail
'); usePortal = false; await t.update(); expect(t.host.innerHTML).toBe( '

head

inlinetail
' ); expect(t.target.innerHTML).toBe(''); usePortal = true; await t.update(); expect(t.host.innerHTML).toBe('

head

tail
'); expect(t.target.innerHTML).toBe('p'); t.cleanup(); }); it('switches between a portal and a component in the same place', async () => { let usePortal = true; const Inline = mount(() => () => ( <> a b )); const t = setup(target => [

head

, usePortal ? portal(p, target) : , tail, ]); usePortal = false; await t.update(); expect(t.host.innerHTML).toBe( '

head

abtail
' ); expect(t.target.innerHTML).toBe(''); usePortal = true; await t.update(); expect(t.host.innerHTML).toBe('

head

tail
'); expect(t.target.innerHTML).toBe('p'); t.cleanup(); }); it('keeps the target when a component that renders a portal is removed', async () => { let show = true; const Modal = mount<{ target: HTMLElement }>(() => ({ target }) => ( <> inline {portal(p, target)} )); const t = setup(target => [

head

, show ? : null, ]); expect(t.host.innerHTML).toBe('

head

inline
'); expect(t.target.innerHTML).toBe('p'); show = false; await t.update(); expect(t.host.innerHTML).toBe('

head

'); expect(t.target.innerHTML).toBe(''); expect(t.target.parentNode).toBe(document.body); t.cleanup(); }); it('removes keyed rows that render portals without removing the target', async () => { let ids = [1, 2, 3]; const Row = mount<{ key?: unknown; id: number; target: HTMLElement }>( () => ({ id, target }) => ( <> {id} {portal({id}, target)} ) ); const t = setup(target => ids.map(id => ) ); expect(t.target.innerHTML).toBe('123'); ids = [3]; await t.update(); expect(t.host.innerHTML).toBe('
3
'); expect(t.target.innerHTML).toBe('3'); expect(t.target.parentNode).toBe(document.body); ids = []; await t.update(); expect(t.host.innerHTML).toBe('
'); expect(t.target.innerHTML).toBe(''); expect(t.target.parentNode).toBe(document.body); t.cleanup(); }); it('keeps unrelated target content when its portal is removed', async () => { let show = true; const t = setup(target => [

head

, show ? portal( <> 1 2 , target ) : null, ]); const outside = document.createElement('u'); t.target.appendChild(outside); show = false; await t.update(); expect(t.host.innerHTML).toBe('

head

'); expect(t.target.innerHTML).toBe(''); t.cleanup(); }); });