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 () =>
');
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 () => (
{[
a
,
x
,
y
,
b
]}
);
});
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 () => (
');
});
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(
{['a', 'b', 'c'].map(id => (
))}
,
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 () => (
');
});
});
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(
');
expect(log).toEqual(['mount', 'unmount', 'mount', 'unmount', 'mount']);
});
it('remounts children moved after the parent redrew them', async () => {
// After a parent redraw the children are resolvers. Drawn again in a new
// place, the same resolver made a second instance with the first one's
// key, and the first one was never unmounted.
const log: string[] = [];
const Child = tracked(log, 'child');
let toggle = () => {};
const Link = makeLink(next => (toggle = next));
let bumpParent = () => {};
const Parent = mount(renew => {
bumpParent = renew;
return () => (
);
});
const el = document.createElement('div');
render(, el);
bumpParent();
await flush();
toggle();
await flush();
toggle();
await flush();
expect(el.innerHTML).toBe('child');
expect(log).toEqual([
'mount child',
'unmount child',
'mount child',
'unmount child',
'mount child',
]);
});
it('reorders children it was given', async () => {
let toggle = () => {};
const Reverse = mount((renew, _props, children) => {
let reversed = false;
toggle = () => {
reversed = !reversed;
renew();
};
return () => (reversed ? [...children].reverse() : children) as never;
});
const el = document.createElement('div');
render(
123
,
el
);
toggle();
await flush();
expect(el.innerHTML).toBe('