/
githubmirror
/
preact
Обзор
Документация
Войти
/
githubmirror
/
preact
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
test/browser/hydrate.test.jsx
612 строк
15 KB
Jovi De Croock
Fix reusing template element content
05 авг 2026, 18:06
05 авг 2026, 18:06
b6d320a
Код
Авторство
О чём код?
import { createElement, hydrate, Fragment, Component } from 'preact'; import { setupRerender } from 'preact/test-utils'; import { setupScratch, teardown, sortAttributes, serializeHtml, spyOnElementAttributes, createEvent } from '../_util/helpers'; import { ul, li, div } from '../_util/dom'; import { logCall, clearLog, getLog } from '../_util/logCall'; import { vi } from 'vitest'; describe('hydrate()', () => { /** @type {HTMLElement} */ let scratch; let attributesSpy; const List = ({ children }) => <ul>{children}</ul>; const ListItem = ({ children, onClick = null }) => ( <li onClick={onClick}>{children}</li> ); let resetAppendChild; let resetInsertBefore; let resetRemove; let resetRemoveText; let resetRemoveComment; let resetSetAttribute; let resetRemoveAttribute; let rerender; beforeAll(() => { resetAppendChild = logCall(Element.prototype, 'appendChild'); resetInsertBefore = logCall(Element.prototype, 'insertBefore'); resetRemove = logCall(Element.prototype, 'remove'); resetRemoveComment = logCall(Comment.prototype, 'remove'); resetRemoveText = logCall(Text.prototype, 'remove'); resetSetAttribute = logCall(Element.prototype, 'setAttribute'); resetRemoveAttribute = logCall(Element.prototype, 'removeAttribute'); }); afterAll(() => { resetAppendChild(); resetInsertBefore(); resetRemove(); resetRemoveText(); resetSetAttribute(); resetRemoveAttribute(); resetRemoveComment(); }); beforeEach(() => { rerender = setupRerender(); scratch = setupScratch(); attributesSpy = spyOnElementAttributes(); clearLog(); }); afterEach(() => { teardown(scratch); }); // Test for preactjs/preact#4340 it('should respect defaultValue in hydrate', () => { scratch.innerHTML = '<input value="foo">'; hydrate(<input defaultValue="foo" />, scratch); expect(scratch.firstChild.value).to.equal('foo'); }); // Test for preactjs/preact#5080 it('should respect textarea defaultValue in hydrate', () => { scratch.innerHTML = '<textarea>foo</textarea>'; hydrate(<textarea defaultValue="foo" />, scratch); expect(scratch.firstChild.value).to.equal('foo'); expect(scratch.firstChild.defaultValue).to.equal('foo'); }); it('should respect textarea value in hydrate', () => { scratch.innerHTML = '<textarea>foo</textarea>'; hydrate(<textarea value="foo" />, scratch); expect(scratch.firstChild.value).to.equal('foo'); }); it('should respect defaultChecked in hydrate', () => { scratch.innerHTML = '<input checked="true">'; hydrate(<input defaultChecked />, scratch); expect(scratch.firstChild.checked).to.equal(true); }); it('should reuse existing DOM', () => { const onClickSpy = vi.fn(); const html = ul([li('1'), li('2'), li('3')]); scratch.innerHTML = html; clearLog(); hydrate( <ul> <li>1</li> <li>2</li> <li onClick={onClickSpy}>3</li> </ul>, scratch ); expect(scratch.innerHTML).to.equal(html); expect(getLog()).to.deep.equal([]); expect(onClickSpy).not.toHaveBeenCalled(); scratch.querySelector('li:last-child').dispatchEvent(createEvent('click')); expect(onClickSpy).toHaveBeenCalledOnce(); }); it('should skip comment nodes between dom nodes', () => { scratch.innerHTML = '<p><i>0</i><!-- c --><b>1</b></p>'; hydrate( <p> <i>0</i> <b>1</b> </p>, scratch ); expect(scratch.innerHTML).to.equal('<p><i>0</i><b>1</b></p>'); expect(getLog()).to.deep.equal(['Comment.remove()']); }); it('should reuse existing DOM when given components', () => { const onClickSpy = vi.fn(); const html = ul([li('1'), li('2'), li('3')]); scratch.innerHTML = html; clearLog(); hydrate( <List> <ListItem>1</ListItem> <ListItem>2</ListItem> <ListItem onClick={onClickSpy}>3</ListItem> </List>, scratch ); expect(scratch.innerHTML).to.equal(html); expect(getLog()).to.deep.equal([]); expect(onClickSpy).not.toHaveBeenCalled(); scratch.querySelector('li:last-child').dispatchEvent(createEvent('click')); expect(onClickSpy).toHaveBeenCalledOnce(); }); it('should properly set event handlers to existing DOM when given components', () => { const proto = Element.prototype; vi.spyOn(proto, 'addEventListener'); const clickHandlers = [vi.fn(), vi.fn(), vi.fn()]; const html = ul([li('1'), li('2'), li('3')]); scratch.innerHTML = html; clearLog(); hydrate( <List> <ListItem onClick={clickHandlers[0]}>1</ListItem> <ListItem onClick={clickHandlers[1]}>2</ListItem> <ListItem onClick={clickHandlers[2]}>3</ListItem> </List>, scratch ); expect(scratch.innerHTML).to.equal(html); expect(getLog()).to.deep.equal([]); expect(proto.addEventListener).toHaveBeenCalledTimes(3); expect(clickHandlers[2]).not.toHaveBeenCalled(); scratch.querySelector('li:last-child').dispatchEvent(createEvent('click')); expect(clickHandlers[2]).toHaveBeenCalledTimes(1); }); it('should add missing nodes to existing DOM when hydrating', () => { const html = ul([li('1')]); scratch.innerHTML = html; clearLog(); hydrate( <List> <ListItem>1</ListItem> <ListItem>2</ListItem> <ListItem>3</ListItem> </List>, scratch ); expect(scratch.innerHTML).to.equal(ul([li('1'), li('2'), li('3')])); expect(getLog()).to.deep.equal([ '<li>.appendChild(#text)', '<ul>1.appendChild(<li>2)', '<li>.appendChild(#text)', '<ul>12.appendChild(<li>3)' ]); }); it('should remove extra nodes from existing DOM when hydrating', () => { const html = ul([li('1'), li('2'), li('3'), li('4')]); scratch.innerHTML = html; clearLog(); hydrate( <List> <ListItem>1</ListItem> <ListItem>2</ListItem> <ListItem>3</ListItem> </List>, scratch ); expect(scratch.innerHTML).to.equal(ul([li('1'), li('2'), li('3')])); expect(getLog()).to.deep.equal(['<li>4.remove()']); }); it('should not update attributes on existing DOM', () => { scratch.innerHTML = '<div><span before-hydrate="test" same-value="foo" different-value="a">Test</span></div>'; let vnode = ( <div> <span same-value="foo" different-value="b" new-value="c"> Test </span> </div> ); clearLog(); hydrate(vnode, scratch); expect(attributesSpy).not.toHaveBeenCalled(); expect(serializeHtml(scratch)).to.equal( sortAttributes( '<div><span before-hydrate="test" different-value="a" same-value="foo">Test</span></div>' ) ); expect(getLog()).to.deep.equal([]); }); it('should update class attribute via className prop', () => { scratch.innerHTML = '<div class="foo">bar</div>'; hydrate(<div className="foo">bar</div>, scratch); expect(scratch.innerHTML).to.equal('<div class="foo">bar</div>'); }); it('should correctly hydrate with Fragments', () => { const html = ul([li('1'), li('2'), li('3'), li('4')]); scratch.innerHTML = html; clearLog(); const clickHandlers = [vi.fn(), vi.fn(), vi.fn(), vi.fn()]; hydrate( <List> <ListItem onClick={clickHandlers[0]}>1</ListItem> <Fragment> <ListItem onClick={clickHandlers[1]}>2</ListItem> <ListItem onClick={clickHandlers[2]}>3</ListItem> </Fragment> <ListItem onClick={clickHandlers[3]}>4</ListItem> </List>, scratch ); expect(scratch.innerHTML).to.equal(html); expect(getLog()).to.deep.equal([]); expect(clickHandlers[2]).not.toHaveBeenCalled(); scratch .querySelector('li:nth-child(3)') .dispatchEvent(createEvent('click')); expect(clickHandlers[2]).toHaveBeenCalledOnce(); }); it('should correctly hydrate root Fragments', () => { const html = [ ul([li('1'), li('2'), li('3'), li('4')]), div('sibling') ].join(''); scratch.innerHTML = html; clearLog(); const clickHandlers = [vi.fn(), vi.fn(), vi.fn(), vi.fn(), vi.fn()]; hydrate( <Fragment> <List> <Fragment> <ListItem onClick={clickHandlers[0]}>1</ListItem> <ListItem onClick={clickHandlers[1]}>2</ListItem> </Fragment> <ListItem onClick={clickHandlers[2]}>3</ListItem> <ListItem onClick={clickHandlers[3]}>4</ListItem> </List> <div onClick={clickHandlers[4]}>sibling</div> </Fragment>, scratch ); expect(scratch.innerHTML).to.equal(html); expect(getLog()).to.deep.equal([]); expect(clickHandlers[2]).not.toHaveBeenCalled(); scratch .querySelector('li:nth-child(3)') .dispatchEvent(createEvent('click')); expect(clickHandlers[2]).toHaveBeenCalledTimes(1); expect(clickHandlers[4]).not.toHaveBeenCalled(); scratch.querySelector('div').dispatchEvent(createEvent('click')); expect(clickHandlers[2]).toHaveBeenCalledTimes(1); expect(clickHandlers[4]).toHaveBeenCalledTimes(1); }); it('should override incorrect pre-existing DOM with VNodes passed into render', () => { const initialHtml = [ div('sibling'), ul([li('1'), li('4'), li('3'), li('2')]) ].join(''); scratch.innerHTML = initialHtml; clearLog(); hydrate( <Fragment> <List> <Fragment> <ListItem>1</ListItem> <ListItem>2</ListItem> </Fragment> <ListItem>3</ListItem> <ListItem>4</ListItem> </List> <div>sibling</div> </Fragment>, scratch ); const finalHtml = [ ul([li('1'), li('2'), li('3'), li('4')]), div('sibling') ].join(''); expect(scratch.innerHTML).to.equal(finalHtml); expect(getLog()).to.deep.equal([ '<div>sibling1234.insertBefore(<ul>1234, <div>sibling)' ]); }); it('should not merge attributes with node created by the DOM', () => { const html = htmlString => { const div = document.createElement('div'); div.innerHTML = htmlString; return div.firstChild; }; // prettier-ignore const DOMElement = html`<div><a foo="bar"></a></div>`; scratch.appendChild(DOMElement); const preactElement = ( <div> <a /> </div> ); hydrate(preactElement, scratch); expect(attributesSpy).not.toHaveBeenCalled(); expect(scratch).to.have.property( 'innerHTML', '<div><a foo="bar"></a></div>' ); }); it('should attach event handlers', () => { let spy = vi.fn(); scratch.innerHTML = '<span>Test</span>'; let vnode = <span onClick={spy}>Test</span>; hydrate(vnode, scratch); scratch.firstChild.click(); expect(spy).toHaveBeenCalledOnce(); }); // #2237 it('should not redundantly add text nodes', () => { scratch.innerHTML = '<div id="test"><p>hello bar</p></div>'; const element = document.getElementById('test'); const Component = props => <p>hello {props.foo}</p>; hydrate(<Component foo="bar" />, element); expect(element.innerHTML).to.equal('<p>hello bar</p>'); }); it('should not remove values', () => { scratch.innerHTML = '<select><option value="0">Zero</option><option selected value="2">Two</option></select>'; const App = () => { const options = [ { value: '0', label: 'Zero' }, { value: '2', label: 'Two' } ]; return ( <select value="2"> {options.map(({ disabled, label, value }) => ( <option key={label} disabled={disabled} value={value}> {label} </option> ))} </select> ); }; hydrate(<App />, scratch); expect(sortAttributes(scratch.innerHTML)).to.equal( sortAttributes( '<select><option value="0">Zero</option><option selected="" value="2">Two</option></select>' ) ); }); it('should deopt for trees introduced in hydrate (append)', () => { scratch.innerHTML = '<div id="test"><p class="hi">hello bar</p></div>'; const Component = props => <p class="hi">hello {props.foo}</p>; const element = document.getElementById('test'); hydrate( <Fragment> <Component foo="bar" /> <Component foo="baz" /> </Fragment>, element ); expect(element.innerHTML).to.equal( '<p class="hi">hello bar</p><p class="hi">hello baz</p>' ); }); it('should deopt for trees introduced in hydrate (insert before)', () => { scratch.innerHTML = '<div id="test"><p class="hi">hello bar</p></div>'; const Component = props => <p class="hi">hello {props.foo}</p>; const element = document.getElementById('test'); hydrate( <Fragment> <Component foo="baz" /> <Component foo="bar" /> </Fragment>, element ); expect(element.innerHTML).to.equal( '<p class="hi">hello baz</p><p class="hi">hello bar</p>' ); }); it('should skip comment nodes', () => { scratch.innerHTML = '<p>hello <!-- c -->foo</p>'; hydrate(<p>hello {'foo'}</p>, scratch); expect(scratch.innerHTML).to.equal('<p>hello foo</p>'); expect(getLog()).to.deep.equal(['Comment.remove()']); }); it('should skip over multiple comment nodes', () => { scratch.innerHTML = '<p>hello <!-- a --><!-- b -->foo</p>'; hydrate(<p>hello {'foo'}</p>, scratch); expect(scratch.innerHTML).to.equal('<p>hello foo</p>'); expect(getLog()).to.deep.equal(['Comment.remove()', 'Comment.remove()']); }); it('should work with error boundaries', () => { scratch.innerHTML = '<div>Hello, World!</div>'; class Root extends Component { constructor() { super(); this.state = {}; } componentDidCatch(error) { this.setState({ error }); } render() { if (!this.state.error) { return <App />; } else { return <div>Error!</div>; } } } function App() { throw Error(); return createElement('div', {}, 'Hello, World!'); } hydrate(<Root />, scratch); rerender(); expect(scratch.innerHTML).to.equal('<div>Error!</div>'); }); it('should not crash when hydrating a suspending component without excess DOM children', () => { const promise = Promise.resolve(); let caught; class Boundary extends Component { componentDidCatch(error) { caught = error; this.setState({ error }); } render() { return this.state && this.state.error ? ( <div>Loading</div> ) : ( <textarea defaultValue=""> <Suspender /> </textarea> ); } } function Suspender() { throw promise; } scratch.innerHTML = '<textarea></textarea>'; expect(() => { hydrate(<Boundary />, scratch); }).to.not.throw(); expect(caught).to.equal(promise); }); it('should pass the original error to boundaries when hydrating without excess DOM children', () => { let caught; const error = new Error('real error'); class Boundary extends Component { componentDidCatch(error) { caught = error; this.setState({ error }); } render() { return this.state && this.state.error ? <div>Error!</div> : <Wrapper />; } } function Wrapper() { return ( <textarea defaultValue=""> <Boom /> </textarea> ); } function Boom() { throw error; } scratch.innerHTML = '<textarea></textarea>'; hydrate(<Boundary />, scratch); rerender(); expect(caught).to.equal(error); expect(caught.message).to.equal('real error'); }); it('should hydrate <template> tags', () => { function App() { return ( <template> <h1>it works</h1> </template> ); } scratch.innerHTML = '<template><h1>it works</h1></template>'; const h1 = scratch.firstChild.content.firstChild; hydrate(<App />, scratch); expect(scratch.innerHTML).to.equal( '<template><h1>it works</h1></template>' ); expect(scratch.firstChild.content.firstChild).to.equal(h1); }); });