import { describe, it, expect } from 'vitest';
import * as core from '@/index';
import { h, Fragment, render, mount, nextTick } from '@/index';
// This suite also runs against the concurrent core (lithentConcurrent's
// vitest config aliases `@/index`).
const isConcurrent = 'deferRender' in core;
/**
* Behaviour the update fast paths must keep (docs/benchmark/STATUS.md):
* props are synced in place, the previous props object is never edited, the
* redraw action is bound once per component, and a list of multi-root
* components is removed without touching its neighbours.
*/
describe('update paths', () => {
it('leaves a props object that is reused across renders untouched', async () => {
const shared = { class: 'a', title: 't' };
let count = 0;
let bump = () => {};
// The same object is handed to h() on every render.
const Reuse = mount(renew => {
bump = renew;
return () => h('p', shared, String(count));
});
const el = document.createElement('div');
render(, el);
for (let i = 0; i < 3; i++) {
count += 1;
bump();
await nextTick();
}
const p = el.querySelector('p') as HTMLElement;
expect(shared).toEqual({ class: 'a', title: 't' });
expect(p.getAttribute('class')).toBe('a');
expect(p.getAttribute('title')).toBe('t');
expect(p.textContent).toBe('3');
});
it('removes an attribute whose prop is gone and keeps the others', async () => {
let props: Record = { class: 'a', title: 't', id: 'x' };
let bump = () => {};
const Comp = mount(renew => {
bump = renew;
return () => h('p', props);
});
const el = document.createElement('div');
render(, el);
const before = props;
props = { class: 'b', id: 'x' };
bump();
await nextTick();
const p = el.querySelector('p') as HTMLElement;
expect(p.getAttribute('class')).toBe('b');
expect(p.getAttribute('id')).toBe('x');
expect(p.hasAttribute('title')).toBe(false);
expect(before).toEqual({ class: 'a', title: 't', id: 'x' });
});
it('syncs component props in place: same object, removed keys gone', async () => {
let next: Record = { a: 1, b: 2 };
let bump = () => {};
const seen: Record[] = [];
let held: Record | undefined;
const Child = mount>((_renew, props) => {
held = props;
return () => {
seen.push({ ...props });
return {Object.keys(props).join(',')};
};
});
const Parent = mount(renew => {
bump = renew;
return () => (
);
});
const el = document.createElement('div');
render(, el);
next = { a: 3, c: 4 };
bump();
await nextTick();
expect(seen).toEqual([
{ a: 1, b: 2 },
{ a: 3, c: 4 },
]);
expect(held).toEqual({ a: 3, c: 4 });
expect('b' in (held as object)).toBe(false);
expect(el.querySelector('i')!.textContent).toBe('a,c');
});
it('skips a redraw queued for a node its parent has already redrawn', async () => {
let bumpParent = () => {};
let bumpChild = () => {};
let childRuns = 0;
const Child = mount(renew => {
bumpChild = renew;
return () => {
childRuns += 1;
return {childRuns};
};
});
const Parent = mount(renew => {
bumpParent = renew;
return () => (
);
});
const el = document.createElement('div');
render(, el);
expect(childRuns).toBe(1);
// Parent first: its redraw re-renders the child, so the child's own
// queued redraw is for a node that is no longer current.
bumpParent();
bumpChild();
await nextTick();
// The concurrent core does not skip it and renders the child once more.
const afterBoth = isConcurrent ? 3 : 2;
expect(childRuns).toBe(afterBoth);
expect(el.querySelector('b')!.textContent).toBe(String(afterBoth));
// The child can still redraw itself afterwards.
bumpChild();
await nextTick();
expect(childRuns).toBe(afterBoth + 1);
expect(el.querySelector('b')!.textContent).toBe(String(afterBoth + 1));
});
it('keeps renew bound after many renders', async () => {
let bump = () => {};
let n = 0;
const Comp = mount(renew => {
bump = renew;
return () => {n};
});
const el = document.createElement('div');
render(, el);
for (let i = 0; i < 5; i++) {
n += 1;
bump();
await nextTick();
expect(el.querySelector('u')!.textContent).toBe(String(n));
}
});
describe('a list of components that each render two siblings', () => {
const setup = (withNeighbours: boolean) => {
let ids: number[] = [1, 2, 3];
let bump = () => {};
const Pair = mount<{ id: number; key?: number }>(
(_renew, props) => () => (
{props.id}
{props.id}
)
);
const List = mount(renew => {
bump = renew;
return () => (
{withNeighbours ? head : null}
{ids.map(id => (
))}
{withNeighbours ? tail : null}
);
});
const el = document.createElement('div');
render(
, el);
return {
el,
set: async (nextIds: number[]) => {
ids = nextIds;
bump();
await nextTick();
},
text: () =>
Array.from(el.querySelector('dl')!.children).map(
node => node.localName + ':' + node.textContent
),
};
};
it('clears the list and leaves its neighbours', async () => {
const list = setup(true);
await list.set([]);
expect(list.text()).toEqual(['span:head', 'span:tail']);
await list.set([7]);
expect(list.text()).toEqual(['span:head', 'dt:7', 'dd:7', 'span:tail']);
});
it('clears a list that is the only content', async () => {
const list = setup(false);
await list.set([]);
expect(list.text()).toEqual([]);
await list.set([8, 9]);
expect(list.text()).toEqual(['dt:8', 'dd:8', 'dt:9', 'dd:9']);
});
it('removes one component from the middle', async () => {
const list = setup(true);
await list.set([1, 3]);
expect(list.text()).toEqual([
'span:head',
'dt:1',
'dd:1',
'dt:3',
'dd:3',
'span:tail',
]);
});
});
});