diff options
Diffstat (limited to 'preact/compat/test/browser/suspense-hydration.test.js')
-rw-r--r-- | preact/compat/test/browser/suspense-hydration.test.js | 778 |
1 files changed, 778 insertions, 0 deletions
diff --git a/preact/compat/test/browser/suspense-hydration.test.js b/preact/compat/test/browser/suspense-hydration.test.js new file mode 100644 index 0000000..b8d45f8 --- /dev/null +++ b/preact/compat/test/browser/suspense-hydration.test.js @@ -0,0 +1,778 @@ +import { setupRerender } from 'preact/test-utils'; +import React, { + createElement, + hydrate, + Fragment, + Suspense, + useState +} from 'preact/compat'; +import { logCall, getLog, clearLog } from '../../../test/_util/logCall'; +import { + createEvent, + setupScratch, + teardown +} from '../../../test/_util/helpers'; +import { ul, li, div } from '../../../test/_util/dom'; +import { createLazy } from './suspense-utils'; + +/* eslint-env browser, mocha */ +describe('suspense hydration', () => { + /** @type {HTMLDivElement} */ + let scratch, + rerender, + unhandledEvents = []; + + const List = ({ children }) => <ul>{children}</ul>; + const ListItem = ({ children, onClick = null }) => ( + <li onClick={onClick}>{children}</li> + ); + + function onUnhandledRejection(event) { + unhandledEvents.push(event); + } + + let resetAppendChild; + let resetInsertBefore; + let resetRemoveChild; + let resetRemove; + + before(() => { + resetAppendChild = logCall(Element.prototype, 'appendChild'); + resetInsertBefore = logCall(Element.prototype, 'insertBefore'); + resetRemoveChild = logCall(Element.prototype, 'removeChild'); + resetRemove = logCall(Element.prototype, 'remove'); + }); + + after(() => { + resetAppendChild(); + resetInsertBefore(); + resetRemoveChild(); + resetRemove(); + }); + + beforeEach(() => { + scratch = setupScratch(); + rerender = setupRerender(); + + unhandledEvents = []; + if ('onunhandledrejection' in window) { + window.addEventListener('unhandledrejection', onUnhandledRejection); + } + }); + + afterEach(() => { + teardown(scratch); + + if ('onunhandledrejection' in window) { + window.removeEventListener('unhandledrejection', onUnhandledRejection); + + if (unhandledEvents.length) { + throw unhandledEvents[0].reason; + } + } + }); + + it('should leave DOM untouched when suspending while hydrating', () => { + scratch.innerHTML = '<div>Hello</div>'; + clearLog(); + + const [Lazy, resolve] = createLazy(); + hydrate( + <Suspense> + <Lazy /> + </Suspense>, + scratch + ); + rerender(); // Flush rerender queue to mimic what preact will really do + expect(scratch.innerHTML).to.equal('<div>Hello</div>'); + expect(getLog()).to.deep.equal([]); + clearLog(); + + return resolve(() => <div>Hello</div>).then(() => { + rerender(); + expect(scratch.innerHTML).to.equal('<div>Hello</div>'); + expect(getLog()).to.deep.equal([]); + clearLog(); + }); + }); + + it('should properly attach event listeners when suspending while hydrating', () => { + scratch.innerHTML = '<div>Hello</div><div>World</div>'; + clearLog(); + + const helloListener = sinon.spy(); + const worldListener = sinon.spy(); + + const [Lazy, resolve] = createLazy(); + hydrate( + <Suspense> + <Lazy /> + <div onClick={worldListener}>World!</div> + </Suspense>, + scratch + ); + rerender(); // Flush rerender queue to mimic what preact will really do + expect(scratch.innerHTML).to.equal('<div>Hello</div><div>World!</div>'); + expect(getLog()).to.deep.equal([]); + clearLog(); + + scratch.querySelector('div:last-child').dispatchEvent(createEvent('click')); + expect(worldListener, 'worldListener 1').to.have.been.calledOnce; + + return resolve(() => <div onClick={helloListener}>Hello</div>).then(() => { + rerender(); + expect(scratch.innerHTML).to.equal('<div>Hello</div><div>World!</div>'); + expect(getLog()).to.deep.equal([]); + + scratch + .querySelector('div:first-child') + .dispatchEvent(createEvent('click')); + expect(helloListener, 'helloListener').to.have.been.calledOnce; + + scratch + .querySelector('div:last-child') + .dispatchEvent(createEvent('click')); + expect(worldListener, 'worldListener 2').to.have.been.calledTwice; + + clearLog(); + }); + }); + + it('should allow siblings to update around suspense boundary', () => { + scratch.innerHTML = '<div>Count: 0</div><div>Hello</div>'; + clearLog(); + + const [Lazy, resolve] = createLazy(); + + /** @type {() => void} */ + let increment; + function Counter() { + const [count, setCount] = useState(0); + increment = () => setCount(c => c + 1); + return <div>Count: {count}</div>; + } + + hydrate( + <Fragment> + <Counter /> + <Suspense> + <Lazy /> + </Suspense> + </Fragment>, + scratch + ); + rerender(); // Flush rerender queue to mimic what preact will really do + expect(scratch.innerHTML).to.equal('<div>Count: 0</div><div>Hello</div>'); + // Re: DOM OP below - Known issue with hydrating merged text nodes + expect(getLog()).to.deep.equal(['<div>Count: .appendChild(#text)']); + clearLog(); + + increment(); + rerender(); + + expect(scratch.innerHTML).to.equal('<div>Count: 1</div><div>Hello</div>'); + expect(getLog()).to.deep.equal([]); + clearLog(); + + return resolve(() => <div>Hello</div>).then(() => { + rerender(); + expect(scratch.innerHTML).to.equal('<div>Count: 1</div><div>Hello</div>'); + expect(getLog()).to.deep.equal([]); + clearLog(); + }); + }); + + it('should allow parents to update around suspense boundary and unmount', async () => { + scratch.innerHTML = '<div>Count: 0</div><div>Hello</div>'; + clearLog(); + + const [Lazy, resolve] = createLazy(); + + /** @type {() => void} */ + let increment; + function Counter() { + const [count, setCount] = useState(0); + increment = () => setCount(c => c + 1); + return ( + <Fragment> + <div>Count: {count}</div> + <Suspense> + <Lazy /> + </Suspense> + </Fragment> + ); + } + + let hide; + function Component() { + const [show, setShow] = useState(true); + hide = () => setShow(false); + + return show ? <Counter /> : null; + } + + hydrate(<Component />, scratch); + rerender(); // Flush rerender queue to mimic what preact will really do + expect(scratch.innerHTML).to.equal('<div>Count: 0</div><div>Hello</div>'); + // Re: DOM OP below - Known issue with hydrating merged text nodes + expect(getLog()).to.deep.equal(['<div>Count: .appendChild(#text)']); + clearLog(); + + increment(); + rerender(); + + expect(scratch.innerHTML).to.equal('<div>Count: 1</div><div>Hello</div>'); + expect(getLog()).to.deep.equal([]); + clearLog(); + + await resolve(() => <div>Hello</div>); + rerender(); + expect(scratch.innerHTML).to.equal('<div>Count: 1</div><div>Hello</div>'); + expect(getLog()).to.deep.equal([]); + clearLog(); + + hide(); + rerender(); + expect(scratch.innerHTML).to.equal(''); + }); + + it('should allow parents to update around suspense boundary and unmount before resolves', async () => { + scratch.innerHTML = '<div>Count: 0</div><div>Hello</div>'; + clearLog(); + + const [Lazy] = createLazy(); + + /** @type {() => void} */ + let increment; + function Counter() { + const [count, setCount] = useState(0); + increment = () => setCount(c => c + 1); + return ( + <Fragment> + <div>Count: {count}</div> + <Suspense> + <Lazy /> + </Suspense> + </Fragment> + ); + } + + let hide; + function Component() { + const [show, setShow] = useState(true); + hide = () => setShow(false); + + return show ? <Counter /> : null; + } + + hydrate(<Component />, scratch); + rerender(); // Flush rerender queue to mimic what preact will really do + expect(scratch.innerHTML).to.equal('<div>Count: 0</div><div>Hello</div>'); + // Re: DOM OP below - Known issue with hydrating merged text nodes + expect(getLog()).to.deep.equal(['<div>Count: .appendChild(#text)']); + clearLog(); + + increment(); + rerender(); + + expect(scratch.innerHTML).to.equal('<div>Count: 1</div><div>Hello</div>'); + expect(getLog()).to.deep.equal([]); + clearLog(); + + hide(); + rerender(); + expect(scratch.innerHTML).to.equal(''); + }); + + it('should allow parents to unmount before resolves', async () => { + scratch.innerHTML = '<div>Count: 0</div><div>Hello</div>'; + + const [Lazy] = createLazy(); + + function Counter() { + return ( + <Fragment> + <div>Count: 0</div> + <Suspense> + <Lazy /> + </Suspense> + </Fragment> + ); + } + + let hide; + function Component() { + const [show, setShow] = useState(true); + hide = () => setShow(false); + + return show ? <Counter /> : null; + } + + hydrate(<Component />, scratch); + rerender(); // Flush rerender queue to mimic what preact will really do + + hide(); + rerender(); + expect(scratch.innerHTML).to.equal(''); + }); + + it('should properly hydrate when there is DOM and Components between Suspense and suspender', () => { + scratch.innerHTML = '<div><div>Hello</div></div>'; + clearLog(); + + const [Lazy, resolve] = createLazy(); + hydrate( + <Suspense> + <div> + <Fragment> + <Lazy /> + </Fragment> + </div> + </Suspense>, + scratch + ); + rerender(); // Flush rerender queue to mimic what preact will really do + expect(scratch.innerHTML).to.equal('<div><div>Hello</div></div>'); + expect(getLog()).to.deep.equal([]); + clearLog(); + + return resolve(() => <div>Hello</div>).then(() => { + rerender(); + expect(scratch.innerHTML).to.equal('<div><div>Hello</div></div>'); + expect(getLog()).to.deep.equal([]); + clearLog(); + }); + }); + + it('should properly hydrate suspense with Fragment siblings', () => { + const originalHtml = ul([li(0), li(1), li(2), li(3), li(4)]); + + const listeners = [ + sinon.spy(), + sinon.spy(), + sinon.spy(), + sinon.spy(), + sinon.spy() + ]; + + scratch.innerHTML = originalHtml; + clearLog(); + + const [Lazy, resolve] = createLazy(); + hydrate( + <ul> + <Fragment> + <li onClick={listeners[0]}>0</li> + <li onClick={listeners[1]}>1</li> + </Fragment> + <Suspense> + <Lazy /> + </Suspense> + <Fragment> + <li onClick={listeners[3]}>3</li> + <li onClick={listeners[4]}>4</li> + </Fragment> + </ul>, + scratch + ); + rerender(); // Flush rerender queue to mimic what preact will really do + expect(scratch.innerHTML).to.equal(originalHtml); + expect(getLog()).to.deep.equal([]); + expect(listeners[4]).not.to.have.been.called; + + clearLog(); + scratch.querySelector('li:last-child').dispatchEvent(createEvent('click')); + expect(listeners[4]).to.have.been.calledOnce; + + return resolve(() => ( + <Fragment> + <li onClick={listeners[2]}>2</li> + </Fragment> + )).then(() => { + rerender(); + expect(scratch.innerHTML).to.equal(originalHtml); + expect(getLog()).to.deep.equal([]); + clearLog(); + + scratch + .querySelector('li:nth-child(3)') + .dispatchEvent(createEvent('click')); + expect(listeners[2]).to.have.been.calledOnce; + + scratch + .querySelector('li:last-child') + .dispatchEvent(createEvent('click')); + expect(listeners[4]).to.have.been.calledTwice; + }); + }); + + it('should properly hydrate suspense with Component & Fragment siblings', () => { + const originalHtml = ul([li(0), li(1), li(2), li(3), li(4)]); + + const listeners = [ + sinon.spy(), + sinon.spy(), + sinon.spy(), + sinon.spy(), + sinon.spy() + ]; + + scratch.innerHTML = originalHtml; + clearLog(); + + const [Lazy, resolve] = createLazy(); + hydrate( + <List> + <Fragment> + <ListItem onClick={listeners[0]}>0</ListItem> + <ListItem onClick={listeners[1]}>1</ListItem> + </Fragment> + <Suspense> + <Lazy /> + </Suspense> + <Fragment> + <ListItem onClick={listeners[3]}>3</ListItem> + <ListItem onClick={listeners[4]}>4</ListItem> + </Fragment> + </List>, + scratch + ); + rerender(); // Flush rerender queue to mimic what preact will really do + expect(scratch.innerHTML).to.equal(originalHtml); + expect(getLog()).to.deep.equal([]); + expect(listeners[4]).not.to.have.been.called; + + clearLog(); + scratch.querySelector('li:last-child').dispatchEvent(createEvent('click')); + expect(listeners[4]).to.have.been.calledOnce; + + return resolve(() => ( + <Fragment> + <ListItem onClick={listeners[2]}>2</ListItem> + </Fragment> + )).then(() => { + rerender(); + expect(scratch.innerHTML).to.equal(originalHtml); + expect(getLog()).to.deep.equal([]); + clearLog(); + + scratch + .querySelector('li:nth-child(3)') + .dispatchEvent(createEvent('click')); + expect(listeners[2]).to.have.been.calledOnce; + + scratch + .querySelector('li:last-child') + .dispatchEvent(createEvent('click')); + expect(listeners[4]).to.have.been.calledTwice; + }); + }); + + it('should suspend hydration with components with state and event listeners between suspender and Suspense', () => { + let html = div([div('Count: 0'), div('Hello')]); + scratch.innerHTML = html; + clearLog(); + + function Counter({ children }) { + const [count, setCount] = useState(0); + return ( + <div onClick={() => setCount(count + 1)}> + <div>Count: {count}</div> + {children} + </div> + ); + } + + const [Lazy, resolve] = createLazy(); + hydrate( + <Suspense> + <Counter> + <Lazy /> + </Counter> + </Suspense>, + scratch + ); + rerender(); // Flush rerender queue to mimic what preact will really do + expect(scratch.innerHTML).to.equal(html); + expect(getLog()).to.deep.equal(['<div>Count: .appendChild(#text)']); + clearLog(); + + scratch.firstElementChild.dispatchEvent(createEvent('click')); + rerender(); + + html = div([div('Count: 1'), div('Hello')]); + expect(scratch.innerHTML).to.equal(html); + expect(getLog()).to.deep.equal([]); + clearLog(); + + const lazySpy = sinon.spy(); + return resolve(() => <div onClick={lazySpy}>Hello</div>).then(() => { + rerender(); + expect(scratch.innerHTML).to.equal(html); + expect(getLog()).to.deep.equal([]); + clearLog(); + + const lazyDiv = scratch.firstChild.firstChild.nextSibling; + expect(lazyDiv.textContent).to.equal('Hello'); + expect(lazySpy).not.to.have.been.called; + + lazyDiv.dispatchEvent(createEvent('click')); + rerender(); + + expect(lazySpy).to.have.been.calledOnce; + }); + }); + + it('should maintain state of sibling components around suspender', () => { + let html = [div('Count: 0'), div('Hello'), div('Count: 0')].join(''); + scratch.innerHTML = html; + clearLog(); + + function Counter() { + const [count, setCount] = useState(0); + return <div onClick={() => setCount(count + 1)}>Count: {count}</div>; + } + + const [Lazy, resolve] = createLazy(); + hydrate( + <Suspense> + <Counter /> + <Lazy /> + <Counter /> + </Suspense>, + scratch + ); + rerender(); // Flush rerender queue to mimic what preact will really do + expect(scratch.innerHTML).to.equal(html); + expect(getLog()).to.deep.equal([ + '<div>Count: .appendChild(#text)', + '<div>Count: .appendChild(#text)' + ]); + clearLog(); + + // Update state of first Counter + scratch.firstElementChild.dispatchEvent(createEvent('click')); + rerender(); + + html = [div('Count: 1'), div('Hello'), div('Count: 0')].join(''); + expect(scratch.innerHTML).to.equal(html); + expect(getLog()).to.deep.equal([]); + clearLog(); + + // Update state of second Counter + scratch.lastElementChild.dispatchEvent(createEvent('click')); + rerender(); + + html = [div('Count: 1'), div('Hello'), div('Count: 1')].join(''); + expect(scratch.innerHTML).to.equal(html); + expect(getLog()).to.deep.equal([]); + clearLog(); + + const lazySpy = sinon.spy(); + return resolve(() => <div onClick={lazySpy}>Hello</div>).then(() => { + rerender(); + expect(scratch.innerHTML).to.equal(html); + expect(getLog()).to.deep.equal([]); + clearLog(); + + const lazyDiv = scratch.firstChild.nextSibling; + expect(lazyDiv.textContent).to.equal('Hello'); + expect(lazySpy).not.to.have.been.called; + + lazyDiv.dispatchEvent(createEvent('click')); + rerender(); + + expect(lazySpy).to.have.been.calledOnce; + }); + }); + + it('should allow component to re-suspend using normal suspension mechanics after initial suspended hydration resumes', () => { + const originalHtml = [div('a'), div('b1'), div('c')].join(''); + scratch.innerHTML = originalHtml; + clearLog(); + + const bOnClickSpy = sinon.spy(); + const cOnClickSpy = sinon.spy(); + + const [Lazy, resolve] = createLazy(); + + /** @type {(c: React.JSX.Element) => void} */ + let setChild; + function App() { + // Mimic some state that may cause a suspend + const [child, setChildInternal] = useState(<Lazy />); + setChild = setChildInternal; + + return ( + <Suspense fallback={<div>fallback</div>}> + <div>a</div> + {child} + <div onClick={cOnClickSpy}>c</div> + </Suspense> + ); + } + + // Validate initial hydration suspend resumes (initial markup stays the same + // and event listeners attached) + hydrate(<App />, scratch); + rerender(); // Flush rerender queue to mimic what preact will really do + expect(scratch.innerHTML, 'initial HTML').to.equal(originalHtml); + expect(getLog()).to.deep.equal([]); + clearLog(); + + scratch.lastChild.dispatchEvent(createEvent('click')); + rerender(); + expect(cOnClickSpy).to.have.been.calledOnce; + + return resolve(() => <div onClick={bOnClickSpy}>b1</div>) + .then(() => { + rerender(); + expect(scratch.innerHTML, 'hydration resumes').to.equal(originalHtml); + expect(getLog()).to.deep.equal([]); + clearLog(); + + scratch.firstChild.nextSibling.dispatchEvent(createEvent('click')); + rerender(); + expect(bOnClickSpy).to.have.been.calledOnce; + + // suspend again and validate normal suspension works (fallback renders + // and result) + const [Lazy2, resolve2] = createLazy(); + setChild(<Lazy2 />); + rerender(); + + expect(scratch.innerHTML, 'second suspend').to.equal(div('fallback')); + + return resolve2(() => <div onClick={bOnClickSpy}>b2</div>); + }) + .then(() => { + rerender(); + expect(scratch.innerHTML, 'second suspend resumes').to.equal( + [div('a'), div('b2'), div('c')].join('') + ); + + scratch.lastChild.dispatchEvent(createEvent('click')); + expect(cOnClickSpy).to.have.been.calledTwice; + + scratch.firstChild.nextSibling.dispatchEvent(createEvent('click')); + expect(bOnClickSpy).to.have.been.calledTwice; + }); + }); + + // Currently not supported. Hydration doesn't set attributes... but should it + // when coming back from suspense if props were updated? + it.skip('should hydrate and update attributes with latest props', () => { + const originalHtml = '<p>Count: 0</p><p data-count="0">Lazy count: 0</p>'; + scratch.innerHTML = originalHtml; + clearLog(); + + /** @type {() => void} */ + let increment; + const [Lazy, resolve] = createLazy(); + function App() { + const [count, setCount] = useState(0); + increment = () => setCount(c => c + 1); + + return ( + <Suspense> + <p>Count: {count}</p> + <Lazy count={count} /> + </Suspense> + ); + } + + hydrate(<App />, scratch); + rerender(); // Flush rerender queue to mimic what preact will really do + expect(scratch.innerHTML).to.equal(originalHtml); + // Re: DOM OP below - Known issue with hydrating merged text nodes + expect(getLog()).to.deep.equal(['<p>Count: .appendChild(#text)']); + clearLog(); + + increment(); + rerender(); + + expect(scratch.innerHTML).to.equal( + '<p>Count: 1</p><p data-count="0">Lazy count: 0</p>' + ); + expect(getLog()).to.deep.equal([]); + clearLog(); + + return resolve(({ count }) => ( + <p data-count={count}>Lazy count: {count}</p> + )).then(() => { + rerender(); + expect(scratch.innerHTML).to.equal( + '<p>Count: 1</p><p data-count="1">Lazy count: 1</p>' + ); + // Re: DOM OP below - Known issue with hydrating merged text nodes + expect(getLog()).to.deep.equal(['<p>Lazy count: .appendChild(#text)']); + clearLog(); + }); + }); + + // Currently not supported, but I wrote the test before I realized that so + // leaving it here in case we do support it eventually + it.skip('should properly hydrate suspense when resolves to a Fragment', () => { + const originalHtml = ul([li(0), li(1), li(2), li(3), li(4), li(5)]); + + const listeners = [ + sinon.spy(), + sinon.spy(), + sinon.spy(), + sinon.spy(), + sinon.spy(), + sinon.spy() + ]; + + scratch.innerHTML = originalHtml; + clearLog(); + + const [Lazy, resolve] = createLazy(); + hydrate( + <List> + <Fragment> + <ListItem onClick={listeners[0]}>0</ListItem> + <ListItem onClick={listeners[1]}>1</ListItem> + </Fragment> + <Suspense> + <Lazy /> + </Suspense> + <Fragment> + <ListItem onClick={listeners[4]}>4</ListItem> + <ListItem onClick={listeners[5]}>5</ListItem> + </Fragment> + </List>, + scratch + ); + rerender(); // Flush rerender queue to mimic what preact will really do + expect(scratch.innerHTML).to.equal(originalHtml); + expect(getLog()).to.deep.equal([]); + expect(listeners[5]).not.to.have.been.called; + + clearLog(); + scratch.querySelector('li:last-child').dispatchEvent(createEvent('click')); + expect(listeners[5]).to.have.been.calledOnce; + + return resolve(() => ( + <Fragment> + <ListItem onClick={listeners[2]}>2</ListItem> + <ListItem onClick={listeners[3]}>3</ListItem> + </Fragment> + )).then(() => { + rerender(); + expect(scratch.innerHTML).to.equal(originalHtml); + expect(getLog()).to.deep.equal([]); + clearLog(); + + scratch + .querySelector('li:nth-child(4)') + .dispatchEvent(createEvent('click')); + expect(listeners[3]).to.have.been.calledOnce; + + scratch + .querySelector('li:last-child') + .dispatchEvent(createEvent('click')); + expect(listeners[5]).to.have.been.calledTwice; + }); + }); +}); |