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('');
show = true;
await t.update();
expect(t.host.innerHTML).toBe(
''
);
expect(t.target.innerHTML).toBe('true');
expect(t.target.parentNode).toBe(document.body);
show = false;
await t.update();
expect(t.host.innerHTML).toBe('');
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(
''
);
show = false;
await t.update();
expect(t.host.innerHTML).toBe('');
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('');
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('');
expect(t.target.innerHTML).toBe('12');
show = false;
await t.update();
expect(t.host.innerHTML).toBe('');
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('');
usePortal = false;
await t.update();
expect(t.host.innerHTML).toBe(
''
);
expect(t.target.innerHTML).toBe('');
usePortal = true;
await t.update();
expect(t.host.innerHTML).toBe('');
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(
''
);
expect(t.target.innerHTML).toBe('');
usePortal = true;
await t.update();
expect(t.host.innerHTML).toBe('');
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('');
expect(t.target.innerHTML).toBe('p');
show = false;
await t.update();
expect(t.host.innerHTML).toBe('');
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('');
expect(t.target.innerHTML).toBe('');
t.cleanup();
});
});