/
githubmirror
/
preact
Обзор
Документация
Войти
/
githubmirror
/
preact
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
test/browser/keys.test.jsx
1 556 строк
37 KB
Jovi De Croock
Add displacement edge-case tests
22 июл 2026, 19:00
22 июл 2026, 19:00
cacd13d
Код
Авторство
О чём код?
import { createElement, Component, render, createRef, createContext } from 'preact'; import { setupRerender } from 'preact/test-utils'; import { setupScratch, teardown } from '../_util/helpers'; import { logCall, clearLog, getLog } from '../_util/logCall'; import { div } from '../_util/dom'; import { vi } from 'vitest'; describe('keys', () => { /** @type {HTMLDivElement} */ let scratch; let rerender; /** @type {string[]} */ let ops; function createStateful(name) { return class Stateful extends Component { constructor(props) { super(props); if (typeof props.ref === 'function') { props.ref(this); } else if (props.ref) { props.ref.current = this; } } componentDidUpdate() { if (typeof this.props.ref === 'function') { this.props.ref(this); } else if (this.props.ref) { this.props.ref.current = this; } ops.push(`Update ${name}`); } componentDidMount() { ops.push(`Mount ${name}`); } componentWillUnmount() { ops.push(`Unmount ${name}`); } render() { return <div>{name}</div>; } }; } /** @type {(props: {values: any[]}) => any} */ const List = props => ( <ol> {props.values.map(value => ( <li key={value}>{value}</li> ))} </ol> ); /** * Move an element in an array from one index to another * @param {any[]} values The array of values * @param {number} from The index to move from * @param {number} to The index to move to */ function move(values, from, to) { const value = values[from]; values.splice(from, 1); values.splice(to, 0, value); } let resetMoveBefore; let resetAppendChild; let resetInsertBefore; let resetRemoveChild; let resetRemove; let resetRemoveText; beforeAll(() => { resetMoveBefore = logCall(Element.prototype, 'moveBefore'); resetAppendChild = logCall(Element.prototype, 'appendChild'); resetInsertBefore = logCall(Element.prototype, 'insertBefore'); resetRemoveChild = logCall(Element.prototype, 'removeChild'); resetRemove = logCall(Element.prototype, 'remove'); resetRemoveText = logCall(Text.prototype, 'remove'); }); afterAll(() => { resetAppendChild(); resetInsertBefore(); resetRemoveChild(); resetRemove(); resetRemoveText(); resetMoveBefore(); }); beforeEach(() => { scratch = setupScratch(); rerender = setupRerender(); ops = []; }); afterEach(() => { teardown(scratch); clearLog(); }); // https://fb.me/react-special-props it('should not pass key in props', () => { const Foo = vi.fn(function Foo() { return null; }); render(<Foo key="foo" />, scratch); expect(Foo.mock.calls[0][0]).to.deep.equal({}); }); it('should update in-place keyed DOM nodes', () => { render( <ul> <li key="0">a</li> <li key="1">b</li> <li key="2">c</li> </ul>, scratch ); expect(scratch.innerHTML).to.equal( '<ul><li>a</li><li>b</li><li>c</li></ul>' ); render( <ul> <li key="0">x</li> <li key="1">y</li> <li key="2">z</li> </ul>, scratch ); expect(scratch.innerHTML).to.equal( '<ul><li>x</li><li>y</li><li>z</li></ul>' ); }); // See preactjs/preact-compat#21 it('should remove orphaned keyed nodes', () => { render( <div> <div>1</div> <li key="a">a</li> <li key="b">b</li> </div>, scratch ); render( <div> <div>2</div> <li key="b">b</li> <li key="c">c</li> </div>, scratch ); expect(scratch.innerHTML).to.equal( '<div><div>2</div><li>b</li><li>c</li></div>' ); }); it('should remove keyed nodes (#232)', () => { class App extends Component { componentDidMount() { setTimeout(() => this.setState({ opened: true, loading: true }), 10); setTimeout(() => this.setState({ opened: true, loading: false }), 20); } render({ opened, loading }) { return ( <BusyIndicator id="app" busy={loading}> <div>This div needs to be here for this to break</div> {opened && !loading && <div>{[]}</div>} </BusyIndicator> ); } } class BusyIndicator extends Component { render({ children, busy }) { return ( <div class={busy ? 'busy' : ''}> {children && children.length ? ( children ) : ( <div class="busy-placeholder" /> )} <div class="indicator"> <div>indicator</div> <div>indicator</div> <div>indicator</div> </div> </div> ); } } render(<App />, scratch); render(<App opened loading />, scratch); render(<App opened />, scratch); const html = String(scratch.firstChild.innerHTML).replace(/ class=""/g, ''); expect(html).to.equal( '<div>This div needs to be here for this to break</div><div></div><div class="indicator"><div>indicator</div><div>indicator</div><div>indicator</div></div>' ); }); it('should append new keyed elements', () => { const values = ['a', 'b']; render(<List values={values} />, scratch); expect(scratch.textContent).to.equal('ab'); values.push('c'); clearLog(); render(<List values={values} />, scratch); expect(scratch.textContent).to.equal('abc'); expect(getLog()).to.deep.equal([ '<li>.appendChild(#text)', '<ol>ab.appendChild(<li>c)' ]); }); it('should remove keyed elements from the end', () => { const values = ['a', 'b', 'c', 'd']; render(<List values={values} />, scratch); expect(scratch.textContent).to.equal('abcd'); values.pop(); clearLog(); render(<List values={values} />, scratch); expect(scratch.textContent).to.equal('abc'); expect(getLog()).to.deep.equal(['<li>d.remove()']); }); it('should prepend keyed elements to the beginning', () => { const values = ['b', 'c']; render(<List values={values} />, scratch); expect(scratch.textContent).to.equal('bc'); values.unshift('a'); clearLog(); render(<List values={values} />, scratch); expect(scratch.textContent).to.equal('abc'); expect(getLog()).to.deep.equal([ '<li>.appendChild(#text)', '<ol>bc.insertBefore(<li>a, <li>b)' ]); }); it('should remove keyed elements from the beginning', () => { const values = ['z', 'a', 'b', 'c']; render(<List values={values} />, scratch); expect(scratch.textContent).to.equal('zabc'); values.shift(); clearLog(); render(<List values={values} />, scratch); expect(scratch.textContent).to.equal('abc'); expect(getLog()).to.deep.equal(['<li>z.remove()']); }); it('should insert new keyed children in the middle', () => { const values = ['a', 'c']; render(<List values={values} />, scratch); expect(scratch.textContent).to.equal('ac'); values.splice(1, 0, 'b'); clearLog(); render(<List values={values} />, scratch); expect(scratch.textContent).to.equal('abc'); expect(getLog()).to.deep.equal([ '<li>.appendChild(#text)', '<ol>ac.insertBefore(<li>b, <li>c)' ]); }); it('should remove keyed children from the middle', () => { const values = ['a', 'b', 'x', 'y', 'z', 'c', 'd']; render(<List values={values} />, scratch); expect(scratch.textContent).to.equal('abxyzcd'); values.splice(2, 3); clearLog(); render(<List values={values} />, scratch); expect(scratch.textContent).to.equal('abcd'); expect(getLog()).to.deep.equal([ '<li>x.remove()', '<li>y.remove()', '<li>z.remove()' ]); }); it('should move keyed children to the beginning', () => { const values = ['b', 'c', 'd', 'a']; render(<List values={values} />, scratch); expect(scratch.textContent).to.equal('bcda'); move(values, values.length - 1, 0); clearLog(); render(<List values={values} />, scratch); expect(scratch.textContent).to.equal('abcd'); expect(getLog()).to.deep.equal(['<ol>bcda.insertBefore(<li>a, <li>b)']); }); it('should move multiple keyed children to the beginning', () => { const values = ['c', 'd', 'e', 'a', 'b']; render(<List values={values} />, scratch); expect(scratch.textContent).to.equal('cdeab'); move(values, values.length - 1, 0); move(values, values.length - 1, 0); clearLog(); render(<List values={values} />, scratch); expect(scratch.textContent).to.equal('abcde'); expect(getLog()).to.deep.equal([ '<ol>cdeab.insertBefore(<li>a, <li>c)', '<ol>acdeb.insertBefore(<li>b, <li>c)' ]); }); it('should swap keyed children efficiently', () => { render(<List values={['a', 'b']} />, scratch); expect(scratch.textContent).to.equal('ab'); clearLog(); render(<List values={['b', 'a']} />, scratch); expect(scratch.textContent).to.equal('ba'); expect(getLog()).to.deep.equal(['<ol>ab.insertBefore(<li>b, <li>a)']); }); it('should swap existing keyed children in the middle of a list efficiently', () => { const values = ['a', 'b', 'c', 'd']; render(<List values={values} />, scratch); expect(scratch.textContent).to.equal('abcd'); // swap move(values, 1, 2); clearLog(); render(<List values={values} />, scratch); expect(scratch.textContent).to.equal('acbd', 'initial swap'); expect(getLog()).to.deep.equal( ['<ol>abcd.insertBefore(<li>c, <li>b)'], 'initial swap' ); // swap back move(values, 2, 1); clearLog(); render(<List values={values} />, scratch); expect(scratch.textContent).to.equal('abcd', 'swap back'); expect(getLog()).to.deep.equal( ['<ol>acbd.insertBefore(<li>b, <li>c)'], 'swap back' ); }); it('should displace multiple keyed children to the end efficiently', () => { const values = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j']; render(<List values={values} />, scratch); expect(scratch.textContent).to.equal('abcdefghij'); values.push(...values.splice(0, 3)); clearLog(); render(<List values={values} />, scratch); expect(scratch.textContent).to.equal('defghijabc'); expect(getLog()).to.deep.equal([ '<ol>abcdefghij.appendChild(<li>a)', '<ol>bcdefghija.appendChild(<li>b)', '<ol>cdefghijab.appendChild(<li>c)' ]); }); it('should not displace when the suffix after the match is shorter than the jump', () => { const values = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j']; render(<List values={values} />, scratch); expect(scratch.textContent).to.equal('abcdefghij'); // Swap two far apart children; only the two swapped children are out of // order, so only those two may move. [values[1], values[8]] = [values[8], values[1]]; clearLog(); render(<List values={values} />, scratch); expect(scratch.textContent).to.equal('aicdefghbj'); expect(getLog()).to.deep.equal([ '<ol>abcdefghij.insertBefore(<li>i, <li>b)', '<ol>aibcdefghj.insertBefore(<li>b, <li>j)' ]); }); it('should move the shorter suffix when more than half the list is displaced', () => { const values = ['a', 'b', 'c', 'd', 'e', 'f']; render(<List values={values} />, scratch); expect(scratch.textContent).to.equal('abcdef'); // Displacing 4 of 6 children: moving the two-child suffix is the // minimal set of moves. values.push(...values.splice(0, 4)); clearLog(); render(<List values={values} />, scratch); expect(scratch.textContent).to.equal('efabcd'); expect(getLog()).to.deep.equal([ '<ol>abcdef.insertBefore(<li>e, <li>a)', '<ol>eabcdf.insertBefore(<li>f, <li>a)' ]); }); it('should displace multiple keyed children to the end while the list grows', () => { const values = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j']; render(<List values={values} />, scratch); expect(scratch.textContent).to.equal('abcdefghij'); values.push(...values.splice(0, 3)); values.push('k'); clearLog(); render(<List values={values} />, scratch); expect(scratch.textContent).to.equal('defghijabck'); expect(getLog()).to.deep.equal([ '<ol>abcdefghij.appendChild(<li>a)', '<ol>bcdefghija.appendChild(<li>b)', '<ol>cdefghijab.appendChild(<li>c)', '<li>.appendChild(#text)', '<ol>defghijabc.appendChild(<li>k)' ]); }); it('should displace multiple keyed children to the end while another is removed', () => { const values = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j']; render(<List values={values} />, scratch); expect(scratch.textContent).to.equal('abcdefghij'); values.push(...values.splice(0, 3)); values.splice(values.indexOf('j'), 1); clearLog(); render(<List values={values} />, scratch); expect(scratch.textContent).to.equal('defghiabc'); expect(getLog()).to.deep.equal([ '<li>j.remove()', '<ol>abcdefghi.appendChild(<li>a)', '<ol>bcdefghia.appendChild(<li>b)', '<ol>cdefghiab.appendChild(<li>c)' ]); }); it('should displace keyed children to the end repeatedly', () => { const values = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j']; const expectedDisplaceLogs = [ [ '<ol>abcdefghij.appendChild(<li>a)', '<ol>bcdefghija.appendChild(<li>b)', '<ol>cdefghijab.appendChild(<li>c)' ], [ '<ol>defghijabc.appendChild(<li>d)', '<ol>efghijabcd.appendChild(<li>e)', '<ol>fghijabcde.appendChild(<li>f)' ], [ '<ol>ghijabcdef.appendChild(<li>g)', '<ol>hijabcdefg.appendChild(<li>h)', '<ol>ijabcdefgh.appendChild(<li>i)' ] ]; render(<List values={values} />, scratch); expect(scratch.textContent).to.equal('abcdefghij'); for (let n = 0; n < 3; n++) { values.push(...values.splice(0, 3)); clearLog(); render(<List values={values} />, scratch); expect(scratch.textContent).to.equal(values.join('')); expect(getLog()).to.deep.equal(expectedDisplaceLogs[n], `round ${n}`); } }); it('should keep text siblings correct around displaced keyed children', () => { const content = condition => ( <ol> {condition ? [ <li key="a">a</li>, <li key="b">b</li>, <li key="c">c</li>, 'mid', <li key="d">d</li>, <li key="e">e</li> ] : [ <li key="c">c</li>, 'mid', <li key="a">a</li>, <li key="b">b</li>, <li key="d">d</li>, <li key="e">e</li> ]} </ol> ); render(content(true), scratch); expect(scratch.innerHTML).to.equal( '<ol><li>a</li><li>b</li><li>c</li>mid<li>d</li><li>e</li></ol>' ); clearLog(); render(content(false), scratch); expect(scratch.innerHTML).to.equal( '<ol><li>c</li>mid<li>a</li><li>b</li><li>d</li><li>e</li></ol>' ); }); it('should move keyed children to the end of the list', () => { const values = ['a', 'b', 'c', 'd']; render(<List values={values} />, scratch); expect(scratch.textContent).to.equal('abcd'); // move to end move(values, 0, values.length - 1); clearLog(); render(<List values={values} />, scratch); expect(scratch.textContent).to.equal('bcda', 'move to end'); expect(getLog()).to.deep.equal( ['<ol>abcd.appendChild(<li>a)'], 'move to end' ); // move to beginning move(values, values.length - 1, 0); clearLog(); render(<List values={values} />, scratch); expect(scratch.textContent).to.equal('abcd', 'move to beginning'); expect(getLog()).to.deep.equal( ['<ol>bcda.insertBefore(<li>a, <li>b)'], 'move to beginning' ); }); it('should move keyed children to the beginning on longer list', () => { // Preact v10 worst case const values = ['a', 'b', 'c', 'd', 'e', 'f']; render(<List values={values} />, scratch); expect(scratch.textContent).to.equal('abcdef'); move(values, 4, 1); clearLog(); render(<List values={values} />, scratch); expect(scratch.textContent).to.equal('aebcdf'); expect(getLog()).to.deep.equal(['<ol>abcdef.insertBefore(<li>e, <li>b)']); }); it('should move keyed children to the end on longer list', () => { const values = ['a', 'b', 'c', 'd', 'e', 'f']; render(<List values={values} />, scratch); expect(scratch.textContent).to.equal('abcdef'); move(values, 1, values.length - 2); clearLog(); render(<List values={values} />, scratch); expect(scratch.textContent).to.equal('acdebf'); expect(getLog()).to.deep.equal(['<ol>abcdef.insertBefore(<li>b, <li>f)']); }); it('should reverse keyed children effectively', () => { const values = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j']; render(<List values={values} />, scratch); expect(scratch.textContent).to.equal(values.join('')); // reverse list values.reverse(); clearLog(); render(<List values={values} />, scratch); expect(scratch.textContent).to.equal(values.join('')); // expect(getLog()).to.have.lengthOf(9); expect(getLog()).to.deep.equal([ '<ol>abcdefghij.insertBefore(<li>j, <li>a)', '<ol>jabcdefghi.insertBefore(<li>i, <li>a)', '<ol>jiabcdefgh.insertBefore(<li>h, <li>a)', '<ol>jihabcdefg.insertBefore(<li>g, <li>a)', '<ol>jihgabcdef.insertBefore(<li>f, <li>a)', '<ol>jihgfabcde.insertBefore(<li>e, <li>a)', '<ol>jihgfeabcd.insertBefore(<li>d, <li>a)', '<ol>jihgfedabc.insertBefore(<li>c, <li>a)', '<ol>jihgfedcab.insertBefore(<li>b, <li>a)' ]); }); it('should properly remove children of memoed components', () => { const values = [1, 2, 3, 4, 5, 6, 7, 8, 9]; class Item extends Component { shouldComponentUpdate(props) { return props.value !== this.props.value; } render() { return <li>{this.props.value}</li>; } } function App({ values }) { return ( <ul> {values.map(value => ( <Item key={value} value={value} /> ))} </ul> ); } render(<App values={values} />, scratch); expect(scratch.textContent).to.equal(values.join('')); clearLog(); values.splice(3, 3); render(<App values={values} />, scratch); expect(scratch.textContent).to.equal(values.join('')); expect(getLog()).to.deep.equal([ '<li>4.remove()', '<li>5.remove()', '<li>6.remove()' ]); }); it("should not preserve state when a component's keys are different", () => { const Stateful = createStateful('Stateful'); function Foo({ condition }) { return condition ? <Stateful key="a" /> : <Stateful key="b" />; } ops = []; render(<Foo condition />, scratch); expect(scratch.innerHTML).to.equal('<div>Stateful</div>'); expect(ops).to.deep.equal(['Mount Stateful'], 'initial mount'); ops = []; render(<Foo condition={false} />, scratch); expect(scratch.innerHTML).to.equal('<div>Stateful</div>'); expect(ops).to.deep.equal( ['Unmount Stateful', 'Mount Stateful'], 'switching keys 1' ); ops = []; render(<Foo condition />, scratch); expect(scratch.innerHTML).to.equal('<div>Stateful</div>'); expect(ops).to.deep.equal( ['Unmount Stateful', 'Mount Stateful'], 'switching keys 2' ); }); it('should not preserve state between an unkeyed and keyed component', () => { // React and Preact v8 behavior: https://codesandbox.io/s/57prmy5mx const Stateful = createStateful('Stateful'); function Foo({ keyed }) { return keyed ? <Stateful key="a" /> : <Stateful />; } ops = []; render(<Foo keyed />, scratch); expect(scratch.innerHTML).to.equal('<div>Stateful</div>'); expect(ops).to.deep.equal(['Mount Stateful'], 'initial mount with key'); ops = []; render(<Foo keyed={false} />, scratch); expect(scratch.innerHTML).to.equal('<div>Stateful</div>'); expect(ops).to.deep.equal( ['Unmount Stateful', 'Mount Stateful'], 'switching from keyed to unkeyed' ); ops = []; render(<Foo keyed />, scratch); expect(scratch.innerHTML).to.equal('<div>Stateful</div>'); expect(ops).to.deep.equal( ['Unmount Stateful', 'Mount Stateful'], 'switching from unkeyed to keyed' ); }); it('should not preserve state when keys change with multiple children', () => { // React & Preact v8 behavior: https://codesandbox.io/s/8l3p6lz9kj const Stateful1 = createStateful('Stateful1'); const Stateful2 = createStateful('Stateful2'); let Stateful1Ref; let Stateful2Ref; let Stateful1MovedRef; let Stateful2MovedRef; function Foo({ moved }) { return moved ? ( <div> <div>1</div> <Stateful1 key="c" ref={c => (Stateful1MovedRef = c)} /> <div>2</div> <Stateful2 key="d" ref={c => (Stateful2MovedRef = c)} /> </div> ) : ( <div> <div>1</div> <Stateful1 key="a" ref={c => (Stateful1Ref = c)} /> <div>2</div> <Stateful2 key="b" ref={c => (Stateful2Ref = c)} /> </div> ); } const expectedHtml = div([ div(1), div('Stateful1'), div(2), div('Stateful2') ]); ops = []; render(<Foo moved={false} />, scratch); expect(scratch.innerHTML).to.equal(expectedHtml); expect(ops).to.deep.equal(['Mount Stateful1', 'Mount Stateful2']); expect(Stateful1Ref).to.exist; expect(Stateful2Ref).to.exist; ops = []; render(<Foo moved />, scratch); expect(scratch.innerHTML).to.equal(expectedHtml); expect(ops).to.deep.equal([ 'Unmount Stateful1', 'Unmount Stateful2', 'Mount Stateful1', 'Mount Stateful2' ]); expect(Stateful1MovedRef).to.not.equal(Stateful1Ref); expect(Stateful2MovedRef).to.not.equal(Stateful2Ref); ops = []; render(<Foo moved={false} />, scratch); expect(scratch.innerHTML).to.equal(expectedHtml); expect(ops).to.deep.equal([ 'Unmount Stateful1', 'Unmount Stateful2', 'Mount Stateful1', 'Mount Stateful2' ]); expect(Stateful1Ref).to.not.equal(Stateful1MovedRef); expect(Stateful2Ref).to.not.equal(Stateful2MovedRef); }); it('should preserve state when moving keyed children components', () => { // React & Preact v8 behavior: https://codesandbox.io/s/8l3p6lz9kj const Stateful1 = createStateful('Stateful1'); const Stateful2 = createStateful('Stateful2'); let Stateful1Ref; let Stateful2Ref; let Stateful1MovedRef; let Stateful2MovedRef; function Foo({ moved }) { return moved ? ( <div> <div>1</div> <Stateful2 key="b" ref={c => (c ? (Stateful2MovedRef = c) : undefined)} /> <div>2</div> <Stateful1 key="a" ref={c => (c ? (Stateful1MovedRef = c) : undefined)} /> </div> ) : ( <div> <div>1</div> <Stateful1 key="a" ref={c => (c ? (Stateful1Ref = c) : undefined)} /> <div>2</div> <Stateful2 key="b" ref={c => (c ? (Stateful2Ref = c) : undefined)} /> </div> ); } const htmlForFalse = div([ div(1), div('Stateful1'), div(2), div('Stateful2') ]); const htmlForTrue = div([ div(1), div('Stateful2'), div(2), div('Stateful1') ]); ops = []; render(<Foo moved={false} />, scratch); expect(scratch.innerHTML).to.equal(htmlForFalse); expect(ops).to.deep.equal(['Mount Stateful1', 'Mount Stateful2']); expect(Stateful1Ref).to.exist; expect(Stateful2Ref).to.exist; ops = []; render(<Foo moved />, scratch); expect(scratch.innerHTML).to.equal(htmlForTrue); expect(ops).to.deep.equal(['Update Stateful2', 'Update Stateful1']); expect(Stateful1MovedRef).to.equal(Stateful1Ref); expect(Stateful2MovedRef).to.equal(Stateful2Ref); ops = []; render(<Foo moved={false} />, scratch); expect(scratch.innerHTML).to.equal(htmlForFalse); expect(ops).to.deep.equal(['Update Stateful1', 'Update Stateful2']); expect(Stateful1Ref).to.equal(Stateful1MovedRef); expect(Stateful2Ref).to.equal(Stateful2MovedRef); }); it('should effectively iterate on large lists', async () => { const newItems = () => Array(100) .fill(0) .map((item, i) => i); let set, mutatedNodes = []; class App extends Component { constructor(props) { super(props); this.state = { items: newItems() }; set = this.set = this.set.bind(this); this.ref = createRef(); } componentDidMount() { const observer = new MutationObserver(listener); observer.observe(this.ref.current, { childList: true }); function listener(mutations) { for (const { addedNodes } of mutations) { for (const node of Array.from(addedNodes)) { mutatedNodes.push(node); } } } } set() { const currentItems = this.state.items; const items = newItems().filter(id => { const isVisible = currentItems.includes(id); return id >= 20 && id <= 80 ? !isVisible : isVisible; }); this.setState({ items }); } render() { return ( <div ref={this.ref}> {this.state.items.map(i => ( <div key={i}>{i}</div> ))} </div> ); } } render(<App />, scratch); set(); rerender(); return new Promise(resolve => { setTimeout(() => { expect(mutatedNodes.length).to.equal(0); resolve(); }); }); }); it('should effectively iterate on large component lists', async () => { const newItems = () => Array(100) .fill(0) .map((item, i) => i); let set, mutatedNodes = []; const Row = ({ i }) => <p>{i}</p>; class App extends Component { constructor(props) { super(props); this.state = { items: newItems() }; set = this.set = this.set.bind(this); this.ref = createRef(); } componentDidMount() { const observer = new MutationObserver(listener); observer.observe(this.ref.current, { childList: true }); function listener(mutations) { for (const { addedNodes } of mutations) { for (const node of Array.from(addedNodes)) { mutatedNodes.push(node); } } } } set() { const currentItems = this.state.items; const items = newItems().filter(id => { const isVisible = currentItems.includes(id); return id >= 20 && id <= 80 ? !isVisible : isVisible; }); this.setState({ items }); } render() { return ( <div ref={this.ref}> {this.state.items.map(i => ( <Row key={i} i={i} /> ))} </div> ); } } render(<App />, scratch); set(); rerender(); return new Promise(resolve => { setTimeout(() => { expect(mutatedNodes.length).to.equal(0); resolve(); }); }); }); it('should not preserve state when switching between keyed and unkeyed components as children', () => { // React & Preact v8 behavior: https://codesandbox.io/s/8l3p6lz9kj const Stateful1 = createStateful('Stateful1'); const Stateful2 = createStateful('Stateful2'); let Stateful1Ref; let Stateful2Ref; let Stateful1MovedRef; let Stateful2MovedRef; function Foo({ unkeyed }) { return unkeyed ? ( <div> <div>1</div> <Stateful1 ref={c => (Stateful2MovedRef = c)} /> <div>2</div> <Stateful2 ref={c => (Stateful1MovedRef = c)} /> </div> ) : ( <div> <div>1</div> <Stateful1 key="a" ref={c => (Stateful1Ref = c)} /> <div>2</div> <Stateful2 key="b" ref={c => (Stateful2Ref = c)} /> </div> ); } const expectedHtml = div([ div(1), div('Stateful1'), div(2), div('Stateful2') ]); ops = []; render(<Foo unkeyed={false} />, scratch); expect(scratch.innerHTML).to.equal(expectedHtml); expect(ops).to.deep.equal(['Mount Stateful1', 'Mount Stateful2']); expect(Stateful1Ref).to.exist; expect(Stateful2Ref).to.exist; ops = []; render(<Foo unkeyed />, scratch); expect(scratch.innerHTML).to.equal(expectedHtml); expect(ops).to.deep.equal([ 'Unmount Stateful1', 'Unmount Stateful2', 'Mount Stateful1', 'Mount Stateful2' ]); expect(Stateful1MovedRef).to.not.equal(Stateful1Ref); expect(Stateful2MovedRef).to.not.equal(Stateful2Ref); ops = []; render(<Foo unkeyed={false} />, scratch); expect(scratch.innerHTML).to.equal(expectedHtml); expect(ops).to.deep.equal([ 'Unmount Stateful1', 'Unmount Stateful2', 'Mount Stateful1', 'Mount Stateful2' ]); expect(Stateful1Ref).to.not.equal(Stateful1MovedRef); expect(Stateful2Ref).to.not.equal(Stateful2MovedRef); }); it('should handle full reorders', () => { const keys = { Apple: `Apple_1`, Orange: `Orange_1`, Banana: `Banana_1`, Grape: `Grape_1`, Kiwi: `Kiwi_1`, Cherry: `Cherry_1` }; let sort; class App extends Component { order; state = { items: ['Apple', 'Grape', 'Cherry', 'Orange', 'Banana'] }; sort() { this.order = this.order === 'ASC' ? 'DESC' : 'ASC'; const items = [...this.state.items].sort((a, b) => this.order === 'ASC' ? a.localeCompare(b) : b.localeCompare(a) ); this.setState({ items }); } render(_, { items }) { sort = this.sort.bind(this); return ( <div> {items.map(item => ( <div key={keys[item]}>{item}</div> ))} </div> ); } } const expected = values => { return values.map(key => `<div>${key}</div>`).join(''); }; render(<App />, scratch); expect(scratch.innerHTML).to.eq( `<div>${expected(['Apple', 'Grape', 'Cherry', 'Orange', 'Banana'])}</div>` ); let sorted = ['Apple', 'Grape', 'Cherry', 'Orange', 'Banana'].sort((a, b) => a.localeCompare(b) ); sort(); rerender(); expect(scratch.innerHTML).to.eq(`<div>${expected(sorted)}</div>`); sorted = ['Apple', 'Grape', 'Cherry', 'Orange', 'Banana'].sort((a, b) => b.localeCompare(a) ); sort(); rerender(); expect(scratch.innerHTML).to.eq(`<div>${expected(sorted)}</div>`); }); it('should handle keyed replacements', () => { const actions = []; class Comp extends Component { componentDidMount() { actions.push('mounted ' + this.props.i); } render() { return <div>Hello</div>; } } const App = props => { return ( <div> <Comp key={props.y} i={1} /> {false} <Comp i={2} /> <Comp i={3} /> </div> ); }; render(<App y="1" />, scratch); expect(actions).to.deep.equal(['mounted 1', 'mounted 2', 'mounted 3']); render(<App y="2" />, scratch); expect(actions).to.deep.equal([ 'mounted 1', 'mounted 2', 'mounted 3', 'mounted 1' ]); }); it('should handle hole prepend', () => { const actions = []; class Comp extends Component { componentDidMount() { actions.push('mounted ' + this.props.i); } render() { return <div>Hello</div>; } } const App = props => { return props.y === '2' ? ( <div> <Comp key={1} i={1} /> <Comp key={2} i={2} /> <Comp key={3} i={3} /> </div> ) : ( <div> {null} <Comp key={1} i={1} /> <Comp key={2} i={2} /> <Comp key={3} i={3} /> </div> ); }; render(<App y="1" />, scratch); expect(actions).to.deep.equal(['mounted 1', 'mounted 2', 'mounted 3']); render(<App y="2" />, scratch); expect(actions).to.deep.equal(['mounted 1', 'mounted 2', 'mounted 3']); }); it('should handle hole replace', () => { const actions = []; class Comp extends Component { componentDidMount() { actions.push('mounted ' + this.props.i); } render() { return <div>Hello</div>; } } const App = props => { return props.y === '1' ? ( <div> <Comp key={1} i={1} /> <Comp key={2} i={2} /> <Comp key={3} i={3} /> </div> ) : ( <div> <Comp key={1} i={1} /> {null} <Comp key={3} i={3} /> </div> ); }; render(<App y="1" />, scratch); expect(actions).to.deep.equal(['mounted 1', 'mounted 2', 'mounted 3']); render(<App y="2" />, scratch); expect(actions).to.deep.equal(['mounted 1', 'mounted 2', 'mounted 3']); render(<App y="1" />, scratch); expect(actions).to.deep.equal([ 'mounted 1', 'mounted 2', 'mounted 3', 'mounted 2' ]); }); it('should handle hole insert', () => { const actions = []; class Comp extends Component { componentDidMount() { actions.push('mounted ' + this.props.i); } render() { return <div>Hello</div>; } } const App = props => { return props.y === '2' ? ( <div> <Comp key={1} i={1} /> <Comp key={2} i={2} /> <Comp key={3} i={3} /> </div> ) : ( <div> <Comp key={1} i={1} /> <Comp key={2} i={2} /> {null} <Comp key={3} i={3} /> </div> ); }; render(<App y="1" />, scratch); expect(actions).to.deep.equal(['mounted 1', 'mounted 2', 'mounted 3']); render(<App y="2" />, scratch); expect(actions).to.deep.equal(['mounted 1', 'mounted 2', 'mounted 3']); }); it('should handle hole apppend', () => { const actions = []; class Comp extends Component { componentDidMount() { actions.push('mounted ' + this.props.i); } render() { return <div>Hello</div>; } } const App = props => { return props.y === '2' ? ( <div> <Comp key={1} i={1} /> <Comp key={2} i={2} /> <Comp key={3} i={3} /> </div> ) : ( <div> <Comp key={1} i={1} /> <Comp key={2} i={2} /> <Comp key={3} i={3} /> {null} </div> ); }; render(<App y="1" />, scratch); expect(actions).to.deep.equal(['mounted 1', 'mounted 2', 'mounted 3']); render(<App y="2" />, scratch); expect(actions).to.deep.equal(['mounted 1', 'mounted 2', 'mounted 3']); }); // Issue #4973: Test growing list diff it('should correctly diff a growing list of keyed children', () => { let values = [0, 1, 2, 3, 4]; render(<List values={values} />, scratch); expect(scratch.textContent).to.equal('01234'); values = [2, 3, 4, 5, 6]; clearLog(); render(<List values={values} />, scratch); expect(scratch.textContent).to.equal('23456'); expect(getLog()).to.deep.equal([ '<li>0.remove()', '<li>1.remove()', '<li>.appendChild(#text)', '<ol>234.appendChild(<li>5)', '<li>.appendChild(#text)', '<ol>2345.appendChild(<li>6)' ]); }); // https://github.com/preactjs/preact/issues/5065 it('should maintain correct DOM order with conditional ContextProvider and inner keys', () => { const Ctx = createContext(null); class Label extends Component { render({ todo }) { return <li key={todo.text}>{todo.text}</li>; } } class App extends Component { render() { const { todos } = this.props; return ( <ul> {todos.map((todo, index) => todo.text === '1' || todo.text === '2' || todo.text === '3' ? ( <Ctx.Provider value={null}> <Label todo={todo} index={index} /> </Ctx.Provider> ) : ( <Label todo={todo} index={index} /> ) )} </ul> ); } } // Initial: 3 collapsed rows render( <App todos={[{ text: '1' }, { text: '2' }, { text: '3' }]} />, scratch ); expect(scratch.querySelector('ul').textContent).to.equal('123'); // Expand row 1: adds sub-items 1A, 1B, 1C (not wrapped in Provider) render( <App todos={[ { text: '1' }, { text: '1A' }, { text: '1B' }, { text: '1C' }, { text: '2' }, { text: '3' } ]} />, scratch ); expect(scratch.querySelector('ul').textContent).to.equal('11A1B1C23'); // Expand row 2: adds sub-items 2A, 2B, 2C render( <App todos={[ { text: '1' }, { text: '1A' }, { text: '1B' }, { text: '1C' }, { text: '2' }, { text: '2A' }, { text: '2B' }, { text: '2C' }, { text: '3' } ]} />, scratch ); expect(scratch.querySelector('ul').textContent).to.equal('11A1B1C22A2B2C3'); // Collapse row 1: removes 1A, 1B, 1C render( <App todos={[ { text: '1' }, { text: '2' }, { text: '2A' }, { text: '2B' }, { text: '2C' }, { text: '3' } ]} />, scratch ); expect(scratch.querySelector('ul').textContent).to.equal('122A2B2C3'); }); // https://github.com/preactjs/preact/issues/5065 it('should maintain correct DOM order when collapsing row 2 with conditional Provider', () => { const Ctx = createContext(null); class Label extends Component { render({ todo }) { return <li key={todo.text}>{todo.text}</li>; } } class App extends Component { render() { const { todos } = this.props; return ( <ul> {todos.map((todo, index) => todo.text === '1' || todo.text === '2' || todo.text === '3' ? ( <Ctx.Provider value={null}> <Label todo={todo} index={index} /> </Ctx.Provider> ) : ( <Label todo={todo} index={index} /> ) )} </ul> ); } } // Both rows expanded render( <App todos={[ { text: '1' }, { text: '1A' }, { text: '1B' }, { text: '1C' }, { text: '2' }, { text: '2A' }, { text: '2B' }, { text: '2C' }, { text: '3' } ]} />, scratch ); expect(scratch.querySelector('ul').textContent).to.equal('11A1B1C22A2B2C3'); // Collapse row 2: removes 2A, 2B, 2C render( <App todos={[ { text: '1' }, { text: '1A' }, { text: '1B' }, { text: '1C' }, { text: '2' }, { text: '3' } ]} />, scratch ); expect(scratch.querySelector('ul').textContent).to.equal('11A1B1C23'); }); // https://github.com/preactjs/preact/issues/5065 it('should maintain correct DOM order expanding then collapsing with conditional Provider', () => { const Ctx = createContext(null); class Label extends Component { render({ todo }) { return <li key={todo.text}>{todo.text}</li>; } } class App extends Component { render() { const { todos } = this.props; return ( <ul> {todos.map((todo, index) => todo.text === '1' || todo.text === '2' || todo.text === '3' ? ( <Ctx.Provider value={null}> <Label todo={todo} index={index} /> </Ctx.Provider> ) : ( <Label todo={todo} index={index} /> ) )} </ul> ); } } // Initial: 3 rows collapsed render( <App todos={[{ text: '1' }, { text: '2' }, { text: '3' }]} />, scratch ); expect(scratch.querySelector('ul').textContent).to.equal('123'); // Expand row 1 render( <App todos={[ { text: '1' }, { text: '1A' }, { text: '1B' }, { text: '1C' }, { text: '2' }, { text: '3' } ]} />, scratch ); expect(scratch.querySelector('ul').textContent).to.equal('11A1B1C23'); // Collapse row 1 render( <App todos={[{ text: '1' }, { text: '2' }, { text: '3' }]} />, scratch ); expect(scratch.querySelector('ul').textContent).to.equal('123'); }); });