diff options
author | Sebastian <sebasjm@gmail.com> | 2021-08-23 16:46:06 -0300 |
---|---|---|
committer | Sebastian <sebasjm@gmail.com> | 2021-08-23 16:48:30 -0300 |
commit | 38acabfa6089ab8ac469c12b5f55022fb96935e5 (patch) | |
tree | 453dbf70000cc5e338b06201af1eaca8343f8f73 /preact/compat/test | |
parent | f26125e039143b92dc0d84e7775f508ab0cdcaa8 (diff) | |
download | node-vendor-38acabfa6089ab8ac469c12b5f55022fb96935e5.tar.gz node-vendor-38acabfa6089ab8ac469c12b5f55022fb96935e5.tar.bz2 node-vendor-38acabfa6089ab8ac469c12b5f55022fb96935e5.zip |
Diffstat (limited to 'preact/compat/test')
36 files changed, 7131 insertions, 0 deletions
diff --git a/preact/compat/test/browser/Children.test.js b/preact/compat/test/browser/Children.test.js new file mode 100644 index 0000000..4e0c32d --- /dev/null +++ b/preact/compat/test/browser/Children.test.js @@ -0,0 +1,185 @@ +import { + setupScratch, + teardown, + serializeHtml +} from '../../../test/_util/helpers'; +import { div, span } from '../../../test/_util/dom'; +import React, { createElement, Children, render } from 'preact/compat'; + +describe('Children', () => { + /** @type {HTMLDivElement} */ + let scratch; + + beforeEach(() => { + scratch = setupScratch(); + }); + + afterEach(() => { + teardown(scratch); + }); + + describe('.count', () => { + let count; + function Foo(props) { + count = Children.count(props.children); + return <div>{count}</div>; + } + + it('should return 0 for no children', () => { + render(<Foo />, scratch); + expect(count).to.equal(0); + }); + + it('should return number of children', () => { + render( + <Foo> + <div /> + foo + </Foo>, + scratch + ); + expect(count).to.equal(2); + }); + }); + + describe('.only', () => { + let actual; + function Foo(props) { + actual = Children.only(props.children); + return <div>{actual}</div>; + } + + it('should only allow 1 child', () => { + render(<Foo>foo</Foo>, scratch); + expect(actual).to.equal('foo'); + }); + + it('should throw if no children are passed', () => { + // eslint-disable-next-line prefer-arrow-callback + expect(function() { + render(<Foo />, scratch); + }).to.throw(); + }); + + it('should throw if more children are passed', () => { + // eslint-disable-next-line prefer-arrow-callback + expect(function() { + render( + <Foo> + foo + <span /> + </Foo>, + scratch + ); + }).to.throw(); + }); + }); + + describe('.map', () => { + function Foo(props) { + let children = Children.map(props.children, child => ( + <span>{child}</span> + )); + return <div>{children}</div>; + } + + it('should iterate over children', () => { + render( + <Foo> + foo<div>bar</div> + </Foo>, + scratch + ); + let expected = div([span('foo'), span(div('bar'))]); + expect(serializeHtml(scratch)).to.equal(expected); + }); + + it('should work with no children', () => { + render(<Foo />, scratch); + expect(serializeHtml(scratch)).to.equal('<div></div>'); + }); + + it('should work with children as zero number', () => { + const testNumber = 0; + + render(<Foo>{testNumber}</Foo>, scratch); + expect(serializeHtml(scratch)).to.equal('<div><span>0</span></div>'); + }); + + it('should flatten result', () => { + const ProblemChild = ({ children }) => { + return React.Children.map(children, child => { + return React.Children.map(child.props.children, x => x); + }).filter(React.isValidElement); + }; + + const App = () => { + return ( + <ProblemChild> + <div> + <div>1</div> + <div>2</div> + </div> + </ProblemChild> + ); + }; + + render(<App />, scratch); + + expect(scratch.textContent).to.equal('12'); + }); + + it('should call with indices', () => { + const assertion = []; + const ProblemChild = ({ children }) => { + return React.Children.map(children, (child, i) => { + assertion.push(i); + return React.Children.map(child.props.children, (x, j) => { + assertion.push(j); + return x; + }); + }).filter(React.isValidElement); + }; + + const App = () => { + return ( + <ProblemChild> + <div> + <div>1</div> + <div>2</div> + </div> + <div> + <div>3</div> + <div>4</div> + </div> + </ProblemChild> + ); + }; + + render(<App />, scratch); + expect(scratch.textContent).to.equal('1234'); + expect(assertion.length).to.equal(6); + }); + }); + + describe('.forEach', () => { + function Foo(props) { + let children = []; + Children.forEach(props.children, child => + children.push(<span>{child}</span>) + ); + return <div>{children}</div>; + } + + it('should iterate over children', () => { + render( + <Foo> + foo<div>bar</div> + </Foo>, + scratch + ); + let expected = div([span('foo'), span(div('bar'))]); + expect(serializeHtml(scratch)).to.equal(expected); + }); + }); +}); diff --git a/preact/compat/test/browser/PureComponent.test.js b/preact/compat/test/browser/PureComponent.test.js new file mode 100644 index 0000000..1e69307 --- /dev/null +++ b/preact/compat/test/browser/PureComponent.test.js @@ -0,0 +1,125 @@ +import React, { createElement } from 'preact/compat'; +import { setupRerender } from 'preact/test-utils'; +import { setupScratch, teardown } from '../../../test/_util/helpers'; + +describe('PureComponent', () => { + /** @type {HTMLDivElement} */ + let scratch; + + /** @type {() => void} */ + let rerender; + + beforeEach(() => { + scratch = setupScratch(); + rerender = setupRerender(); + }); + + afterEach(() => { + teardown(scratch); + }); + + it('should be a class', () => { + expect(React) + .to.have.property('PureComponent') + .that.is.a('function'); + }); + + it('should pass props in constructor', () => { + let spy = sinon.spy(); + class Foo extends React.PureComponent { + constructor(props) { + super(props); + spy(this.props, props); + } + } + + React.render(<Foo foo="bar" />, scratch); + + let expected = { foo: 'bar' }; + expect(spy).to.be.calledWithMatch(expected, expected); + }); + + it('should ignore the __source variable', () => { + const pureSpy = sinon.spy(); + const appSpy = sinon.spy(); + let set; + class Pure extends React.PureComponent { + render() { + pureSpy(); + return <div>Static</div>; + } + } + + const App = () => { + const [, setState] = React.useState(0); + appSpy(); + set = setState; + return <Pure __source={{}} />; + }; + + React.render(<App />, scratch); + expect(appSpy).to.be.calledOnce; + expect(pureSpy).to.be.calledOnce; + + set(1); + rerender(); + expect(appSpy).to.be.calledTwice; + expect(pureSpy).to.be.calledOnce; + }); + + it('should only re-render when props or state change', () => { + class C extends React.PureComponent { + render() { + return <div />; + } + } + let spy = sinon.spy(C.prototype, 'render'); + + let inst = React.render(<C />, scratch); + expect(spy).to.have.been.calledOnce; + spy.resetHistory(); + + inst = React.render(<C />, scratch); + expect(spy).not.to.have.been.called; + + let b = { foo: 'bar' }; + inst = React.render(<C a="a" b={b} />, scratch); + expect(spy).to.have.been.calledOnce; + spy.resetHistory(); + + inst = React.render(<C a="a" b={b} />, scratch); + expect(spy).not.to.have.been.called; + + inst.setState({}); + rerender(); + expect(spy).not.to.have.been.called; + + inst.setState({ a: 'a', b }); + rerender(); + expect(spy).to.have.been.calledOnce; + spy.resetHistory(); + + inst.setState({ a: 'a', b }); + rerender(); + expect(spy).not.to.have.been.called; + }); + + it('should update when props are removed', () => { + let spy = sinon.spy(); + class App extends React.PureComponent { + render() { + spy(); + return <div>foo</div>; + } + } + + React.render(<App a="foo" />, scratch); + React.render(<App />, scratch); + expect(spy).to.be.calledTwice; + }); + + it('should have "isPureReactComponent" property', () => { + let Pure = new React.PureComponent(); + expect(Pure.isReactComponent).to.deep.equal({}); + }); +}); diff --git a/preact/compat/test/browser/cloneElement.test.js b/preact/compat/test/browser/cloneElement.test.js new file mode 100644 index 0000000..460ef77 --- /dev/null +++ b/preact/compat/test/browser/cloneElement.test.js @@ -0,0 +1,96 @@ +import { createElement as preactH } from 'preact'; +import React, { createElement, render, cloneElement } from 'preact/compat'; +import { setupScratch, teardown } from '../../../test/_util/helpers'; + +describe('compat cloneElement', () => { + /** @type {HTMLDivElement} */ + let scratch; + + beforeEach(() => { + scratch = setupScratch(); + }); + + afterEach(() => { + teardown(scratch); + }); + + it('should clone elements', () => { + let element = ( + <foo a="b" c="d"> + a<span>b</span> + </foo> + ); + const clone = cloneElement(element); + delete clone._original; + delete element._original; + expect(clone).to.eql(element); + }); + + it('should support props.children', () => { + let element = <foo children={<span>b</span>} />; + let clone = cloneElement(element); + delete clone._original; + delete element._original; + expect(clone).to.eql(element); + expect(cloneElement(clone).props.children).to.eql(element.props.children); + }); + + it('children take precedence over props.children', () => { + let element = ( + <foo children={<span>c</span>}> + <div>b</div> + </foo> + ); + let clone = cloneElement(element); + delete clone._original; + delete element._original; + expect(clone).to.eql(element); + expect(clone.props.children.type).to.eql('div'); + }); + + it('should support children in prop argument', () => { + let element = <foo />; + let children = [<span>b</span>]; + let clone = cloneElement(element, { children }); + expect(clone.props.children).to.eql(children); + }); + + it('single child argument takes precedence over props.children', () => { + let element = <foo />; + let childrenA = [<span>b</span>]; + let childrenB = [<div>c</div>]; + let clone = cloneElement(element, { children: childrenA }, ...childrenB); + expect(clone.props.children).to.eql(childrenB[0]); + }); + + it('multiple children arguments take precedence over props.children', () => { + let element = <foo />; + let childrenA = [<span>b</span>]; + let childrenB = [<div>c</div>, 'd']; + let clone = cloneElement(element, { children: childrenA }, ...childrenB); + expect(clone.props.children).to.eql(childrenB); + }); + + it('children argument takes precedence over props.children even if falsey', () => { + let element = <foo />; + let childrenA = [<span>b</span>]; + let clone = cloneElement(element, { children: childrenA }, undefined); + expect(clone.children).to.eql(undefined); + }); + + it('should skip cloning on invalid element', () => { + let element = { foo: 42 }; + let clone = cloneElement(element); + expect(clone).to.eql(element); + }); + + it('should work with jsx constructor from core', () => { + function Foo(props) { + return <div>{props.value}</div>; + } + + let clone = cloneElement(preactH(Foo), { value: 'foo' }); + render(clone, scratch); + expect(scratch.textContent).to.equal('foo'); + }); +}); diff --git a/preact/compat/test/browser/compat.options.test.js b/preact/compat/test/browser/compat.options.test.js new file mode 100644 index 0000000..6c52278 --- /dev/null +++ b/preact/compat/test/browser/compat.options.test.js @@ -0,0 +1,85 @@ +import { vnodeSpy, eventSpy } from '../../../test/_util/optionSpies'; +import React, { + createElement, + render, + Component, + createRef +} from 'preact/compat'; +import { setupRerender } from 'preact/test-utils'; +import { + setupScratch, + teardown, + createEvent +} from '../../../test/_util/helpers'; + +describe('compat options', () => { + /** @type {HTMLDivElement} */ + let scratch; + + /** @type {() => void} */ + let rerender; + + /** @type {() => void} */ + let increment; + + /** @type {import('../../src/index').PropRef<HTMLButtonElement | null>} */ + let buttonRef; + + beforeEach(() => { + scratch = setupScratch(); + rerender = setupRerender(); + + vnodeSpy.resetHistory(); + eventSpy.resetHistory(); + + buttonRef = createRef(); + }); + + afterEach(() => { + teardown(scratch); + }); + + class ClassApp extends Component { + constructor() { + super(); + this.state = { count: 0 }; + increment = () => + this.setState(({ count }) => ({ + count: count + 1 + })); + } + + render() { + return ( + <button ref={buttonRef} onClick={increment}> + {this.state.count} + </button> + ); + } + } + + it('should call old options on mount', () => { + render(<ClassApp />, scratch); + + expect(vnodeSpy).to.have.been.called; + }); + + it('should call old options on event and update', () => { + render(<ClassApp />, scratch); + expect(scratch.innerHTML).to.equal('<button>0</button>'); + + buttonRef.current.dispatchEvent(createEvent('click')); + rerender(); + expect(scratch.innerHTML).to.equal('<button>1</button>'); + + expect(vnodeSpy).to.have.been.called; + expect(eventSpy).to.have.been.called; + }); + + it('should call old options on unmount', () => { + render(<ClassApp />, scratch); + render(null, scratch); + + expect(vnodeSpy).to.have.been.called; + }); +}); diff --git a/preact/compat/test/browser/component.test.js b/preact/compat/test/browser/component.test.js new file mode 100644 index 0000000..de78a1f --- /dev/null +++ b/preact/compat/test/browser/component.test.js @@ -0,0 +1,243 @@ +import { setupRerender } from 'preact/test-utils'; +import { setupScratch, teardown } from '../../../test/_util/helpers'; +import React, { createElement } from 'preact/compat'; + +describe('components', () => { + /** @type {HTMLDivElement} */ + let scratch; + + /** @type {() => void} */ + let rerender; + + beforeEach(() => { + scratch = setupScratch(); + rerender = setupRerender(); + }); + + afterEach(() => { + teardown(scratch); + }); + + it('should have "isReactComponent" property', () => { + let Comp = new React.Component(); + expect(Comp.isReactComponent).to.deep.equal({}); + }); + + it('should be sane', () => { + let props; + + class Demo extends React.Component { + render() { + props = this.props; + return <div id="demo">{this.props.children}</div>; + } + } + + React.render( + <Demo a="b" c="d"> + inner + </Demo>, + scratch + ); + + expect(props).to.exist.and.deep.equal({ + a: 'b', + c: 'd', + children: 'inner' + }); + + expect(scratch.innerHTML).to.equal('<div id="demo">inner</div>'); + }); + + it('should single out children before componentWillReceiveProps', () => { + let props; + + class Child extends React.Component { + componentWillReceiveProps(newProps) { + props = newProps; + } + render() { + return this.props.children; + } + } + + class Parent extends React.Component { + render() { + return <Child>second</Child>; + } + } + + let a = React.render(<Parent />, scratch); + a.forceUpdate(); + rerender(); + + expect(props).to.exist.and.deep.equal({ + children: 'second' + }); + }); + + describe('UNSAFE_* lifecycle methods', () => { + it('should support UNSAFE_componentWillMount', () => { + let spy = sinon.spy(); + + class Foo extends React.Component { + // eslint-disable-next-line camelcase + UNSAFE_componentWillMount() { + spy(); + } + + render() { + return <h1>foo</h1>; + } + } + + React.render(<Foo />, scratch); + + expect(spy).to.be.calledOnce; + }); + + it('should support UNSAFE_componentWillMount #2', () => { + let spy = sinon.spy(); + + class Foo extends React.Component { + render() { + return <h1>foo</h1>; + } + } + + Object.defineProperty(Foo.prototype, 'UNSAFE_componentWillMount', { + value: spy + }); + + React.render(<Foo />, scratch); + expect(spy).to.be.calledOnce; + }); + + it('should support UNSAFE_componentWillReceiveProps', () => { + let spy = sinon.spy(); + + class Foo extends React.Component { + // eslint-disable-next-line camelcase + UNSAFE_componentWillReceiveProps() { + spy(); + } + + render() { + return <h1>foo</h1>; + } + } + + React.render(<Foo />, scratch); + // Trigger an update + React.render(<Foo />, scratch); + expect(spy).to.be.calledOnce; + }); + + it('should support UNSAFE_componentWillReceiveProps #2', () => { + let spy = sinon.spy(); + + class Foo extends React.Component { + render() { + return <h1>foo</h1>; + } + } + + Object.defineProperty(Foo.prototype, 'UNSAFE_componentWillReceiveProps', { + value: spy + }); + + React.render(<Foo />, scratch); + // Trigger an update + React.render(<Foo />, scratch); + expect(spy).to.be.calledOnce; + }); + + it('should support UNSAFE_componentWillUpdate', () => { + let spy = sinon.spy(); + + class Foo extends React.Component { + // eslint-disable-next-line camelcase + UNSAFE_componentWillUpdate() { + spy(); + } + + render() { + return <h1>foo</h1>; + } + } + + React.render(<Foo />, scratch); + // Trigger an update + React.render(<Foo />, scratch); + expect(spy).to.be.calledOnce; + }); + + it('should support UNSAFE_componentWillUpdate #2', () => { + let spy = sinon.spy(); + + class Foo extends React.Component { + render() { + return <h1>foo</h1>; + } + } + + Object.defineProperty(Foo.prototype, 'UNSAFE_componentWillUpdate', { + value: spy + }); + + React.render(<Foo />, scratch); + // Trigger an update + React.render(<Foo />, scratch); + expect(spy).to.be.calledOnce; + }); + + it('should alias UNSAFE_* method to non-prefixed variant', () => { + let inst; + class Foo extends React.Component { + // eslint-disable-next-line camelcase + UNSAFE_componentWillMount() {} + // eslint-disable-next-line camelcase + UNSAFE_componentWillReceiveProps() {} + // eslint-disable-next-line camelcase + UNSAFE_componentWillUpdate() {} + render() { + inst = this; + return <div>foo</div>; + } + } + + React.render(<Foo />, scratch); + + expect(inst.UNSAFE_componentWillMount).to.equal(inst.componentWillMount); + expect(inst.UNSAFE_componentWillReceiveProps).to.equal( + inst.UNSAFE_componentWillReceiveProps + ); + expect(inst.UNSAFE_componentWillUpdate).to.equal( + inst.UNSAFE_componentWillUpdate + ); + }); + + it('should call UNSAFE_* methods through Suspense with wrapper component #2525', () => { + class Page extends React.Component { + UNSAFE_componentWillMount() {} + render() { + return <h1>Example</h1>; + } + } + + const Wrapper = () => <Page />; + + sinon.spy(Page.prototype, 'UNSAFE_componentWillMount'); + + React.render( + <React.Suspense fallback={<div>fallback</div>}> + <Wrapper /> + </React.Suspense>, + scratch + ); + + expect(scratch.innerHTML).to.equal('<h1>Example</h1>'); + expect(Page.prototype.UNSAFE_componentWillMount).to.have.been.called; + }); + }); +}); diff --git a/preact/compat/test/browser/createElement.test.js b/preact/compat/test/browser/createElement.test.js new file mode 100644 index 0000000..8a39b3f --- /dev/null +++ b/preact/compat/test/browser/createElement.test.js @@ -0,0 +1,49 @@ +import React, { createElement, render } from 'preact/compat'; +import { setupScratch, teardown } from '../../../test/_util/helpers'; +import { getSymbol } from './testUtils'; + +describe('compat createElement()', () => { + /** @type {HTMLDivElement} */ + let scratch; + + beforeEach(() => { + scratch = setupScratch(); + }); + + afterEach(() => { + teardown(scratch); + }); + + it('should normalize vnodes', () => { + let vnode = ( + <div a="b"> + <a>t</a> + </div> + ); + + const $$typeof = getSymbol('react.element', 0xeac7); + expect(vnode).to.have.property('$$typeof', $$typeof); + expect(vnode).to.have.property('type', 'div'); + expect(vnode) + .to.have.property('props') + .that.is.an('object'); + expect(vnode.props).to.have.property('children'); + expect(vnode.props.children).to.have.property('$$typeof', $$typeof); + expect(vnode.props.children).to.have.property('type', 'a'); + expect(vnode.props.children) + .to.have.property('props') + .that.is.an('object'); + expect(vnode.props.children.props).to.eql({ children: 't' }); + }); + + it('should not normalize text nodes', () => { + String.prototype.capFLetter = function() { + return this.charAt(0).toUpperCase() + this.slice(1); + }; + let vnode = <div>hi buddy</div>; + + render(vnode, scratch); + + expect(scratch.innerHTML).to.equal('<div>hi buddy</div>'); + }); +}); diff --git a/preact/compat/test/browser/createFactory.test.js b/preact/compat/test/browser/createFactory.test.js new file mode 100644 index 0000000..8d4f929 --- /dev/null +++ b/preact/compat/test/browser/createFactory.test.js @@ -0,0 +1,26 @@ +import React, { render, createElement, createFactory } from 'preact/compat'; +import { setupScratch, teardown } from '../../../test/_util/helpers'; + +describe('createFactory', () => { + /** @type {HTMLDivElement} */ + let scratch; + + beforeEach(() => { + scratch = setupScratch(); + }); + + afterEach(() => { + teardown(scratch); + }); + + it('should create a DOM element', () => { + render(createFactory('span')({ class: 'foo' }, '1'), scratch); + expect(scratch.innerHTML).to.equal('<span class="foo">1</span>'); + }); + + it('should create a component', () => { + const Foo = ({ id, children }) => <div id={id}>foo {children}</div>; + render(createFactory(Foo)({ id: 'value' }, 'bar'), scratch); + expect(scratch.innerHTML).to.equal('<div id="value">foo bar</div>'); + }); +}); diff --git a/preact/compat/test/browser/events.test.js b/preact/compat/test/browser/events.test.js new file mode 100644 index 0000000..40e4ccc --- /dev/null +++ b/preact/compat/test/browser/events.test.js @@ -0,0 +1,278 @@ +import { render } from 'preact'; +import { + setupScratch, + teardown, + createEvent +} from '../../../test/_util/helpers'; + +import React, { createElement } from 'preact/compat'; + +describe('preact/compat events', () => { + /** @type {HTMLDivElement} */ + let scratch; + let proto; + + beforeEach(() => { + scratch = setupScratch(); + + proto = Element.prototype; + sinon.spy(proto, 'addEventListener'); + sinon.spy(proto, 'removeEventListener'); + }); + + afterEach(() => { + teardown(scratch); + + proto.addEventListener.restore(); + proto.removeEventListener.restore(); + }); + + it('should patch events', () => { + let spy = sinon.spy(event => { + // Calling ev.preventDefault() outside of an event handler + // does nothing in IE11. So we move these asserts inside + // the event handler. We ensure that it's called once + // in another assertion + expect(event.isDefaultPrevented()).to.be.false; + event.preventDefault(); + expect(event.isDefaultPrevented()).to.be.true; + + expect(event.isPropagationStopped()).to.be.false; + event.stopPropagation(); + expect(event.isPropagationStopped()).to.be.true; + }); + + render(<div onClick={spy} />, scratch); + scratch.firstChild.click(); + + expect(spy).to.be.calledOnce; + const event = spy.args[0][0]; + expect(event).to.haveOwnProperty('persist'); + expect(event).to.haveOwnProperty('nativeEvent'); + expect(event).to.haveOwnProperty('isDefaultPrevented'); + expect(event).to.haveOwnProperty('isPropagationStopped'); + expect(typeof event.persist).to.equal('function'); + expect(typeof event.isDefaultPrevented).to.equal('function'); + expect(typeof event.isPropagationStopped).to.equal('function'); + + expect(() => event.persist()).to.not.throw(); + expect(() => event.isDefaultPrevented()).to.not.throw(); + expect(() => event.isPropagationStopped()).to.not.throw(); + }); + + it('should normalize ondoubleclick event', () => { + let vnode = <div onDoubleClick={() => null} />; + expect(vnode.props).to.haveOwnProperty('ondblclick'); + }); + + it('should normalize onChange for textarea', () => { + let vnode = <textarea onChange={() => null} />; + expect(vnode.props).to.haveOwnProperty('oninput'); + expect(vnode.props).to.not.haveOwnProperty('onchange'); + + vnode = <textarea oninput={() => null} onChange={() => null} />; + expect(vnode.props).to.haveOwnProperty('oninput'); + expect(vnode.props).to.not.haveOwnProperty('onchange'); + }); + + it('should normalize onChange for range, except in IE11', () => { + // NOTE: we don't normalize `onchange` for range inputs in IE11. + const eventType = /Trident\//.test(navigator.userAgent) + ? 'change' + : 'input'; + + render(<input type="range" onChange={() => null} />, scratch); + expect(proto.addEventListener).to.have.been.calledOnce; + expect(proto.addEventListener).to.have.been.calledWithExactly( + eventType, + sinon.match.func, + false + ); + }); + + it('should normalize onChange for range, except in IE11, including when IE11 has Symbol polyfill', () => { + // NOTE: we don't normalize `onchange` for range inputs in IE11. + // This test mimics a specific scenario when a Symbol polyfill may + // be present, in which case onChange should still not be normalized + + const isIE11 = /Trident\//.test(navigator.userAgent); + const eventType = isIE11 ? 'change' : 'input'; + + if (isIE11) { + window.Symbol = () => 'mockSymbolPolyfill'; + } + sinon.spy(window, 'Symbol'); + + render(<input type="range" onChange={() => null} />, scratch); + expect(window.Symbol).to.have.been.calledOnce; + expect(proto.addEventListener).to.have.been.calledOnce; + expect(proto.addEventListener).to.have.been.calledWithExactly( + eventType, + sinon.match.func, + false + ); + + window.Symbol.restore(); + if (isIE11) { + window.Symbol = undefined; + } + }); + + it('should support onAnimationEnd', () => { + const func = sinon.spy(() => {}); + render(<div onAnimationEnd={func} />, scratch); + + expect( + proto.addEventListener + ).to.have.been.calledOnce.and.to.have.been.calledWithExactly( + 'animationend', + sinon.match.func, + false + ); + + scratch.firstChild.dispatchEvent(createEvent('animationend')); + expect(func).to.have.been.calledOnce; + + render(<div />, scratch); + expect( + proto.removeEventListener + ).to.have.been.calledOnce.and.to.have.been.calledWithExactly( + 'animationend', + sinon.match.func, + false + ); + }); + + it('should support onTouch* events', () => { + const onTouchStart = sinon.spy(); + const onTouchEnd = sinon.spy(); + const onTouchMove = sinon.spy(); + const onTouchCancel = sinon.spy(); + + render( + <div + onTouchStart={onTouchStart} + onTouchEnd={onTouchEnd} + onTouchMove={onTouchMove} + onTouchCancel={onTouchCancel} + />, + scratch + ); + + expect(proto.addEventListener.args.length).to.eql(4); + expect(proto.addEventListener.args[0].length).to.eql(3); + expect(proto.addEventListener.args[0][0]).to.eql('touchstart'); + expect(proto.addEventListener.args[0][2]).to.eql(false); + expect(proto.addEventListener.args[1].length).to.eql(3); + expect(proto.addEventListener.args[1][0]).to.eql('touchend'); + expect(proto.addEventListener.args[1][2]).to.eql(false); + expect(proto.addEventListener.args[2].length).to.eql(3); + expect(proto.addEventListener.args[2][0]).to.eql('touchmove'); + expect(proto.addEventListener.args[2][2]).to.eql(false); + expect(proto.addEventListener.args[3].length).to.eql(3); + expect(proto.addEventListener.args[3][0]).to.eql('touchcancel'); + expect(proto.addEventListener.args[3][2]).to.eql(false); + + scratch.firstChild.dispatchEvent(createEvent('touchstart')); + expect(onTouchStart).to.have.been.calledOnce; + + scratch.firstChild.dispatchEvent(createEvent('touchmove')); + expect(onTouchMove).to.have.been.calledOnce; + + scratch.firstChild.dispatchEvent(createEvent('touchend')); + expect(onTouchEnd).to.have.been.calledOnce; + + scratch.firstChild.dispatchEvent(createEvent('touchcancel')); + expect(onTouchCancel).to.have.been.calledOnce; + + render(<div />, scratch); + + expect(proto.removeEventListener.args.length).to.eql(4); + expect(proto.removeEventListener.args[0].length).to.eql(3); + expect(proto.removeEventListener.args[0][0]).to.eql('touchstart'); + expect(proto.removeEventListener.args[0][2]).to.eql(false); + expect(proto.removeEventListener.args[1].length).to.eql(3); + expect(proto.removeEventListener.args[1][0]).to.eql('touchend'); + expect(proto.removeEventListener.args[1][2]).to.eql(false); + expect(proto.removeEventListener.args[2].length).to.eql(3); + expect(proto.removeEventListener.args[2][0]).to.eql('touchmove'); + expect(proto.removeEventListener.args[2][2]).to.eql(false); + expect(proto.removeEventListener.args[3].length).to.eql(3); + expect(proto.removeEventListener.args[3][0]).to.eql('touchcancel'); + expect(proto.removeEventListener.args[3][2]).to.eql(false); + }); + + it('should support onTransitionEnd', () => { + const func = sinon.spy(() => {}); + render(<div onTransitionEnd={func} />, scratch); + + expect( + proto.addEventListener + ).to.have.been.calledOnce.and.to.have.been.calledWithExactly( + 'transitionend', + sinon.match.func, + false + ); + + scratch.firstChild.dispatchEvent(createEvent('transitionend')); + expect(func).to.have.been.calledOnce; + + render(<div />, scratch); + expect( + proto.removeEventListener + ).to.have.been.calledOnce.and.to.have.been.calledWithExactly( + 'transitionend', + sinon.match.func, + false + ); + }); + + it('should normalize onChange', () => { + let props = { onChange() {} }; + + function expectToBeNormalized(vnode, desc) { + expect(vnode, desc) + .to.have.property('props') + .with.all.keys(['oninput'].concat(vnode.props.type ? 'type' : [])) + .and.property('oninput') + .that.is.a('function'); + } + + function expectToBeUnmodified(vnode, desc) { + expect(vnode, desc) + .to.have.property('props') + .eql({ + ...props, + ...(vnode.props.type ? { type: vnode.props.type } : {}) + }); + } + + expectToBeUnmodified(<div {...props} />, '<div>'); + expectToBeUnmodified( + <input {...props} type="radio" />, + '<input type="radio">' + ); + expectToBeUnmodified( + <input {...props} type="checkbox" />, + '<input type="checkbox">' + ); + expectToBeUnmodified( + <input {...props} type="file" />, + '<input type="file">' + ); + + expectToBeNormalized(<textarea {...props} />, '<textarea>'); + expectToBeNormalized(<input {...props} />, '<input>'); + expectToBeNormalized( + <input {...props} type="text" />, + '<input type="text">' + ); + }); + + it('should normalize beforeinput event listener', () => { + let spy = sinon.spy(); + render(<input onBeforeInput={spy} />, scratch); + scratch.firstChild.dispatchEvent(createEvent('beforeinput')); + expect(spy).to.be.calledOnce; + }); +}); diff --git a/preact/compat/test/browser/exports.test.js b/preact/compat/test/browser/exports.test.js new file mode 100644 index 0000000..f4a3f26 --- /dev/null +++ b/preact/compat/test/browser/exports.test.js @@ -0,0 +1,85 @@ +import Compat from 'preact/compat'; +// eslint-disable-next-line no-duplicate-imports +import * as Named from 'preact/compat'; + +describe('compat exports', () => { + it('should have a default export', () => { + expect(Compat.createElement).to.be.a('function'); + expect(Compat.Component).to.be.a('function'); + expect(Compat.Fragment).to.exist; + expect(Compat.render).to.be.a('function'); + expect(Compat.hydrate).to.be.a('function'); + expect(Compat.cloneElement).to.be.a('function'); + expect(Compat.createContext).to.be.a('function'); + expect(Compat.createRef).to.be.a('function'); + + // Hooks + expect(Compat.useState).to.be.a('function'); + expect(Compat.useReducer).to.be.a('function'); + expect(Compat.useEffect).to.be.a('function'); + expect(Compat.useLayoutEffect).to.be.a('function'); + expect(Compat.useRef).to.be.a('function'); + expect(Compat.useMemo).to.be.a('function'); + expect(Compat.useCallback).to.be.a('function'); + expect(Compat.useContext).to.be.a('function'); + + // Suspense + expect(Compat.Suspense).to.be.a('function'); + expect(Compat.lazy).to.be.a('function'); + + // Compat specific + expect(Compat.PureComponent).to.exist.and.be.a('function'); + expect(Compat.createPortal).to.exist.and.be.a('function'); + expect(Compat.createFactory).to.exist.and.be.a('function'); + expect(Compat.isValidElement).to.exist.and.be.a('function'); + expect(Compat.findDOMNode).to.exist.and.be.a('function'); + expect(Compat.Children.map).to.exist.and.be.a('function'); + expect(Compat.Children.forEach).to.exist.and.be.a('function'); + expect(Compat.Children.count).to.exist.and.be.a('function'); + expect(Compat.Children.toArray).to.exist.and.be.a('function'); + expect(Compat.Children.only).to.exist.and.be.a('function'); + expect(Compat.unmountComponentAtNode).to.exist.and.be.a('function'); + expect(Compat.unstable_batchedUpdates).to.exist.and.be.a('function'); + expect(Compat.version).to.exist.and.be.a('string'); + }); + + it('should have named exports', () => { + expect(Named.createElement).to.be.a('function'); + expect(Named.Component).to.be.a('function'); + expect(Named.Fragment).to.exist; + expect(Named.render).to.be.a('function'); + expect(Named.hydrate).to.be.a('function'); + expect(Named.cloneElement).to.be.a('function'); + expect(Named.createContext).to.be.a('function'); + expect(Named.createRef).to.be.a('function'); + + // Hooks + expect(Named.useState).to.be.a('function'); + expect(Named.useReducer).to.be.a('function'); + expect(Named.useEffect).to.be.a('function'); + expect(Named.useLayoutEffect).to.be.a('function'); + expect(Named.useRef).to.be.a('function'); + expect(Named.useMemo).to.be.a('function'); + expect(Named.useCallback).to.be.a('function'); + expect(Named.useContext).to.be.a('function'); + + // Suspense + expect(Named.Suspense).to.be.a('function'); + expect(Named.lazy).to.be.a('function'); + + // Compat specific + expect(Named.PureComponent).to.exist.and.be.a('function'); + expect(Named.createPortal).to.exist.and.be.a('function'); + expect(Named.createFactory).to.exist.and.be.a('function'); + expect(Named.isValidElement).to.exist.and.be.a('function'); + expect(Named.findDOMNode).to.exist.and.be.a('function'); + expect(Named.Children.map).to.exist.and.be.a('function'); + expect(Named.Children.forEach).to.exist.and.be.a('function'); + expect(Named.Children.count).to.exist.and.be.a('function'); + expect(Named.Children.toArray).to.exist.and.be.a('function'); + expect(Named.Children.only).to.exist.and.be.a('function'); + expect(Named.unmountComponentAtNode).to.exist.and.be.a('function'); + expect(Named.unstable_batchedUpdates).to.exist.and.be.a('function'); + expect(Named.version).to.exist.and.be.a('string'); + }); +}); diff --git a/preact/compat/test/browser/findDOMNode.test.js b/preact/compat/test/browser/findDOMNode.test.js new file mode 100644 index 0000000..0812ed9 --- /dev/null +++ b/preact/compat/test/browser/findDOMNode.test.js @@ -0,0 +1,51 @@ +import React, { createElement, findDOMNode } from 'preact/compat'; +import { setupScratch, teardown } from '../../../test/_util/helpers'; + +describe('findDOMNode()', () => { + /** @type {HTMLDivElement} */ + let scratch; + + class Helper extends React.Component { + render({ something }) { + if (something == null) return null; + if (something === false) return null; + return <div />; + } + } + + beforeEach(() => { + scratch = setupScratch(); + }); + + afterEach(() => { + teardown(scratch); + }); + + it.skip('should return DOM Node if render is not false nor null', () => { + const helper = React.render(<Helper />, scratch); + expect(findDOMNode(helper)).to.be.instanceof(Node); + }); + + it('should return null if given null', () => { + expect(findDOMNode(null)).to.be.null; + }); + + it('should return a regular DOM Element if given a regular DOM Element', () => { + let scratch = document.createElement('div'); + expect(findDOMNode(scratch)).to.equalNode(scratch); + }); + + // NOTE: React.render() returning false or null has the component pointing + // to no DOM Node, in contrast, Preact always render an empty Text DOM Node. + it('should return null if render returns false', () => { + const helper = React.render(<Helper something={false} />, scratch); + expect(findDOMNode(helper)).to.be.null; + }); + + // NOTE: React.render() returning false or null has the component pointing + // to no DOM Node, in contrast, Preact always render an empty Text DOM Node. + it('should return null if render returns null', () => { + const helper = React.render(<Helper something={null} />, scratch); + expect(findDOMNode(helper)).to.be.null; + }); +}); diff --git a/preact/compat/test/browser/forwardRef.test.js b/preact/compat/test/browser/forwardRef.test.js new file mode 100644 index 0000000..68f9219 --- /dev/null +++ b/preact/compat/test/browser/forwardRef.test.js @@ -0,0 +1,460 @@ +import React, { + createElement, + render, + createRef, + forwardRef, + hydrate, + memo, + useState, + useRef, + useImperativeHandle, + createPortal +} from 'preact/compat'; +import { setupScratch, teardown } from '../../../test/_util/helpers'; +import { setupRerender, act } from 'preact/test-utils'; +import { getSymbol } from './testUtils'; + +/* eslint-disable react/jsx-boolean-value, react/display-name, prefer-arrow-callback */ + +describe('forwardRef', () => { + /** @type {HTMLDivElement} */ + let scratch, rerender; + + beforeEach(() => { + scratch = setupScratch(); + rerender = setupRerender(); + }); + + afterEach(() => { + teardown(scratch); + }); + + it('should have isReactComponent flag', () => { + let App = forwardRef((_, ref) => <div ref={ref}>foo</div>); + expect(App.prototype.isReactComponent).to.equal(true); + }); + + it('should have $$typeof property', () => { + let App = forwardRef((_, ref) => <div ref={ref}>foo</div>); + const expected = getSymbol('react.forward_ref', 0xf47); + expect(App.$$typeof).to.equal(expected); + }); + + it('should pass ref with createRef', () => { + let App = forwardRef((_, ref) => <div ref={ref}>foo</div>); + let ref = createRef(); + render(<App ref={ref} />, scratch); + + expect(ref.current).to.equalNode(scratch.firstChild); + }); + + it('should share the same ref reference', () => { + let passedRef; + let App = forwardRef((_, ref) => { + passedRef = ref; + return <div ref={ref}>foo</div>; + }); + + let ref = createRef(); + render(<App ref={ref} />, scratch); + + expect(ref).to.equal(passedRef); + }); + + it('should pass ref with a callback', () => { + let App = forwardRef((_, ref) => ( + <div> + <span ref={ref}>foo</span> + </div> + )); + let ref; + render(<App ref={x => (ref = x)} />, scratch); + + expect(ref).to.equalNode(scratch.firstChild.firstChild); + }); + + it('should forward props', () => { + let spy = sinon.spy(); + let App = forwardRef(spy); + render(<App foo="bar" />, scratch); + + expect(spy).to.be.calledWithMatch({ foo: 'bar' }); + }); + + it('should support nesting', () => { + let passedRef; + let Inner = forwardRef((_, ref) => { + passedRef = ref; + return <div ref={ref}>inner</div>; + }); + let App = forwardRef((_, ref) => <Inner ref={ref} />); + + let ref = createRef(); + render(<App ref={ref} />, scratch); + + expect(ref).to.equal(passedRef); + }); + + it('should forward null on unmount', () => { + let passedRef; + let App = forwardRef((_, ref) => { + passedRef = ref; + return <div ref={ref}>foo</div>; + }); + + let ref = createRef(); + render(<App ref={ref} />, scratch); + render(null, scratch); + + expect(passedRef.current).to.equal(null); + }); + + it('should be able to render and hydrate forwardRef components', () => { + const Foo = ({ label, forwardedRef }) => ( + <div ref={forwardedRef}>{label}</div> + ); + const App = forwardRef((props, ref) => ( + <Foo {...props} forwardedRef={ref} /> + )); + + const ref = createRef(); + const markup = <App ref={ref} label="Hi" />; + + const element = document.createElement('div'); + element.innerHTML = '<div>Hi</div>'; + expect(element.textContent).to.equal('Hi'); + expect(ref.current == null).to.equal(true); + + hydrate(markup, element); + expect(element.textContent).to.equal('Hi'); + expect(ref.current.tagName).to.equal('DIV'); + }); + + it('should update refs when switching between children', () => { + function Foo({ forwardedRef, setRefOnDiv }) { + return ( + <section> + <div ref={setRefOnDiv ? forwardedRef : null}>First</div> + <span ref={setRefOnDiv ? null : forwardedRef}>Second</span> + </section> + ); + } + + const App = forwardRef((props, ref) => ( + <Foo {...props} forwardedRef={ref} /> + )); + + const ref = createRef(); + + render(<App ref={ref} setRefOnDiv={true} />, scratch); + expect(ref.current.nodeName).to.equal('DIV'); + + render(<App ref={ref} setRefOnDiv={false} />, scratch); + expect(ref.current.nodeName).to.equal('SPAN'); + }); + + it('should support rendering null', () => { + const App = forwardRef(() => null); + const ref = createRef(); + + render(<App ref={ref} />, scratch); + expect(ref.current == null).to.equal(true); + }); + + it('should support rendering null for multiple children', () => { + const Foo = forwardRef(() => null); + const ref = createRef(); + + render( + <div> + <div /> + <Foo ref={ref} /> + <div /> + </div>, + scratch + ); + expect(ref.current == null).to.equal(true); + }); + + it('should support useImperativeHandle', () => { + let setValue; + const Foo = forwardRef((props, ref) => { + const result = useState(''); + setValue = result[1]; + + useImperativeHandle( + ref, + () => ({ + getValue: () => result[0] + }), + [result[0]] + ); + + return <input ref={ref} value={result[0]} />; + }); + + const ref = createRef(); + render(<Foo ref={ref} />, scratch); + + expect(typeof ref.current.getValue).to.equal('function'); + expect(ref.current.getValue()).to.equal(''); + + setValue('x'); + rerender(); + expect(typeof ref.current.getValue).to.equal('function'); + expect(ref.current.getValue()).to.equal('x'); + }); + + it('should not bailout if forwardRef is not wrapped in memo', () => { + const Component = props => <div {...props} />; + + let renderCount = 0; + + const App = forwardRef((props, ref) => { + renderCount++; + return <Component {...props} forwardedRef={ref} />; + }); + + const ref = createRef(); + + render(<App ref={ref} optional="foo" />, scratch); + expect(renderCount).to.equal(1); + + render(<App ref={ref} optional="foo" />, scratch); + expect(renderCount).to.equal(2); + }); + + it('should bailout if forwardRef is wrapped in memo', () => { + const Component = props => <div ref={props.forwardedRef} />; + + let renderCount = 0; + + const App = memo( + forwardRef((props, ref) => { + renderCount++; + return <Component {...props} forwardedRef={ref} />; + }) + ); + + const ref = createRef(); + + render(<App ref={ref} optional="foo" />, scratch); + expect(renderCount).to.equal(1); + + expect(ref.current.nodeName).to.equal('DIV'); + + render(<App ref={ref} optional="foo" />, scratch); + expect(renderCount).to.equal(1); + + const differentRef = createRef(); + + render(<App ref={differentRef} optional="foo" />, scratch); + expect(renderCount).to.equal(2); + + expect(ref.current == null).to.equal(true); + expect(differentRef.current.nodeName).to.equal('DIV'); + + render(<App ref={ref} optional="bar" />, scratch); + expect(renderCount).to.equal(3); + }); + + it('should bailout if forwardRef is wrapped in memo using function refs', () => { + const Component = props => <div ref={props.forwardedRef} />; + + let renderCount = 0; + + const App = memo( + forwardRef((props, ref) => { + renderCount++; + return <Component {...props} forwardedRef={ref} />; + }) + ); + + const ref = sinon.spy(); + + render(<App ref={ref} optional="foo" />, scratch); + expect(renderCount).to.equal(1); + + expect(ref).to.have.been.called; + + ref.resetHistory(); + render(<App ref={ref} optional="foo" />, scratch); + expect(renderCount).to.equal(1); + + const differentRef = sinon.spy(); + + render(<App ref={differentRef} optional="foo" />, scratch); + expect(renderCount).to.equal(2); + + expect(ref).to.have.been.calledWith(null); + expect(differentRef).to.have.been.called; + + differentRef.resetHistory(); + render(<App ref={ref} optional="bar" />, scratch); + expect(renderCount).to.equal(3); + }); + + it('should pass ref through memo() with custom comparer function', () => { + const Foo = props => <div ref={props.forwardedRef} />; + + let renderCount = 0; + + const App = memo( + forwardRef((props, ref) => { + renderCount++; + return <Foo {...props} forwardedRef={ref} />; + }), + (o, p) => o.a === p.a && o.b === p.b + ); + + const ref = createRef(); + + render(<App ref={ref} a="0" b="0" c="1" />, scratch); + expect(renderCount).to.equal(1); + + expect(ref.current.nodeName).to.equal('DIV'); + + // Changing either a or b rerenders + render(<App ref={ref} a="0" b="1" c="1" />, scratch); + expect(renderCount).to.equal(2); + + // Changing c doesn't rerender + render(<App ref={ref} a="0" b="1" c="2" />, scratch); + expect(renderCount).to.equal(2); + + const App2 = memo(App, (o, p) => o.a === p.a && o.c === p.c); + + render(<App2 ref={ref} a="0" b="0" c="0" />, scratch); + expect(renderCount).to.equal(3); + + // Changing just b no longer updates + render(<App2 ref={ref} a="0" b="1" c="0" />, scratch); + expect(renderCount).to.equal(3); + + // Changing just a and c updates + render(<App2 ref={ref} a="2" b="2" c="2" />, scratch); + expect(renderCount).to.equal(4); + + // Changing just c does not update + render(<App2 ref={ref} a="2" b="2" c="3" />, scratch); + expect(renderCount).to.equal(4); + + // Changing ref still rerenders + const differentRef = createRef(); + + render(<App2 ref={differentRef} a="2" b="2" c="3" />, scratch); + expect(renderCount).to.equal(5); + + expect(ref.current == null).to.equal(true); + expect(differentRef.current.nodeName).to.equal('DIV'); + }); + + it('calls ref when this is a function.', () => { + const spy = sinon.spy(); + const Bar = forwardRef((props, ref) => { + useImperativeHandle(ref, () => ({ foo: 100 })); + return null; + }); + + render(<Bar ref={spy} />, scratch); + expect(spy).to.be.calledOnce; + expect(spy).to.be.calledWithExactly({ foo: 100 }); + }); + + it('stale ref missing with passed useRef', () => { + let _ref = null; + let _set = null; + const Inner = forwardRef((props, ref) => { + const _hook = useState(null); + _ref = ref; + _set = _hook[1]; + return <div ref={ref} />; + }); + + const Parent = () => { + const parentRef = useRef(null); + return <Inner ref={parentRef}>child</Inner>; + }; + + act(() => { + render(<Parent />, scratch); + }); + + expect(_ref.current).to.equal(scratch.firstChild); + + act(() => { + _set(1); + rerender(); + }); + + expect(_ref.current).to.equal(scratch.firstChild); + }); + + it('should forward at diff time instead vnode-creation.', () => { + let ref, forceTransition, forceOpen; + + const Portal = ({ children, open }) => + open ? createPortal(children, scratch) : null; + + const Wrapper = forwardRef((_props, ref) => <div ref={ref}>Wrapper</div>); + const Transition = ({ children }) => { + const state = useState(0); + forceTransition = state[1]; + expect(children.ref).to.not.be.undefined; + if (state[0] === 0) expect(children.props.ref).to.be.undefined; + return children; + }; + + const App = () => { + const openState = useState(false); + forceOpen = openState[1]; + ref = useRef(); + return ( + <Portal open={openState[0]}> + <Transition> + <Wrapper ref={ref} /> + </Transition> + </Portal> + ); + }; + + render(<App />, scratch); + + act(() => { + forceOpen(true); + }); + + expect(ref.current.innerHTML).to.equal('Wrapper'); + + act(() => { + forceTransition(1); + }); + + expect(ref.current.innerHTML).to.equal('Wrapper'); + }); + + // Issue #2566 + it('should pass null as ref when no ref is present', () => { + let actual; + const App = forwardRef((_, ref) => { + actual = ref; + return <div />; + }); + + render(<App />, scratch); + expect(actual).to.equal(null); + }); + + // Issue #2599 + it('should not crash when explicitly passing null', () => { + let actual; + const App = forwardRef((_, ref) => { + actual = ref; + return <div />; + }); + + // eslint-disable-next-line new-cap + render(App({}, null), scratch); + expect(actual).to.equal(null); + }); +}); diff --git a/preact/compat/test/browser/hydrate.test.js b/preact/compat/test/browser/hydrate.test.js new file mode 100644 index 0000000..e42cdb7 --- /dev/null +++ b/preact/compat/test/browser/hydrate.test.js @@ -0,0 +1,34 @@ +import React, { hydrate } from 'preact/compat'; +import { setupScratch, teardown } from '../../../test/_util/helpers'; + +describe('compat hydrate', () => { + /** @type {HTMLDivElement} */ + let scratch; + + beforeEach(() => { + scratch = setupScratch(); + }); + + afterEach(() => { + teardown(scratch); + }); + + it('should render react-style jsx', () => { + const input = document.createElement('input'); + scratch.appendChild(input); + input.focus(); + expect(document.activeElement).to.equal(input); + + hydrate(<input />, scratch); + expect(document.activeElement).to.equal(input); + }); + + it('should call the callback', () => { + scratch.innerHTML = '<div></div>'; + + let spy = sinon.spy(); + hydrate(<div />, scratch, spy); + expect(spy).to.be.calledOnce; + expect(spy).to.be.calledWithExactly(); + }); +}); diff --git a/preact/compat/test/browser/isValidElement.test.js b/preact/compat/test/browser/isValidElement.test.js new file mode 100644 index 0000000..0fa34ef --- /dev/null +++ b/preact/compat/test/browser/isValidElement.test.js @@ -0,0 +1,22 @@ +import { createElement as preactCreateElement } from 'preact'; +import React, { isValidElement } from 'preact/compat'; + +describe('isValidElement', () => { + it('should check return false for invalid arguments', () => { + expect(isValidElement(null)).to.equal(false); + expect(isValidElement(false)).to.equal(false); + expect(isValidElement(true)).to.equal(false); + expect(isValidElement('foo')).to.equal(false); + expect(isValidElement(123)).to.equal(false); + expect(isValidElement([])).to.equal(false); + expect(isValidElement({})).to.equal(false); + }); + + it('should detect a preact vnode', () => { + expect(isValidElement(preactCreateElement('div'))).to.equal(true); + }); + + it('should detect a compat vnode', () => { + expect(isValidElement(React.createElement('div'))).to.equal(true); + }); +}); diff --git a/preact/compat/test/browser/memo.test.js b/preact/compat/test/browser/memo.test.js new file mode 100644 index 0000000..c1e155c --- /dev/null +++ b/preact/compat/test/browser/memo.test.js @@ -0,0 +1,234 @@ +import { setupRerender } from 'preact/test-utils'; +import { + createEvent, + setupScratch, + teardown +} from '../../../test/_util/helpers'; +import React, { + createElement, + Component, + render, + memo, + useState +} from 'preact/compat'; +import { li, ol } from '../../../test/_util/dom'; + +const h = React.createElement; + +describe('memo()', () => { + /** @type {HTMLDivElement} */ + let scratch; + + /** @type {() => void} */ + let rerender; + + beforeEach(() => { + scratch = setupScratch(); + rerender = setupRerender(); + }); + + afterEach(() => { + teardown(scratch); + }); + + it('should have isReactComponent flag', () => { + // eslint-disable-next-line react/display-name + let App = memo(() => <div>foo</div>); + expect(App.prototype.isReactComponent).to.equal(true); + }); + + it('should work with function components', () => { + let spy = sinon.spy(); + + function Foo() { + spy(); + return <h1>Hello World</h1>; + } + + let Memoized = memo(Foo); + + let update; + class App extends Component { + constructor() { + super(); + update = () => this.setState({}); + } + render() { + return <Memoized />; + } + } + render(<App />, scratch); + + expect(spy).to.be.calledOnce; + + update(); + rerender(); + + expect(spy).to.be.calledOnce; + }); + + it('should support adding refs', () => { + let spy = sinon.spy(); + + let ref = null; + + function Foo() { + spy(); + return <h1>Hello World</h1>; + } + + let Memoized = memo(Foo); + + let update; + class App extends Component { + constructor() { + super(); + update = () => this.setState({}); + } + render() { + return <Memoized ref={ref} />; + } + } + render(<App />, scratch); + + expect(spy).to.be.calledOnce; + + ref = {}; + + update(); + rerender(); + + expect(ref.current).not.to.be.undefined; + + // TODO: not sure whether this is in-line with react... + expect(spy).to.be.calledTwice; + }); + + it('should support custom comparer functions', () => { + function Foo() { + return <h1>Hello World</h1>; + } + + let spy = sinon.spy(() => true); + let Memoized = memo(Foo, spy); + + let update; + class App extends Component { + constructor() { + super(); + update = () => this.setState({}); + } + render() { + return <Memoized />; + } + } + render(<App />, scratch); + + update(); + rerender(); + + expect(spy).to.be.calledOnce; + expect(spy).to.be.calledWith({}, {}); + }); + + it('should rerender when custom comparer returns false', () => { + const spy = sinon.spy(); + function Foo() { + spy(); + return <h1>Hello World</h1>; + } + + const App = memo(Foo, () => false); + render(<App />, scratch); + expect(spy).to.be.calledOnce; + + render(<App foo="bar" />, scratch); + expect(spy).to.be.calledTwice; + }); + + it('should pass props and nextProps to comparer fn', () => { + const spy = sinon.spy(() => false); + function Foo() { + return <div>foo</div>; + } + + const props = { foo: true }; + const nextProps = { foo: false }; + const App = memo(Foo, spy); + render(h(App, props), scratch); + render(h(App, nextProps), scratch); + + expect(spy).to.be.calledWith(props, nextProps); + }); + + it('should nest without errors', () => { + const Foo = () => <div>foo</div>; + const App = memo(memo(Foo)); + + // eslint-disable-next-line prefer-arrow-callback + expect(function() { + render(<App />, scratch); + }).to.not.throw(); + }); + + it('should pass ref through nested memos', () => { + class Foo extends Component { + render() { + return <h1>Hello World</h1>; + } + } + + const App = memo(memo(Foo)); + + const ref = {}; + + render(<App ref={ref} />, scratch); + + expect(ref.current).not.to.be.undefined; + expect(ref.current).to.be.instanceOf(Foo); + }); + + it('should not unnecessarily reorder children #2895', () => { + const array = [{ name: 'A' }, { name: 'B' }, { name: 'C' }, { name: 'D' }]; + + const List = () => { + const [selected, setSelected] = useState(''); + return ( + <ol> + {array.map(item => ( + <ListItem + {...{ + isSelected: item.name === selected, + setSelected, + ...item + }} + key={item.name} + /> + ))} + </ol> + ); + }; + + const ListItem = memo(({ name, isSelected, setSelected }) => { + const handleClick = () => setSelected(name); + return ( + <li class={isSelected ? 'selected' : null} onClick={handleClick}> + {name} + </li> + ); + }); + + render(<List />, scratch); + expect(scratch.innerHTML).to.equal( + `<ol><li>A</li><li>B</li><li>C</li><li>D</li></ol>` + ); + + let listItem = scratch.querySelector('li:nth-child(3)'); + listItem.dispatchEvent(createEvent('click')); + rerender(); + + expect(scratch.innerHTML).to.equal( + `<ol><li>A</li><li>B</li><li class="selected">C</li><li>D</li></ol>` + ); + }); +}); diff --git a/preact/compat/test/browser/portals.test.js b/preact/compat/test/browser/portals.test.js new file mode 100644 index 0000000..c114657 --- /dev/null +++ b/preact/compat/test/browser/portals.test.js @@ -0,0 +1,627 @@ +import React, { + createElement, + render, + createPortal, + useState, + Component +} from 'preact/compat'; +import { setupScratch, teardown } from '../../../test/_util/helpers'; +import { setupRerender, act } from 'preact/test-utils'; + +/* eslint-disable react/jsx-boolean-value, react/display-name, prefer-arrow-callback */ + +describe('Portal', () => { + /** @type {HTMLDivElement} */ + let scratch; + let rerender; + + beforeEach(() => { + scratch = setupScratch(); + rerender = setupRerender(); + }); + + afterEach(() => { + teardown(scratch); + }); + + it('should render into a different root node', () => { + let root = document.createElement('div'); + document.body.appendChild(root); + + function Foo(props) { + return <div>{createPortal(props.children, root)}</div>; + } + render(<Foo>foobar</Foo>, scratch); + + expect(root.innerHTML).to.equal('foobar'); + + root.parentNode.removeChild(root); + }); + + it('should insert the portal', () => { + let setFalse; + function Foo(props) { + const [mounted, setMounted] = useState(true); + setFalse = () => setMounted(() => false); + return ( + <div> + <p>Hello</p> + {mounted && createPortal(props.children, scratch)} + </div> + ); + } + render(<Foo>foobar</Foo>, scratch); + expect(scratch.innerHTML).to.equal('foobar<div><p>Hello</p></div>'); + + setFalse(); + rerender(); + expect(scratch.innerHTML).to.equal('<div><p>Hello</p></div>'); + }); + + it('should toggle the portal', () => { + let toggle; + + function Foo(props) { + const [mounted, setMounted] = useState(true); + toggle = () => setMounted(s => !s); + return ( + <div> + <p>Hello</p> + {mounted && createPortal(props.children, scratch)} + </div> + ); + } + + render( + <Foo> + <div>foobar</div> + </Foo>, + scratch + ); + expect(scratch.innerHTML).to.equal( + '<div>foobar</div><div><p>Hello</p></div>' + ); + + toggle(); + rerender(); + expect(scratch.innerHTML).to.equal('<div><p>Hello</p></div>'); + + toggle(); + rerender(); + expect(scratch.innerHTML).to.equal( + '<div><p>Hello</p></div><div>foobar</div>' + ); + }); + + it('should notice prop changes on the portal', () => { + let set; + + function Foo(props) { + const [additionalProps, setProps] = useState({ + style: { backgroundColor: 'red' } + }); + set = c => setProps(c); + return ( + <div> + <p>Hello</p> + {createPortal(<p {...additionalProps}>Foo</p>, scratch)} + </div> + ); + } + + render(<Foo />, scratch); + expect(scratch.firstChild.style.backgroundColor).to.equal('red'); + + set({}); + rerender(); + expect(scratch.firstChild.style.backgroundColor).to.equal(''); + }); + + it('should not unmount the portal component', () => { + let spy = sinon.spy(); + let set; + class Child extends Component { + componentWillUnmount() { + spy(); + } + + render(props) { + return props.children; + } + } + + function Foo(props) { + const [additionalProps, setProps] = useState({ + style: { background: 'red' } + }); + set = c => setProps(c); + return ( + <div> + <p>Hello</p> + {createPortal(<Child {...additionalProps}>Foo</Child>, scratch)} + </div> + ); + } + + render(<Foo />, scratch); + expect(spy).not.to.be.called; + + set({}); + rerender(); + expect(spy).not.to.be.called; + }); + + it('should not render <undefined> for Portal nodes', () => { + let root = document.createElement('div'); + let dialog = document.createElement('div'); + dialog.id = 'container'; + + scratch.appendChild(root); + scratch.appendChild(dialog); + + function Dialog() { + return <div>Dialog content</div>; + } + + function App() { + return <div>{createPortal(<Dialog />, dialog)}</div>; + } + + render(<App />, root); + expect(scratch.firstChild.firstChild.childNodes.length).to.equal(0); + }); + + it('should unmount Portal', () => { + let root = document.createElement('div'); + let dialog = document.createElement('div'); + dialog.id = 'container'; + + scratch.appendChild(root); + scratch.appendChild(dialog); + + function Dialog() { + return <div>Dialog content</div>; + } + + function App() { + return <div>{createPortal(<Dialog />, dialog)}</div>; + } + + render(<App />, root); + expect(dialog.childNodes.length).to.equal(1); + render(null, root); + expect(dialog.childNodes.length).to.equal(0); + }); + + it('should leave a working root after the portal', () => { + let toggle, toggle2; + + function Foo(props) { + const [mounted, setMounted] = useState(false); + const [mounted2, setMounted2] = useState(true); + toggle = () => setMounted(s => !s); + toggle2 = () => setMounted2(s => !s); + return ( + <div> + {mounted && createPortal(props.children, scratch)} + {mounted2 && <p>Hello</p>} + </div> + ); + } + + render( + <Foo> + <div>foobar</div> + </Foo>, + scratch + ); + expect(scratch.innerHTML).to.equal('<div><p>Hello</p></div>'); + + toggle(); + rerender(); + expect(scratch.innerHTML).to.equal( + '<div><p>Hello</p></div><div>foobar</div>' + ); + + toggle2(); + rerender(); + expect(scratch.innerHTML).to.equal('<div></div><div>foobar</div>'); + + toggle2(); + rerender(); + expect(scratch.innerHTML).to.equal( + '<div><p>Hello</p></div><div>foobar</div>' + ); + + toggle(); + rerender(); + expect(scratch.innerHTML).to.equal('<div><p>Hello</p></div>'); + + toggle2(); + rerender(); + expect(scratch.innerHTML).to.equal('<div></div>'); + }); + + it('should work with stacking portals', () => { + let toggle, toggle2; + + function Foo(props) { + const [mounted, setMounted] = useState(false); + const [mounted2, setMounted2] = useState(false); + toggle = () => setMounted(s => !s); + toggle2 = () => setMounted2(s => !s); + return ( + <div> + <p>Hello</p> + {mounted && createPortal(props.children, scratch)} + {mounted2 && createPortal(props.children2, scratch)} + </div> + ); + } + + render( + <Foo children2={<div>foobar2</div>}> + <div>foobar</div> + </Foo>, + scratch + ); + expect(scratch.innerHTML).to.equal('<div><p>Hello</p></div>'); + + toggle(); + rerender(); + expect(scratch.innerHTML).to.equal( + '<div><p>Hello</p></div><div>foobar</div>' + ); + + toggle2(); + rerender(); + expect(scratch.innerHTML).to.equal( + '<div><p>Hello</p></div><div>foobar</div><div>foobar2</div>' + ); + + toggle2(); + rerender(); + expect(scratch.innerHTML).to.equal( + '<div><p>Hello</p></div><div>foobar</div>' + ); + + toggle(); + rerender(); + expect(scratch.innerHTML).to.equal('<div><p>Hello</p></div>'); + }); + + it('should work with changing the container', () => { + let set, ref; + + function Foo(props) { + const [container, setContainer] = useState(scratch); + set = setContainer; + + return ( + <div + ref={r => { + ref = r; + }} + > + <p>Hello</p> + {createPortal(props.children, container)} + </div> + ); + } + + render( + <Foo> + <div>foobar</div> + </Foo>, + scratch + ); + expect(scratch.innerHTML).to.equal( + '<div>foobar</div><div><p>Hello</p></div>' + ); + + set(() => ref); + rerender(); + expect(scratch.innerHTML).to.equal( + '<div><p>Hello</p><div>foobar</div></div>' + ); + }); + + it('should work with replacing placeholder portals', () => { + let toggle, toggle2; + + function Foo(props) { + const [mounted, setMounted] = useState(false); + const [mounted2, setMounted2] = useState(false); + toggle = () => setMounted(s => !s); + toggle2 = () => setMounted2(s => !s); + return ( + <div> + <p>Hello</p> + {createPortal(mounted && props.children, scratch)} + {createPortal(mounted2 && props.children, scratch)} + </div> + ); + } + + render( + <Foo> + <div>foobar</div> + </Foo>, + scratch + ); + expect(scratch.innerHTML).to.equal('<div><p>Hello</p></div>'); + + toggle(); + rerender(); + expect(scratch.innerHTML).to.equal( + '<div><p>Hello</p></div><div>foobar</div>' + ); + + toggle(); + rerender(); + expect(scratch.innerHTML).to.equal('<div><p>Hello</p></div>'); + + toggle2(); + rerender(); + expect(scratch.innerHTML).to.equal( + '<div><p>Hello</p></div><div>foobar</div>' + ); + + toggle2(); + rerender(); + expect(scratch.innerHTML).to.equal('<div><p>Hello</p></div>'); + }); + + it('should work with removing an element from stacked container to new one', () => { + let toggle, root2; + + function Foo(props) { + const [root, setRoot] = useState(scratch); + toggle = () => setRoot(() => root2); + return ( + <div + ref={r => { + root2 = r; + }} + > + <p>Hello</p> + {createPortal(props.children, scratch)} + {createPortal(props.children, root)} + </div> + ); + } + + render( + <Foo> + <div>foobar</div> + </Foo>, + scratch + ); + expect(scratch.innerHTML).to.equal( + '<div>foobar</div><div>foobar</div><div><p>Hello</p></div>' + ); + + toggle(); + rerender(); + expect(scratch.innerHTML).to.equal( + '<div>foobar</div><div><p>Hello</p><div>foobar</div></div>' + ); + }); + + it('should support nested portals', () => { + let toggle, toggle2, inner; + + function Bar() { + const [mounted, setMounted] = useState(false); + toggle2 = () => setMounted(s => !s); + return ( + <div + ref={r => { + inner = r; + }} + > + <p>Inner</p> + {mounted && createPortal(<p>hiFromBar</p>, scratch)} + {mounted && createPortal(<p>innerPortal</p>, inner)} + </div> + ); + } + + function Foo(props) { + const [mounted, setMounted] = useState(false); + toggle = () => setMounted(s => !s); + return ( + <div> + <p>Hello</p> + {mounted && createPortal(<Bar />, scratch)} + </div> + ); + } + + render(<Foo />, scratch); + expect(scratch.innerHTML).to.equal('<div><p>Hello</p></div>'); + + toggle(); + rerender(); + expect(scratch.innerHTML).to.equal( + '<div><p>Hello</p></div><div><p>Inner</p></div>' + ); + + toggle2(); + rerender(); + expect(scratch.innerHTML).to.equal( + '<div><p>Hello</p></div><div><p>Inner</p><p>innerPortal</p></div><p>hiFromBar</p>' + ); + + toggle(); + rerender(); + expect(scratch.innerHTML).to.equal('<div><p>Hello</p></div>'); + }); + + it('should support nested portals remounting #2669', () => { + let setVisible; + let i = 0; + + function PortalComponent(props) { + const innerVnode = <div id="inner">{i}</div>; + innerVnode.___id = 'inner_' + i++; + const outerVnode = ( + <div id="outer"> + {i} + {props.show && createPortal(innerVnode, scratch)} + </div> + ); + outerVnode.___id = 'outer_' + i++; + return createPortal(outerVnode, scratch); + } + + function App() { + const [visible, _setVisible] = useState(true); + setVisible = _setVisible; + + return ( + <div id="app"> + test + <PortalComponent show={visible} /> + </div> + ); + } + + render(<App />, scratch); + expect(scratch.innerHTML).to.equal( + '<div id="inner">0</div><div id="outer">1</div><div id="app">test</div>' + ); + + act(() => { + setVisible(false); + }); + rerender(); + expect(scratch.innerHTML).to.equal( + '<div id="outer">3</div><div id="app">test</div>' + ); + + act(() => { + setVisible(true); + }); + rerender(); + expect(scratch.innerHTML).to.equal( + '<div id="outer">5</div><div id="app">test</div><div id="inner">4</div>' + ); + + act(() => { + setVisible(false); + }); + rerender(); + expect(scratch.innerHTML).to.equal( + '<div id="outer">7</div><div id="app">test</div>' + ); + }); + + it('should not unmount when parent renders', () => { + let root = document.createElement('div'); + let dialog = document.createElement('div'); + dialog.id = 'container'; + + scratch.appendChild(root); + scratch.appendChild(dialog); + + let spy = sinon.spy(); + class Child extends Component { + componentDidMount() { + spy(); + } + + render() { + return <div id="child">child</div>; + } + } + + let spyParent = sinon.spy(); + class App extends Component { + componentDidMount() { + spyParent(); + } + render() { + return <div>{createPortal(<Child />, dialog)}</div>; + } + } + + render(<App />, root); + let dom = document.getElementById('child'); + expect(spyParent).to.be.calledOnce; + expect(spy).to.be.calledOnce; + + // Render twice to trigger update scenario + render(<App />, root); + render(<App />, root); + + let domNew = document.getElementById('child'); + expect(dom).to.equal(domNew); + expect(spyParent).to.be.calledOnce; + expect(spy).to.be.calledOnce; + }); + + it('should switch between non portal and portal node (Modal as lastChild)', () => { + let toggle; + const Modal = ({ children, open }) => + open ? createPortal(<div>{children}</div>, scratch) : <div>Closed</div>; + + const App = () => { + const [open, setOpen] = useState(false); + toggle = setOpen.bind(this, x => !x); + return ( + <div> + <button onClick={() => setOpen(!open)}>Show</button> + {open ? 'Open' : 'Closed'} + <Modal open={open}>Hello</Modal> + </div> + ); + }; + + render(<App />, scratch); + expect(scratch.innerHTML).to.equal( + '<div><button>Show</button>Closed<div>Closed</div></div>' + ); + + toggle(); + rerender(); + expect(scratch.innerHTML).to.equal( + '<div><button>Show</button>Open</div><div>Hello</div>' + ); + }); + + it('should switch between non portal and portal node (Modal as firstChild)', () => { + let toggle; + const Modal = ({ children, open }) => + open ? createPortal(<div>{children}</div>, scratch) : <div>Closed</div>; + + const App = () => { + const [open, setOpen] = useState(false); + toggle = setOpen.bind(this, x => !x); + return ( + <div> + <Modal open={open}>Hello</Modal> + <button onClick={() => setOpen(!open)}>Show</button> + {open ? 'Open' : 'Closed'} + </div> + ); + }; + + render(<App />, scratch); + expect(scratch.innerHTML).to.equal( + '<div><div>Closed</div><button>Show</button>Closed</div>' + ); + + toggle(); + rerender(); + expect(scratch.innerHTML).to.equal( + '<div><button>Show</button>Open</div><div>Hello</div>' + ); + + toggle(); + rerender(); + expect(scratch.innerHTML).to.equal( + '<div><div>Closed</div><button>Show</button>Closed</div>' + ); + }); +}); diff --git a/preact/compat/test/browser/render.test.js b/preact/compat/test/browser/render.test.js new file mode 100644 index 0000000..5d9ff5c --- /dev/null +++ b/preact/compat/test/browser/render.test.js @@ -0,0 +1,454 @@ +import React, { + createElement, + render, + Component, + hydrate, + createContext +} from 'preact/compat'; +import { setupRerender, act } from 'preact/test-utils'; +import { + setupScratch, + teardown, + serializeHtml, + createEvent +} from '../../../test/_util/helpers'; + +describe('compat render', () => { + /** @type {HTMLDivElement} */ + let scratch; + + /** @type {() => void} */ + let rerender; + + const ce = type => document.createElement(type); + const text = text => document.createTextNode(text); + + beforeEach(() => { + scratch = setupScratch(); + rerender = setupRerender(); + }); + + afterEach(() => { + teardown(scratch); + }); + + it('should render react-style jsx', () => { + let jsx = ( + <div className="foo bar" data-foo="bar"> + <span id="some_id">inner!</span> + {['a', 'b']} + </div> + ); + + expect(jsx.props).to.have.property('className', 'foo bar'); + + React.render(jsx, scratch); + expect(serializeHtml(scratch)).to.equal( + '<div class="foo bar" data-foo="bar"><span id="some_id">inner!</span>ab</div>' + ); + }); + + it('should replace isomorphic content', () => { + let root = ce('div'); + let initialChild = ce('div'); + initialChild.appendChild(text('initial content')); + root.appendChild(initialChild); + + render(<div>dynamic content</div>, root); + expect(root) + .to.have.property('textContent') + .that.is.a('string') + .that.equals('dynamic content'); + }); + + it('should remove extra elements', () => { + let root = ce('div'); + let inner = ce('div'); + + root.appendChild(inner); + + let c1 = ce('div'); + c1.appendChild(text('isomorphic content')); + inner.appendChild(c1); + + let c2 = ce('div'); + c2.appendChild(text('extra content')); + inner.appendChild(c2); + + render(<div>dynamic content</div>, root); + expect(root) + .to.have.property('textContent') + .that.is.a('string') + .that.equals('dynamic content'); + }); + + // Note: Replacing text nodes inside the root itself is currently unsupported. + // We do replace them everywhere else, though. + it('should remove text nodes', () => { + let root = ce('div'); + + let div = ce('div'); + root.appendChild(div); + div.appendChild(text('Text Content')); + div.appendChild(text('More Text Content')); + + render(<div>dynamic content</div>, root); + expect(root) + .to.have.property('textContent') + .that.is.a('string') + .that.equals('dynamic content'); + }); + + it('should ignore maxLength / minLength when is null', () => { + render(<input maxLength={null} minLength={null} />, scratch); + expect(scratch.firstElementChild.getAttribute('maxlength')).to.equal(null); + expect(scratch.firstElementChild.getAttribute('minlength')).to.equal(null); + }); + + it('should support defaultValue', () => { + render(<input defaultValue="foo" />, scratch); + expect(scratch.firstElementChild).to.have.property('value', 'foo'); + }); + + it('should add defaultValue when value is null/undefined', () => { + render(<input defaultValue="foo" value={null} />, scratch); + expect(scratch.firstElementChild).to.have.property('value', 'foo'); + + render(<input defaultValue="foo" value={undefined} />, scratch); + expect(scratch.firstElementChild).to.have.property('value', 'foo'); + }); + + it('should support defaultValue for select tag', () => { + function App() { + return ( + <select defaultValue="2"> + <option value="1">Picked 1</option> + <option value="2">Picked 2</option> + <option value="3">Picked 3</option> + </select> + ); + } + + render(<App />, scratch); + const options = scratch.firstChild.children; + expect(options[0]).to.have.property('selected', false); + expect(options[1]).to.have.property('selected', true); + }); + + it('should support defaultValue for select tag when using multi selection', () => { + function App() { + return ( + <select multiple defaultValue={['1', '3']}> + <option value="1">Picked 1</option> + <option value="2">Picked 2</option> + <option value="3">Picked 3</option> + </select> + ); + } + + render(<App />, scratch); + const options = scratch.firstChild.children; + expect(options[0]).to.have.property('selected', true); + expect(options[1]).to.have.property('selected', false); + expect(options[2]).to.have.property('selected', true); + }); + + it('should ignore defaultValue when value is 0', () => { + render(<input defaultValue={2} value={0} />, scratch); + expect(scratch.firstElementChild.value).to.equal('0'); + }); + + it('should keep value of uncontrolled inputs using defaultValue', () => { + // See https://github.com/preactjs/preact/issues/2391 + + const spy = sinon.spy(); + + class Input extends Component { + render() { + return ( + <input + type="text" + defaultValue="bar" + onChange={() => { + spy(); + this.forceUpdate(); + }} + /> + ); + } + } + + render(<Input />, scratch); + expect(scratch.firstChild.value).to.equal('bar'); + scratch.firstChild.focus(); + scratch.firstChild.value = 'foo'; + + scratch.firstChild.dispatchEvent(createEvent('input')); + rerender(); + expect(scratch.firstChild.value).to.equal('foo'); + expect(spy).to.be.calledOnce; + }); + + it('should call the callback', () => { + let spy = sinon.spy(); + render(<div />, scratch, spy); + expect(spy).to.be.calledOnce; + expect(spy).to.be.calledWithExactly(); + }); + + // Issue #1727 + it('should destroy the any existing DOM nodes inside the container', () => { + scratch.appendChild(document.createElement('div')); + scratch.appendChild(document.createElement('div')); + + render(<span>foo</span>, scratch); + expect(scratch.innerHTML).to.equal('<span>foo</span>'); + }); + + it('should only destroy existing DOM nodes on first render', () => { + scratch.appendChild(document.createElement('div')); + scratch.appendChild(document.createElement('div')); + + render(<input />, scratch); + + let child = scratch.firstChild; + child.focus(); + render(<input />, scratch); + expect(document.activeElement.nodeName).to.equal('INPUT'); + }); + + it('should normalize class+className even on components', () => { + function Foo(props) { + return ( + <div class={props.class} className={props.className}> + foo + </div> + ); + } + render(<Foo class="foo" />, scratch); + expect(scratch.firstChild.className).to.equal('foo'); + render(null, scratch); + + render(<Foo className="foo" />, scratch); + expect(scratch.firstChild.className).to.equal('foo'); + }); + + it('should normalize className when it has an empty string', () => { + function Foo(props) { + expect(props.className).to.equal(''); + return <div className="">foo</div>; + } + + render(<Foo className="" />, scratch); + }); + + // Issue #2275 + it('should normalize class+className + DOM properties', () => { + function Foo(props) { + return <ul class="old" {...props} />; + } + + render(<Foo fontSize="xlarge" className="new" />, scratch); + expect(scratch.firstChild.className).to.equal('new'); + }); + + it('should give precedence to last-applied class/className prop', () => { + render(<ul className="from className" class="from class" />, scratch); + expect(scratch.firstChild.className).to.equal('from className'); + + render(<ul class="from class" className="from className" />, scratch); + expect(scratch.firstChild.className).to.equal('from className'); + }); + + describe('className normalization', () => { + it('should give precedence to className over class', () => { + const { props } = <ul className="from className" class="from class" />; + expect(props).to.have.property('className', 'from className'); + expect(props).to.have.property('class', 'from className'); + }); + + it('should preserve className, add class alias', () => { + const { props } = <ul className="from className" />; + expect(props).to.have.property('className', 'from className'); + expect(props).to.have.property('class', 'from className'); + }); + + it('should preserve class, and add className alias', () => { + const { props } = <ul class="from class" />; + expect(props).to.have.property('class', 'from class'); + expect(props.propertyIsEnumerable('className')).to.equal(false); + expect(props).to.have.property('className', 'from class'); + }); + + it('should preserve class when spreading', () => { + const { props } = <ul class="from class" />; + const spreaded = (<li a {...props} />).props; + expect(spreaded).to.have.property('class', 'from class'); + expect(spreaded.propertyIsEnumerable('className')).to.equal(false); + expect(spreaded).to.have.property('className', 'from class'); + }); + + it('should preserve className when spreading', () => { + const { props } = <ul className="from className" />; + const spreaded = (<li a {...props} />).props; + expect(spreaded).to.have.property('className', 'from className'); + expect(spreaded).to.have.property('class', 'from className'); + expect(spreaded.propertyIsEnumerable('class')).to.equal(true); + }); + + // Issue #2772 + it('should give precedence to className from spread props', () => { + const Foo = ({ className, ...props }) => { + return <div className={`${className} foo`} {...props} />; + }; + render(<Foo className="bar" />, scratch); + expect(scratch.firstChild.className).to.equal('bar foo'); + }); + + it('should give precedence to class from spread props', () => { + const Foo = ({ class: c, ...props }) => { + return <div class={`${c} foo`} {...props} />; + }; + render(<Foo class="bar" />, scratch); + expect(scratch.firstChild.className).to.equal('bar foo'); + }); + + // Issue #2224 + it('should not mark both class and className as enumerable', () => { + function ClassNameCheck(props) { + return ( + <div>{props.propertyIsEnumerable('className') ? 'Failed' : ''}</div> + ); + } + + let update; + class OtherThing extends Component { + render({ children }) { + update = () => this.forceUpdate(); + return ( + <div> + {children} + <ClassNameCheck class="test" /> + </div> + ); + } + } + + function App() { + return ( + <OtherThing> + <ClassNameCheck class="test" /> + </OtherThing> + ); + } + + render(<App />, scratch); + + update(); + rerender(); + + expect(/Failed/g.test(scratch.textContent)).to.equal( + false, + 'not enumerable' + ); + }); + }); + + it('should cast boolean "download" values', () => { + render(<a download />, scratch); + expect(scratch.firstChild.getAttribute('download')).to.equal(''); + + render(<a download={false} />, scratch); + expect(scratch.firstChild.getAttribute('download')).to.equal(null); + }); + + it('should support static content', () => { + const updateSpy = sinon.spy(); + const mountSpy = sinon.spy(); + const renderSpy = sinon.spy(); + + function StaticContent({ children, element = 'div', staticMode }) { + // if we're in the server or a spa navigation, just render it + if (!staticMode) { + return createElement(element, { + children + }); + } + + // avoid re-render on the client + return createElement(element, { + dangerouslySetInnerHTML: { __html: '' } + }); + } + + class App extends Component { + componentDidMount() { + mountSpy(); + } + + componentDidUpdate() { + updateSpy(); + } + + render() { + renderSpy(); + return <div>Staticness</div>; + } + } + + act(() => { + render( + <StaticContent staticMode={false}> + <App /> + </StaticContent>, + scratch + ); + }); + + expect(scratch.innerHTML).to.eq('<div><div>Staticness</div></div>'); + expect(renderSpy).to.be.calledOnce; + expect(mountSpy).to.be.calledOnce; + expect(updateSpy).to.not.be.calledOnce; + + act(() => { + hydrate( + <StaticContent staticMode> + <App /> + </StaticContent>, + scratch + ); + }); + + expect(scratch.innerHTML).to.eq('<div><div>Staticness</div></div>'); + expect(renderSpy).to.be.calledOnce; + expect(mountSpy).to.be.calledOnce; + expect(updateSpy).to.not.be.calledOnce; + }); + + it("should support react-relay's usage of __SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED", () => { + const Ctx = createContext('foo'); + + // Simplified version of: https://github.com/facebook/relay/blob/fba79309977bf6b356ee77a5421ca5e6f306223b/packages/react-relay/readContext.js#L17-L28 + function readContext(Context) { + const { + ReactCurrentDispatcher + } = React.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED; + const dispatcher = ReactCurrentDispatcher.current; + return dispatcher.readContext(Context); + } + + function Foo() { + const value = readContext(Ctx); + return <div>{value}</div>; + } + + React.render( + <Ctx.Provider value="foo"> + <Foo /> + </Ctx.Provider>, + scratch + ); + + expect(scratch.textContent).to.equal('foo'); + }); +}); diff --git a/preact/compat/test/browser/scheduler.test.js b/preact/compat/test/browser/scheduler.test.js new file mode 100644 index 0000000..fdb426d --- /dev/null +++ b/preact/compat/test/browser/scheduler.test.js @@ -0,0 +1,39 @@ +import { + unstable_runWithPriority, + unstable_NormalPriority, + unstable_LowPriority, + unstable_IdlePriority, + unstable_UserBlockingPriority, + unstable_ImmediatePriority, + unstable_now +} from 'preact/compat/scheduler'; + +describe('scheduler', () => { + describe('runWithPriority', () => { + it('should call callback ', () => { + const spy = sinon.spy(); + unstable_runWithPriority(unstable_IdlePriority, spy); + expect(spy.callCount).to.equal(1); + + unstable_runWithPriority(unstable_LowPriority, spy); + expect(spy.callCount).to.equal(2); + + unstable_runWithPriority(unstable_NormalPriority, spy); + expect(spy.callCount).to.equal(3); + + unstable_runWithPriority(unstable_UserBlockingPriority, spy); + expect(spy.callCount).to.equal(4); + + unstable_runWithPriority(unstable_ImmediatePriority, spy); + expect(spy.callCount).to.equal(5); + }); + }); + + describe('unstable_now', () => { + it('should return number', () => { + const res = unstable_now(); + expect(res).is.a('number'); + expect(res > 0).to.equal(true); + }); + }); +}); diff --git a/preact/compat/test/browser/select.test.js b/preact/compat/test/browser/select.test.js new file mode 100644 index 0000000..bf8c9b9 --- /dev/null +++ b/preact/compat/test/browser/select.test.js @@ -0,0 +1,33 @@ +import { setupScratch, teardown } from '../../../test/_util/helpers'; +import React, { createElement, render } from 'preact/compat'; + +describe('Select', () => { + let scratch; + + beforeEach(() => { + scratch = setupScratch(); + }); + + afterEach(() => { + teardown(scratch); + }); + + it('should work with multiple selected (array of values)', () => { + function App() { + return ( + <select multiple value={['B', 'C']}> + <option value="A">A</option> + <option value="B">B</option> + <option value="C">C</option> + </select> + ); + } + + render(<App />, scratch); + const options = scratch.firstChild.children; + expect(options[0]).to.have.property('selected', false); + expect(options[1]).to.have.property('selected', true); + expect(options[2]).to.have.property('selected', true); + expect(scratch.firstChild.value).to.equal('B'); + }); +}); 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; + }); + }); +}); diff --git a/preact/compat/test/browser/suspense-list.test.js b/preact/compat/test/browser/suspense-list.test.js new file mode 100644 index 0000000..f3d4c91 --- /dev/null +++ b/preact/compat/test/browser/suspense-list.test.js @@ -0,0 +1,588 @@ +import { setupRerender } from 'preact/test-utils'; +import React, { + createElement, + render, + Component, + Suspense, + SuspenseList +} from 'preact/compat'; +import { useState } from 'preact/hooks'; +import { setupScratch, teardown } from '../../../test/_util/helpers'; + +const h = React.createElement; +/* eslint-env browser, mocha */ + +function getSuspendableComponent(text) { + let resolve; + let resolved = false; + const promise = new Promise(_resolve => { + resolve = () => { + resolved = true; + _resolve(); + return promise; + }; + }); + + class LifecycleSuspender extends Component { + render() { + if (!resolved) { + throw promise; + } + return <span>{text}</span>; + } + } + + LifecycleSuspender.resolve = () => { + resolve(); + }; + + return LifecycleSuspender; +} + +describe('suspense-list', () => { + /** @type {HTMLDivElement} */ + let scratch, + rerender, + unhandledEvents = []; + + function onUnhandledRejection(event) { + unhandledEvents.push(event); + } + + function getSuspenseList(revealOrder) { + const A = getSuspendableComponent('A'); + const B = getSuspendableComponent('B'); + const C = getSuspendableComponent('C'); + render( + <SuspenseList revealOrder={revealOrder}> + <Suspense fallback={<span>Loading...</span>}> + <A /> + </Suspense> + <Suspense fallback={<span>Loading...</span>}> + <B /> + </Suspense> + <Suspense fallback={<span>Loading...</span>}> + <C /> + </Suspense> + </SuspenseList>, + scratch + ); // Render initial state + + return [A.resolve, B.resolve, C.resolve]; + } + + function getNestedSuspenseList(outerRevealOrder, innerRevealOrder) { + const A = getSuspendableComponent('A'); + const B = getSuspendableComponent('B'); + const C = getSuspendableComponent('C'); + const D = getSuspendableComponent('D'); + + render( + <SuspenseList revealOrder={outerRevealOrder}> + <Suspense fallback={<span>Loading...</span>}> + <A /> + </Suspense> + <SuspenseList revealOrder={innerRevealOrder}> + <Suspense fallback={<span>Loading...</span>}> + <B /> + </Suspense> + <Suspense fallback={<span>Loading...</span>}> + <C /> + </Suspense> + </SuspenseList> + <Suspense fallback={<span>Loading...</span>}> + <D /> + </Suspense> + </SuspenseList>, + scratch + ); + return [A.resolve, B.resolve, C.resolve, D.resolve]; + } + + 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 work for single element', async () => { + const Component = getSuspendableComponent('A'); + render( + <SuspenseList> + <Suspense fallback={<span>Loading...</span>}> + <Component /> + </Suspense> + </SuspenseList>, + scratch + ); // Render initial state + + rerender(); // Re-render with fallback cuz lazy threw + expect(scratch.innerHTML).to.eql(`<span>Loading...</span>`); + + await Component.resolve(); + rerender(); + expect(scratch.innerHTML).to.eql(`<span>A</span>`); + }); + + it('should let components appear backwards if no revealOrder is mentioned', async () => { + const [resolver1, resolver2, resolver3] = getSuspenseList(); + + rerender(); // Re-render with fallback cuz lazy threw + expect(scratch.innerHTML).to.eql( + `<span>Loading...</span><span>Loading...</span><span>Loading...</span>` + ); + + await resolver2(); + rerender(); + expect(scratch.innerHTML).to.eql( + `<span>Loading...</span><span>B</span><span>Loading...</span>` + ); + + await resolver3(); + rerender(); + expect(scratch.innerHTML).to.eql( + `<span>Loading...</span><span>B</span><span>C</span>` + ); + + await resolver1(); + rerender(); + expect(scratch.innerHTML).to.eql( + `<span>A</span><span>B</span><span>C</span>` + ); + }); + + it('should let components appear forwards if no revealOrder is mentioned', async () => { + const [resolver1, resolver2, resolver3] = getSuspenseList(); + + rerender(); // Re-render with fallback cuz lazy threw + expect(scratch.innerHTML).to.eql( + `<span>Loading...</span><span>Loading...</span><span>Loading...</span>` + ); + + await resolver1(); + rerender(); + expect(scratch.innerHTML).to.eql( + `<span>A</span><span>Loading...</span><span>Loading...</span>` + ); + + await resolver2(); + rerender(); + expect(scratch.innerHTML).to.eql( + `<span>A</span><span>B</span><span>Loading...</span>` + ); + + await resolver3(); + rerender(); + expect(scratch.innerHTML).to.eql( + `<span>A</span><span>B</span><span>C</span>` + ); + }); + + it('should let components appear in forwards if revealOrder=forwards and first one resolves before others', async () => { + const [resolver1, resolver2, resolver3] = getSuspenseList('forwards'); + + rerender(); // Re-render with fallback cuz lazy threw + expect(scratch.innerHTML).to.eql( + `<span>Loading...</span><span>Loading...</span><span>Loading...</span>` + ); + + await resolver1(); + rerender(); + expect(scratch.innerHTML).to.eql( + `<span>A</span><span>Loading...</span><span>Loading...</span>` + ); + + await resolver3(); + rerender(); + expect(scratch.innerHTML).to.eql( + `<span>A</span><span>Loading...</span><span>Loading...</span>` + ); + + await resolver2(); + rerender(); + expect(scratch.innerHTML).to.eql( + `<span>A</span><span>B</span><span>C</span>` + ); + }); + + it('should make components appear together if revealOrder=forwards and others resolves before first', async () => { + const [resolver1, resolver2, resolver3] = getSuspenseList('forwards'); + + rerender(); // Re-render with fallback cuz lazy threw + expect(scratch.innerHTML).to.eql( + `<span>Loading...</span><span>Loading...</span><span>Loading...</span>` + ); + + await resolver2(); + rerender(); + expect(scratch.innerHTML).to.eql( + `<span>Loading...</span><span>Loading...</span><span>Loading...</span>` + ); + + await resolver3(); + rerender(); + expect(scratch.innerHTML).to.eql( + `<span>Loading...</span><span>Loading...</span><span>Loading...</span>` + ); + + await resolver1(); + rerender(); + expect(scratch.innerHTML).to.eql( + `<span>A</span><span>B</span><span>C</span>` + ); + }); + + it('should let components appear backwards if revealOrder=backwards and others resolves before first', async () => { + const [resolver1, resolver2, resolver3] = getSuspenseList('backwards'); + + rerender(); // Re-render with fallback cuz lazy threw + expect(scratch.innerHTML).to.eql( + `<span>Loading...</span><span>Loading...</span><span>Loading...</span>` + ); + + await resolver3(); + rerender(); + expect(scratch.innerHTML).to.eql( + `<span>Loading...</span><span>Loading...</span><span>C</span>` + ); + + await resolver2(); + rerender(); + expect(scratch.innerHTML).to.eql( + `<span>Loading...</span><span>B</span><span>C</span>` + ); + + await resolver1(); + rerender(); + expect(scratch.innerHTML).to.eql( + `<span>A</span><span>B</span><span>C</span>` + ); + }); + + it('should make components appear together if revealOrder=backwards and first one resolves others', async () => { + const [resolver1, resolver2, resolver3] = getSuspenseList('backwards'); + + rerender(); // Re-render with fallback cuz lazy threw + expect(scratch.innerHTML).to.eql( + `<span>Loading...</span><span>Loading...</span><span>Loading...</span>` + ); + + await resolver1(); + rerender(); + expect(scratch.innerHTML).to.eql( + `<span>Loading...</span><span>Loading...</span><span>Loading...</span>` + ); + + await resolver3(); + rerender(); + expect(scratch.innerHTML).to.eql( + `<span>Loading...</span><span>Loading...</span><span>C</span>` + ); + + await resolver2(); + rerender(); + expect(scratch.innerHTML).to.eql( + `<span>A</span><span>B</span><span>C</span>` + ); + }); + + it('should make components appear together if revealOrder=together and first one resolves others', async () => { + const [resolver1, resolver2, resolver3] = getSuspenseList('together'); + + rerender(); // Re-render with fallback cuz lazy threw + expect(scratch.innerHTML).to.eql( + `<span>Loading...</span><span>Loading...</span><span>Loading...</span>` + ); + + await resolver1(); + rerender(); + expect(scratch.innerHTML).to.eql( + `<span>Loading...</span><span>Loading...</span><span>Loading...</span>` + ); + + await resolver3(); + rerender(); + expect(scratch.innerHTML).to.eql( + `<span>Loading...</span><span>Loading...</span><span>Loading...</span>` + ); + + await resolver2(); + rerender(); + expect(scratch.innerHTML).to.eql( + `<span>A</span><span>B</span><span>C</span>` + ); + }); + + it('should make components appear together if revealOrder=together and second one resolves before others', async () => { + const [resolver1, resolver2, resolver3] = getSuspenseList('together'); + + rerender(); // Re-render with fallback cuz lazy threw + expect(scratch.innerHTML).to.eql( + `<span>Loading...</span><span>Loading...</span><span>Loading...</span>` + ); + + await resolver2(); + rerender(); + expect(scratch.innerHTML).to.eql( + `<span>Loading...</span><span>Loading...</span><span>Loading...</span>` + ); + + await resolver1(); + rerender(); + expect(scratch.innerHTML).to.eql( + `<span>Loading...</span><span>Loading...</span><span>Loading...</span>` + ); + + await resolver3(); + rerender(); + expect(scratch.innerHTML).to.eql( + `<span>A</span><span>B</span><span>C</span>` + ); + }); + + it('should not do anything to non suspense elements', async () => { + const A = getSuspendableComponent('A'); + const B = getSuspendableComponent('B'); + render( + <SuspenseList> + <Suspense fallback={<span>Loading...</span>}> + <A /> + </Suspense> + <div>foo</div> + <Suspense fallback={<span>Loading...</span>}> + <B /> + </Suspense> + <span>bar</span> + </SuspenseList>, + scratch + ); + + rerender(); // Re-render with fallback cuz lazy threw + expect(scratch.innerHTML).to.eql( + `<span>Loading...</span><div>foo</div><span>Loading...</span><span>bar</span>` + ); + + await A.resolve(); + rerender(); + expect(scratch.innerHTML).to.eql( + `<span>A</span><div>foo</div><span>Loading...</span><span>bar</span>` + ); + + await B.resolve(); + rerender(); + expect(scratch.innerHTML).to.eql( + `<span>A</span><div>foo</div><span>B</span><span>bar</span>` + ); + }); + + it('should make sure nested SuspenseList works with forwards', async () => { + const [resolveA, resolveB, resolveC, resolveD] = getNestedSuspenseList( + 'forwards', + 'forwards' + ); + rerender(); // Re-render with fallback cuz lazy threw + expect(scratch.innerHTML).to.eql( + `<span>Loading...</span><span>Loading...</span><span>Loading...</span><span>Loading...</span>` + ); + + await resolveB(); + rerender(); + expect(scratch.innerHTML).to.eql( + `<span>Loading...</span><span>Loading...</span><span>Loading...</span><span>Loading...</span>` + ); + + await resolveA(); + rerender(); + expect(scratch.innerHTML).to.eql( + `<span>A</span><span>B</span><span>Loading...</span><span>Loading...</span>` + ); + + await resolveC(); + rerender(); + expect(scratch.innerHTML).to.eql( + `<span>A</span><span>B</span><span>C</span><span>Loading...</span>` + ); + + await resolveD(); + rerender(); + expect(scratch.innerHTML).to.eql( + `<span>A</span><span>B</span><span>C</span><span>D</span>` + ); + }); + + it('should make sure nested SuspenseList works with backwards', async () => { + const [resolveA, resolveB, resolveC, resolveD] = getNestedSuspenseList( + 'forwards', + 'backwards' + ); + rerender(); // Re-render with fallback cuz lazy threw + expect(scratch.innerHTML).to.eql( + `<span>Loading...</span><span>Loading...</span><span>Loading...</span><span>Loading...</span>` + ); + + await resolveA(); + rerender(); + expect(scratch.innerHTML).to.eql( + `<span>A</span><span>Loading...</span><span>Loading...</span><span>Loading...</span>` + ); + + await resolveC(); + rerender(); + expect(scratch.innerHTML).to.eql( + `<span>A</span><span>Loading...</span><span>C</span><span>Loading...</span>` + ); + + await resolveB(); + rerender(); + expect(scratch.innerHTML).to.eql( + `<span>A</span><span>B</span><span>C</span><span>Loading...</span>` + ); + + await resolveD(); + rerender(); + expect(scratch.innerHTML).to.eql( + `<span>A</span><span>B</span><span>C</span><span>D</span>` + ); + }); + + it('should make sure nested SuspenseList works with together', async () => { + const [resolveA, resolveB, resolveC, resolveD] = getNestedSuspenseList( + 'together', + 'forwards' + ); + rerender(); // Re-render with fallback cuz lazy threw + expect(scratch.innerHTML).to.eql( + `<span>Loading...</span><span>Loading...</span><span>Loading...</span><span>Loading...</span>` + ); + + await resolveA(); + rerender(); + expect(scratch.innerHTML).to.eql( + `<span>Loading...</span><span>Loading...</span><span>Loading...</span><span>Loading...</span>` + ); + + await resolveD(); + rerender(); + expect(scratch.innerHTML).to.eql( + `<span>Loading...</span><span>Loading...</span><span>Loading...</span><span>Loading...</span>` + ); + + await resolveB(); + rerender(); + expect(scratch.innerHTML).to.eql( + `<span>Loading...</span><span>Loading...</span><span>Loading...</span><span>Loading...</span>` + ); + + await resolveC(); + rerender(); + expect(scratch.innerHTML).to.eql( + `<span>A</span><span>B</span><span>C</span><span>D</span>` + ); + }); + + it('should work with forwards even when a <Suspense> child does not suspend', async () => { + const Component = getSuspendableComponent('A'); + + render( + <SuspenseList revealOrder="forwards"> + <Suspense fallback={<span>Loading...</span>}> + <div /> + </Suspense> + <Suspense fallback={<span>Loading...</span>}> + <Component /> + </Suspense> + </SuspenseList>, + scratch + ); // Render initial state + + rerender(); + expect(scratch.innerHTML).to.eql(`<div></div><span>Loading...</span>`); + + await Component.resolve(); + rerender(); + expect(scratch.innerHTML).to.eql(`<div></div><span>A</span>`); + }); + + it('should work with together even when a <Suspense> child does not suspend', async () => { + const Component = getSuspendableComponent('A'); + + render( + <SuspenseList revealOrder="together"> + <Suspense fallback={<span>Loading...</span>}> + <div /> + </Suspense> + <Suspense fallback={<span>Loading...</span>}> + <Component /> + </Suspense> + </SuspenseList>, + scratch + ); // Render initial state + + rerender(); + expect(scratch.innerHTML).to.eql(`<div></div><span>Loading...</span>`); + + await Component.resolve(); + rerender(); + expect(scratch.innerHTML).to.eql(`<div></div><span>A</span>`); + }); + + it('should not suspend resolved children if a new suspense comes in between', async () => { + const ComponentA = getSuspendableComponent('A'); + const ComponentB = getSuspendableComponent('B'); + + let showB; + function Container() { + const [showHidden, setShowHidden] = useState(false); + showB = setShowHidden; + return ( + <SuspenseList revealOrder="together"> + <Suspense fallback={<span>Loading...</span>}> + <div /> + </Suspense> + {showHidden && ( + <Suspense fallback={<span>Loading...</span>}> + <ComponentB /> + </Suspense> + )} + <Suspense fallback={<span>Loading...</span>}> + <ComponentA /> + </Suspense> + </SuspenseList> + ); + } + render(<Container />, scratch); // Render initial state + + rerender(); + expect(scratch.innerHTML).to.eql(`<div></div><span>Loading...</span>`); + + await ComponentA.resolve(); + rerender(); + expect(scratch.innerHTML).to.eql(`<div></div><span>A</span>`); + + showB(true); + rerender(); + expect(scratch.innerHTML).to.eql( + `<div></div><span>Loading...</span><span>A</span>` + ); + + await ComponentB.resolve(); + rerender(); + expect(scratch.innerHTML).to.eql(`<div></div><span>B</span><span>A</span>`); + }); +}); diff --git a/preact/compat/test/browser/suspense-utils.js b/preact/compat/test/browser/suspense-utils.js new file mode 100644 index 0000000..f8f380b --- /dev/null +++ b/preact/compat/test/browser/suspense-utils.js @@ -0,0 +1,116 @@ +import React, { Component, lazy } from 'preact/compat'; + +const h = React.createElement; + +/** + * Create a Lazy component whose promise is controlled by by the test. This + * function returns 3 values: The Lazy component to render, a `resolve` + * function, and a `reject` function. Call `resolve` with the component the Lazy + * component should resolve with. Call `reject` with the error the Lazy + * component should reject with + * + * @example + * // 1. Create and render the Lazy component + * const [Lazy, resolve] = createLazy(); + * render( + * <Suspense fallback={<div>Suspended...</div>}> + * <Lazy /> + * </Suspense>, + * scratch + * ); + * rerender(); // Rerender is required so the fallback is displayed + * expect(scratch.innerHTML).to.eql(`<div>Suspended...</div>`); + * + * // 2. Resolve the Lazy with a new component to render + * return resolve(() => <div>Hello</div>).then(() => { + * rerender(); + * expect(scratch.innerHTML).to.equal(`<div>Hello</div>`); + * }); + * + * @typedef {import('../../../src').ComponentType<any>} ComponentType + * @returns {[typeof Component, (c: ComponentType) => Promise<void>, (e: Error) => Promise<void>]} + */ +export function createLazy() { + /** @type {(c: ComponentType) => Promise<void>} */ + let resolver, rejecter; + const Lazy = lazy(() => { + let promise = new Promise((resolve, reject) => { + resolver = c => { + resolve({ default: c }); + return promise; + }; + + rejecter = e => { + reject(e); + return promise; + }; + }); + + return promise; + }); + + return [Lazy, c => resolver(c), e => rejecter(e)]; +} + +/** + * Returns a Component and a function (named `suspend`) that will suspend the component when called. + * `suspend` will return two functions, `resolve` and `reject`. Call `resolve` with a Component the + * suspended component should resume with or reject with the Error the suspended component should + * reject with + * + * @example + * // 1. Create a suspender with initial children (argument to createSuspender) and render it + * const [Suspender, suspend] = createSuspender(() => <div>Hello</div>); + * render( + * <Suspense fallback={<div>Suspended...</div>}> + * <Suspender /> + * </Suspense>, + * scratch + * ); + * expect(scratch.innerHTML).to.eql(`div>Hello</div>`); + * + * // 2. Cause the component to suspend and rerender the update (i.e. the fallback) + * const [resolve] = suspend(); + * rerender(); + * expect(scratch.innerHTML).to.eql(`div>Suspended...</div>`); + * + * // 3. Resolve the suspended component with a new component and rerender + * return resolve(() => <div>Hello2</div>).then(() => { + * rerender(); + * expect(scratch.innerHTML).to.eql(`div>Hello2</div>`); + * }); + * + * @typedef {Component<{}, any>} Suspender + * @typedef {[(c: ComponentType) => Promise<void>, (error: Error) => Promise<void>]} Resolvers + * @param {ComponentType} DefaultComponent + * @returns {[typeof Suspender, () => Resolvers]} + */ +export function createSuspender(DefaultComponent) { + /** @type {(lazy: typeof Component) => void} */ + let renderLazy; + class Suspender extends Component { + constructor(props, context) { + super(props, context); + this.state = { Lazy: null }; + + renderLazy = Lazy => this.setState({ Lazy }); + } + + render(props, state) { + return state.Lazy ? h(state.Lazy, props) : h(DefaultComponent, props); + } + } + + sinon.spy(Suspender.prototype, 'render'); + + /** + * @returns {Resolvers} + */ + function suspend() { + const [Lazy, resolve, reject] = createLazy(); + renderLazy(Lazy); + return [resolve, reject]; + } + + return [Suspender, suspend]; +} diff --git a/preact/compat/test/browser/suspense.test.js b/preact/compat/test/browser/suspense.test.js new file mode 100644 index 0000000..cc2cd84 --- /dev/null +++ b/preact/compat/test/browser/suspense.test.js @@ -0,0 +1,2091 @@ +import { setupRerender } from 'preact/test-utils'; +import React, { + createElement, + render, + Component, + Suspense, + lazy, + Fragment, + createContext, + useState, + useEffect, + useLayoutEffect +} from 'preact/compat'; +import { setupScratch, teardown } from '../../../test/_util/helpers'; +import { createLazy, createSuspender } from './suspense-utils'; + +const h = React.createElement; +/* eslint-env browser, mocha */ + +class Catcher extends Component { + constructor(props) { + super(props); + this.state = { error: false }; + } + + componentDidCatch(e) { + if (e.then) { + this.setState({ error: { message: '{Promise}' } }); + } else { + this.setState({ error: e }); + } + } + + render(props, state) { + return state.error ? ( + <div>Catcher did catch: {state.error.message}</div> + ) : ( + props.children + ); + } +} + +describe('suspense', () => { + /** @type {HTMLDivElement} */ + let scratch, + rerender, + unhandledEvents = []; + + function onUnhandledRejection(event) { + unhandledEvents.push(event); + } + + 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 support lazy', () => { + const LazyComp = ({ name }) => <div>Hello from {name}</div>; + + /** @type {() => Promise<void>} */ + let resolve; + const Lazy = lazy(() => { + const p = new Promise(res => { + resolve = () => { + res({ default: LazyComp }); + return p; + }; + }); + + return p; + }); + + render( + <Suspense fallback={<div>Suspended...</div>}> + <Lazy name="LazyComp" /> + </Suspense>, + scratch + ); // Render initial state + rerender(); // Re-render with fallback cuz lazy threw + + expect(scratch.innerHTML).to.eql(`<div>Suspended...</div>`); + + return resolve().then(() => { + rerender(); + expect(scratch.innerHTML).to.eql(`<div>Hello from LazyComp</div>`); + }); + }); + + it('should reset hooks of components', () => { + let set; + const LazyComp = ({ name }) => <div>Hello from {name}</div>; + + /** @type {() => Promise<void>} */ + let resolve; + const Lazy = lazy(() => { + const p = new Promise(res => { + resolve = () => { + res({ default: LazyComp }); + return p; + }; + }); + + return p; + }); + + const Parent = ({ children }) => { + const [state, setState] = useState(false); + set = setState; + + return ( + <div> + <p>hi</p> + {state && children} + </div> + ); + }; + + render( + <Suspense fallback={<div>Suspended...</div>}> + <Parent> + <Lazy name="LazyComp" /> + </Parent> + </Suspense>, + scratch + ); + expect(scratch.innerHTML).to.eql(`<div><p>hi</p></div>`); + + set(true); + rerender(); + + expect(scratch.innerHTML).to.eql('<div>Suspended...</div>'); + + return resolve().then(() => { + rerender(); + expect(scratch.innerHTML).to.eql(`<div><p>hi</p></div>`); + }); + }); + + it('should call effect cleanups', () => { + let set; + const effectSpy = sinon.spy(); + const layoutEffectSpy = sinon.spy(); + const LazyComp = ({ name }) => <div>Hello from {name}</div>; + + /** @type {() => Promise<void>} */ + let resolve; + const Lazy = lazy(() => { + const p = new Promise(res => { + resolve = () => { + res({ default: LazyComp }); + return p; + }; + }); + + return p; + }); + + const Parent = ({ children }) => { + const [state, setState] = useState(false); + set = setState; + useEffect(() => { + return () => { + effectSpy(); + }; + }, []); + + useLayoutEffect(() => { + return () => { + layoutEffectSpy(); + }; + }, []); + + return state ? ( + <div>{children}</div> + ) : ( + <div> + <p>hi</p> + </div> + ); + }; + + render( + <Suspense fallback={<div>Suspended...</div>}> + <Parent> + <Lazy name="LazyComp" /> + </Parent> + </Suspense>, + scratch + ); + + set(true); + rerender(); + expect(scratch.innerHTML).to.eql('<div>Suspended...</div>'); + expect(effectSpy).to.be.calledOnce; + expect(layoutEffectSpy).to.be.calledOnce; + + return resolve().then(() => { + rerender(); + expect(effectSpy).to.be.calledOnce; + expect(layoutEffectSpy).to.be.calledOnce; + expect(scratch.innerHTML).to.eql(`<div><p>hi</p></div>`); + }); + }); + + it('should support a call to setState before rendering the fallback', () => { + const LazyComp = ({ name }) => <div>Hello from {name}</div>; + + /** @type {() => Promise<void>} */ + let resolve; + const Lazy = lazy(() => { + const p = new Promise(res => { + resolve = () => { + res({ default: LazyComp }); + return p; + }; + }); + + return p; + }); + + /** @type {(Object) => void} */ + let setState; + class App extends Component { + constructor(props) { + super(props); + this.state = {}; + setState = this.setState.bind(this); + } + render(props, state) { + return ( + <Fragment> + <Suspense fallback={<div>Suspended...</div>}> + <Lazy name="LazyComp" /> + </Suspense> + </Fragment> + ); + } + } + + render(<App />, scratch); // Render initial state + + setState({ foo: 'bar' }); + rerender(); + + expect(scratch.innerHTML).to.eql(`<div>Suspended...</div>`); + + return resolve().then(() => { + rerender(); + expect(scratch.innerHTML).to.eql(`<div>Hello from LazyComp</div>`); + }); + }); + + it('lazy should forward refs', () => { + const LazyComp = () => <div>Hello from LazyComp</div>; + let ref = {}; + + /** @type {() => Promise<void>} */ + let resolve; + const Lazy = lazy(() => { + const p = new Promise(res => { + resolve = () => { + res({ default: LazyComp }); + return p; + }; + }); + + return p; + }); + + render( + <Suspense fallback={<div>Suspended...</div>}> + <Lazy ref={ref} /> + </Suspense>, + scratch + ); + rerender(); + + return resolve().then(() => { + rerender(); + expect(ref.current.constructor).to.equal(LazyComp); + }); + }); + + it('should suspend when a promise is thrown', () => { + class ClassWrapper extends Component { + render(props) { + return <div id="class-wrapper">{props.children}</div>; + } + } + + const FuncWrapper = props => <div id="func-wrapper">{props.children}</div>; + + const [Suspender, suspend] = createSuspender(() => <div>Hello</div>); + + render( + <Suspense fallback={<div>Suspended...</div>}> + <ClassWrapper> + <FuncWrapper> + <Suspender /> + </FuncWrapper> + </ClassWrapper> + </Suspense>, + scratch + ); + + expect(scratch.innerHTML).to.eql( + `<div id="class-wrapper"><div id="func-wrapper"><div>Hello</div></div></div>` + ); + + const [resolve] = suspend(); + rerender(); + + expect(scratch.innerHTML).to.eql(`<div>Suspended...</div>`); + + return resolve(() => <div>Hello2</div>).then(() => { + rerender(); + expect(scratch.innerHTML).to.eql( + `<div id="class-wrapper"><div id="func-wrapper"><div>Hello2</div></div></div>` + ); + }); + }); + + it('should not call lifecycle methods of an initially suspending component', () => { + let componentWillMount = sinon.spy(); + let componentDidMount = sinon.spy(); + let componentWillUnmount = sinon.spy(); + + /** @type {() => Promise<void>} */ + let resolve; + let resolved = false; + const promise = new Promise(_resolve => { + resolve = () => { + resolved = true; + _resolve(); + return promise; + }; + }); + + class LifecycleSuspender extends Component { + render() { + if (!resolved) { + throw promise; + } + return <div>Lifecycle</div>; + } + componentWillMount() { + componentWillMount(); + } + componentDidMount() { + componentDidMount(); + } + componentWillUnmount() { + componentWillUnmount(); + } + } + + render( + <Suspense fallback={<div>Suspended...</div>}> + <LifecycleSuspender /> + </Suspense>, + scratch + ); + + expect(scratch.innerHTML).to.eql(``); + expect(componentWillMount).to.have.been.calledOnce; + expect(componentDidMount).to.not.have.been.called; + expect(componentWillUnmount).to.not.have.been.called; + + rerender(); + + expect(scratch.innerHTML).to.eql(`<div>Suspended...</div>`); + expect(componentWillMount).to.have.been.calledOnce; + expect(componentDidMount).to.not.have.been.called; + expect(componentWillUnmount).to.not.have.been.called; + + return resolve().then(() => { + rerender(); + expect(scratch.innerHTML).to.eql(`<div>Lifecycle</div>`); + + expect(componentWillMount).to.have.been.calledOnce; + expect(componentDidMount).to.have.been.calledOnce; + expect(componentWillUnmount).to.not.have.been.called; + }); + }); + + it('should properly call lifecycle methods and maintain state of a delayed suspending component', () => { + let componentWillMount = sinon.spy(); + let componentDidMount = sinon.spy(); + let componentDidUpdate = sinon.spy(); + let componentWillUnmount = sinon.spy(); + + /** @type {() => void} */ + let increment; + + /** @type {() => Promise<void>} */ + let resolve; + let resolved = false; + const promise = new Promise(_resolve => { + resolve = () => { + resolved = true; + _resolve(); + return promise; + }; + }); + + class LifecycleSuspender extends Component { + constructor(props) { + super(props); + this.state = { count: 0 }; + + increment = () => this.setState(({ count }) => ({ count: count + 1 })); + } + render() { + if (this.state.count == 2 && !resolved) { + throw promise; + } + + return ( + <Fragment> + <p>Count: {this.state.count}</p> + </Fragment> + ); + } + componentWillMount() { + componentWillMount(); + } + componentDidMount() { + componentDidMount(); + } + componentWillUnmount() { + componentWillUnmount(); + } + componentDidUpdate() { + componentDidUpdate(); + } + } + + render( + <Suspense fallback={<div>Suspended...</div>}> + <LifecycleSuspender /> + </Suspense>, + scratch + ); + + expect(scratch.innerHTML).to.eql(`<p>Count: 0</p>`); + expect(componentWillMount).to.have.been.calledOnce; + expect(componentDidMount).to.have.been.calledOnce; + expect(componentDidUpdate).to.not.have.been.called; + expect(componentWillUnmount).to.not.have.been.called; + + increment(); + rerender(); + + expect(scratch.innerHTML).to.eql(`<p>Count: 1</p>`); + expect(componentWillMount).to.have.been.calledOnce; + expect(componentDidMount).to.have.been.calledOnce; + expect(componentDidUpdate).to.have.been.calledOnce; + expect(componentWillUnmount).to.not.have.been.called; + + increment(); + rerender(); + + expect(scratch.innerHTML).to.eql(`<div>Suspended...</div>`); + expect(componentWillMount).to.have.been.calledOnce; + expect(componentDidMount).to.have.been.calledOnce; + expect(componentDidUpdate).to.have.been.calledOnce; + expect(componentWillUnmount).to.not.have.been.called; + + return resolve().then(() => { + rerender(); + + expect(scratch.innerHTML).to.eql(`<p>Count: 2</p>`); + expect(componentWillMount).to.have.been.calledOnce; + expect(componentDidMount).to.have.been.calledOnce; + // TODO: This is called thrice since the cDU queued up after the second + // increment is never cleared once the component suspends. So when it + // resumes and the component is rerendered, we queue up another cDU so + // cDU is called an extra time. + expect(componentDidUpdate).to.have.been.calledThrice; + expect(componentWillUnmount).to.not.have.been.called; + }); + }); + + it('should not call lifecycle methods when a sibling suspends', () => { + let componentWillMount = sinon.spy(); + let componentDidMount = sinon.spy(); + let componentWillUnmount = sinon.spy(); + class LifecycleLogger extends Component { + render() { + return <div>Lifecycle</div>; + } + componentWillMount() { + componentWillMount(); + } + componentDidMount() { + componentDidMount(); + } + componentWillUnmount() { + componentWillUnmount(); + } + } + + const [Suspender, suspend] = createSuspender(() => <div>Suspense</div>); + + render( + <Suspense fallback={<div>Suspended...</div>}> + <Suspender /> + <LifecycleLogger /> + </Suspense>, + scratch + ); + + expect(scratch.innerHTML).to.eql(`<div>Suspense</div><div>Lifecycle</div>`); + expect(componentWillMount).to.have.been.calledOnce; + expect(componentDidMount).to.have.been.calledOnce; + expect(componentWillUnmount).to.not.have.been.called; + + const [resolve] = suspend(); + + rerender(); + + expect(scratch.innerHTML).to.eql(`<div>Suspended...</div>`); + expect(componentWillMount).to.have.been.calledOnce; + expect(componentDidMount).to.have.been.calledOnce; + expect(componentWillUnmount).to.not.have.been.called; + + return resolve(() => <div>Suspense 2</div>).then(() => { + rerender(); + expect(scratch.innerHTML).to.eql( + `<div>Suspense 2</div><div>Lifecycle</div>` + ); + + expect(componentWillMount).to.have.been.calledOnce; + expect(componentDidMount).to.have.been.calledOnce; + expect(componentWillUnmount).to.not.have.been.called; + }); + }); + + it("should call fallback's lifecycle methods when suspending", () => { + class LifecycleLogger extends Component { + render() { + return <div>Lifecycle</div>; + } + componentWillMount() {} + componentDidMount() {} + componentWillUnmount() {} + } + + const componentWillMount = sinon.spy( + LifecycleLogger.prototype, + 'componentWillMount' + ); + const componentDidMount = sinon.spy( + LifecycleLogger.prototype, + 'componentDidMount' + ); + const componentWillUnmount = sinon.spy( + LifecycleLogger.prototype, + 'componentWillUnmount' + ); + + const [Suspender, suspend] = createSuspender(() => <div>Suspense</div>); + + render( + <Suspense fallback={<LifecycleLogger />}> + <Suspender /> + </Suspense>, + scratch + ); + + expect(scratch.innerHTML).to.eql(`<div>Suspense</div>`); + expect(componentWillMount).to.not.have.been.called; + expect(componentDidMount).to.not.have.been.called; + expect(componentWillUnmount).to.not.have.been.called; + + const [resolve] = suspend(); + + rerender(); + + expect(scratch.innerHTML).to.eql(`<div>Lifecycle</div>`); + expect(componentWillMount).to.have.been.calledOnce; + expect(componentDidMount).to.have.been.calledOnce; + expect(componentWillUnmount).to.not.have.been.called; + + return resolve(() => <div>Suspense 2</div>).then(() => { + rerender(); + expect(scratch.innerHTML).to.eql(`<div>Suspense 2</div>`); + + expect(componentWillMount).to.have.been.calledOnce; + expect(componentDidMount).to.have.been.calledOnce; + expect(componentWillUnmount).to.have.been.calledOnce; + }); + }); + + it('should keep state of siblings when suspending', () => { + /** @type {(state: { s: string }) => void} */ + let setState; + class Stateful extends Component { + constructor(props) { + super(props); + setState = this.setState.bind(this); + this.state = { s: 'initial' }; + } + render(props, state) { + return <div>Stateful: {state.s}</div>; + } + } + + const [Suspender, suspend] = createSuspender(() => <div>Suspense</div>); + + render( + <Suspense fallback={<div>Suspended...</div>}> + <Suspender /> + <Stateful /> + </Suspense>, + scratch + ); + + expect(scratch.innerHTML).to.eql( + `<div>Suspense</div><div>Stateful: initial</div>` + ); + + setState({ s: 'first' }); + rerender(); + + expect(scratch.innerHTML).to.eql( + `<div>Suspense</div><div>Stateful: first</div>` + ); + + const [resolve] = suspend(); + + rerender(); + + expect(scratch.innerHTML).to.eql(`<div>Suspended...</div>`); + + return resolve(() => <div>Suspense 2</div>).then(() => { + rerender(); + expect(scratch.innerHTML).to.eql( + `<div>Suspense 2</div><div>Stateful: first</div>` + ); + }); + }); + + it('should allow children to update state while suspending', () => { + /** @type {(state: { s: string }) => void} */ + let setState; + class Stateful extends Component { + constructor(props) { + super(props); + setState = this.setState.bind(this); + this.state = { s: 'initial' }; + } + render(props, state) { + return <div>Stateful: {state.s}</div>; + } + } + + const [Suspender, suspend] = createSuspender(() => <div>Suspense</div>); + + render( + <Suspense fallback={<div>Suspended...</div>}> + <Suspender /> + <Stateful /> + </Suspense>, + scratch + ); + + expect(scratch.innerHTML).to.eql( + `<div>Suspense</div><div>Stateful: initial</div>` + ); + + setState({ s: 'first' }); + rerender(); + + expect(scratch.innerHTML).to.eql( + `<div>Suspense</div><div>Stateful: first</div>` + ); + + const [resolve] = suspend(); + rerender(); + + expect(scratch.innerHTML).to.eql(`<div>Suspended...</div>`); + + setState({ s: 'second' }); + rerender(); + + expect(scratch.innerHTML).to.eql(`<div>Suspended...</div>`); + + return resolve(() => <div>Suspense 2</div>).then(() => { + rerender(); + expect(scratch.innerHTML).to.eql( + `<div>Suspense 2</div><div>Stateful: second</div>` + ); + }); + }); + + it('should allow siblings of Suspense to update state while suspending', () => { + /** @type {(state: { s: string }) => void} */ + let setState; + class Stateful extends Component { + constructor(props) { + super(props); + setState = this.setState.bind(this); + this.state = { s: 'initial' }; + } + render(props, state) { + return <div>Stateful: {state.s}</div>; + } + } + + const [Suspender, suspend] = createSuspender(() => <div>Suspense</div>); + + render( + <Fragment> + <Suspense fallback={<div>Suspended...</div>}> + <Suspender /> + </Suspense> + <Stateful /> + </Fragment>, + scratch + ); + + expect(scratch.innerHTML).to.eql( + `<div>Suspense</div><div>Stateful: initial</div>` + ); + + setState({ s: 'first' }); + rerender(); + + expect(scratch.innerHTML).to.eql( + `<div>Suspense</div><div>Stateful: first</div>` + ); + + const [resolve] = suspend(); + + rerender(); + + expect(scratch.innerHTML).to.eql( + `<div>Suspended...</div><div>Stateful: first</div>` + ); + + setState({ s: 'second' }); + rerender(); + + expect(scratch.innerHTML).to.eql( + `<div>Suspended...</div><div>Stateful: second</div>` + ); + + return resolve(() => <div>Suspense 2</div>).then(() => { + rerender(); + expect(scratch.innerHTML).to.eql( + `<div>Suspense 2</div><div>Stateful: second</div>` + ); + }); + }); + + it('should suspend with custom error boundary', () => { + const [Suspender, suspend] = createSuspender(() => ( + <div>within error boundary</div> + )); + + render( + <Suspense fallback={<div>Suspended...</div>}> + <Catcher> + <Suspender /> + </Catcher> + </Suspense>, + scratch + ); + + expect(scratch.innerHTML).to.eql(`<div>within error boundary</div>`); + + const [resolve] = suspend(); + rerender(); + + expect(scratch.innerHTML).to.eql(`<div>Suspended...</div>`); + + return resolve(() => <div>within error boundary 2</div>).then(() => { + rerender(); + expect(scratch.innerHTML).to.eql(`<div>within error boundary 2</div>`); + }); + }); + + it('should allow multiple sibling children to suspend', () => { + const [Suspender1, suspend1] = createSuspender(() => ( + <div>Hello first</div> + )); + const [Suspender2, suspend2] = createSuspender(() => ( + <div>Hello second</div> + )); + + render( + <Suspense fallback={<div>Suspended...</div>}> + <Catcher> + <Suspender1 /> + <Suspender2 /> + </Catcher> + </Suspense>, + scratch + ); + + expect(scratch.innerHTML).to.eql( + `<div>Hello first</div><div>Hello second</div>` + ); + expect(Suspender1.prototype.render).to.have.been.calledOnce; + expect(Suspender2.prototype.render).to.have.been.calledOnce; + + const [resolve1] = suspend1(); + const [resolve2] = suspend2(); + expect(Suspender1.prototype.render).to.have.been.calledOnce; + expect(Suspender2.prototype.render).to.have.been.calledOnce; + + rerender(); + + expect(scratch.innerHTML).to.eql(`<div>Suspended...</div>`); + expect(Suspender1.prototype.render).to.have.been.calledTwice; + expect(Suspender2.prototype.render).to.have.been.calledTwice; + + return resolve1(() => <div>Hello first 2</div>).then(() => { + rerender(); + expect(scratch.innerHTML).to.eql(`<div>Suspended...</div>`); + expect(Suspender1.prototype.render).to.have.been.calledTwice; + expect(Suspender2.prototype.render).to.have.been.calledTwice; + + return resolve2(() => <div>Hello second 2</div>).then(() => { + rerender(); + expect(scratch.innerHTML).to.eql( + `<div>Hello first 2</div><div>Hello second 2</div>` + ); + expect(Suspender1.prototype.render).to.have.been.calledThrice; + expect(Suspender2.prototype.render).to.have.been.calledThrice; + }); + }); + }); + + it('should call multiple nested sibling suspending components render in one go', () => { + const [Suspender1, suspend1] = createSuspender(() => ( + <div>Hello first</div> + )); + const [Suspender2, suspend2] = createSuspender(() => ( + <div>Hello second</div> + )); + + render( + <Suspense fallback={<div>Suspended...</div>}> + <Catcher> + <Suspender1 /> + <div> + <Suspender2 /> + </div> + </Catcher> + </Suspense>, + scratch + ); + + expect(scratch.innerHTML).to.eql( + `<div>Hello first</div><div><div>Hello second</div></div>` + ); + expect(Suspender1.prototype.render).to.have.been.calledOnce; + expect(Suspender2.prototype.render).to.have.been.calledOnce; + + const [resolve1] = suspend1(); + const [resolve2] = suspend2(); + expect(Suspender1.prototype.render).to.have.been.calledOnce; + expect(Suspender2.prototype.render).to.have.been.calledOnce; + + rerender(); + + expect(scratch.innerHTML).to.eql(`<div>Suspended...</div>`); + expect(Suspender1.prototype.render).to.have.been.calledTwice; + expect(Suspender2.prototype.render).to.have.been.calledTwice; + + return resolve1(() => <div>Hello first 2</div>).then(() => { + rerender(); + expect(scratch.innerHTML).to.eql(`<div>Suspended...</div>`); + expect(Suspender1.prototype.render).to.have.been.calledTwice; + expect(Suspender2.prototype.render).to.have.been.calledTwice; + + return resolve2(() => <div>Hello second 2</div>).then(() => { + rerender(); + expect(scratch.innerHTML).to.eql( + `<div>Hello first 2</div><div><div>Hello second 2</div></div>` + ); + expect(Suspender1.prototype.render).to.have.been.calledThrice; + expect(Suspender2.prototype.render).to.have.been.calledThrice; + }); + }); + }); + + it('should support text directly under Suspense', () => { + const [Suspender, suspend] = createSuspender(() => <div>Hello</div>); + + render( + <Suspense fallback={<div>Suspended...</div>}> + Text + {/* Adding a <div> here will make things work... */} + <Suspender /> + </Suspense>, + scratch + ); + + expect(scratch.innerHTML).to.eql(`Text<div>Hello</div>`); + + const [resolve] = suspend(); + rerender(); + + expect(scratch.innerHTML).to.eql(`<div>Suspended...</div>`); + + return resolve(() => <div>Hello 2</div>).then(() => { + rerender(); + expect(scratch.innerHTML).to.eql(`Text<div>Hello 2</div>`); + }); + }); + + it('should support to change DOM tag directly under suspense', () => { + /** @type {(state: {tag: string}) => void} */ + let setState; + class StatefulComp extends Component { + constructor(props) { + super(props); + setState = this.setState.bind(this); + this.state = { + tag: props.defaultTag + }; + } + render(props, { tag: Tag }) { + return <Tag>Stateful</Tag>; + } + } + + const [Suspender, suspend] = createSuspender(() => <div>Hello</div>); + + render( + <Suspense fallback={<div>Suspended...</div>}> + <StatefulComp defaultTag="div" /> + <Suspender /> + </Suspense>, + scratch + ); + + expect(scratch.innerHTML).to.eql(`<div>Stateful</div><div>Hello</div>`); + + const [resolve] = suspend(); + rerender(); + + expect(scratch.innerHTML).to.eql(`<div>Suspended...</div>`); + + setState({ tag: 'article' }); + + return resolve(() => <div>Hello 2</div>).then(() => { + rerender(); + expect(scratch.innerHTML).to.eql( + `<article>Stateful</article><div>Hello 2</div>` + ); + }); + }); + + it('should only suspend the most inner Suspend', () => { + const [Suspender, suspend] = createSuspender(() => <div>Hello</div>); + + render( + <Suspense fallback={<div>Suspended... 1</div>}> + Not suspended... + <Suspense fallback={<div>Suspended... 2</div>}> + <Catcher> + <Suspender /> + </Catcher> + </Suspense> + </Suspense>, + scratch + ); + + expect(scratch.innerHTML).to.eql(`Not suspended...<div>Hello</div>`); + + const [resolve] = suspend(); + rerender(); + + expect(scratch.innerHTML).to.eql( + `Not suspended...<div>Suspended... 2</div>` + ); + + return resolve(() => <div>Hello 2</div>).then(() => { + rerender(); + expect(scratch.innerHTML).to.eql(`Not suspended...<div>Hello 2</div>`); + }); + }); + + it('should throw when missing Suspense', () => { + const [Suspender, suspend] = createSuspender(() => <div>Hello</div>); + + render( + <Catcher> + <Suspender /> + </Catcher>, + scratch + ); + rerender(); + expect(scratch.innerHTML).to.eql(`<div>Hello</div>`); + + suspend(); + rerender(); + expect(scratch.innerHTML).to.eql(`<div>Catcher did catch: {Promise}</div>`); + }); + + it("should throw when lazy's loader throws", () => { + /** @type {() => Promise<any>} */ + let reject; + const ThrowingLazy = lazy(() => { + const prom = new Promise((res, rej) => { + reject = () => { + rej(new Error("Thrown in lazy's loader...")); + return prom; + }; + }); + + return prom; + }); + + render( + <Suspense fallback={<div>Suspended...</div>}> + <Catcher> + <ThrowingLazy /> + </Catcher> + </Suspense>, + scratch + ); + rerender(); + + expect(scratch.innerHTML).to.eql(`<div>Suspended...</div>`); + + return reject().then( + () => { + expect.fail('Suspended promises resolved instead of rejected.'); + }, + () => { + rerender(); + expect(scratch.innerHTML).to.eql( + `<div>Catcher did catch: Thrown in lazy's loader...</div>` + ); + } + ); + }); + + it('should support null fallback', () => { + const [Suspender, suspend] = createSuspender(() => <div>Hello</div>); + + render( + <div id="wrapper"> + <Suspense fallback={null}> + <div id="inner"> + <Suspender /> + </div> + </Suspense> + </div>, + scratch + ); + expect(scratch.innerHTML).to.equal( + `<div id="wrapper"><div id="inner"><div>Hello</div></div></div>` + ); + + const [resolve] = suspend(); + rerender(); + expect(scratch.innerHTML).to.equal(`<div id="wrapper"></div>`); + + return resolve(() => <div>Hello2</div>).then(() => { + rerender(); + expect(scratch.innerHTML).to.equal( + `<div id="wrapper"><div id="inner"><div>Hello2</div></div></div>` + ); + }); + }); + + it('should support suspending multiple times', () => { + const [Suspender, suspend] = createSuspender(() => ( + <div>initial render</div> + )); + const Loading = () => <div>Suspended...</div>; + + render( + <Suspense fallback={<Loading />}> + <Suspender /> + </Suspense>, + scratch + ); + + expect(scratch.innerHTML).to.eql(`<div>initial render</div>`); + + let [resolve] = suspend(); + rerender(); + + expect(scratch.innerHTML).to.eql(`<div>Suspended...</div>`); + + return resolve(() => <div>Hello1</div>) + .then(() => { + // Rerender promise resolution + rerender(); + expect(scratch.innerHTML).to.eql(`<div>Hello1</div>`); + + // suspend again + [resolve] = suspend(); + rerender(); + + expect(scratch.innerHTML).to.eql(`<div>Suspended...</div>`); + + return resolve(() => <div>Hello2</div>); + }) + .then(() => { + // Rerender promise resolution + rerender(); + expect(scratch.innerHTML).to.eql(`<div>Hello2</div>`); + }); + }); + + it("should correctly render when a suspended component's child also suspends", () => { + const [Suspender1, suspend1] = createSuspender(() => <div>Hello1</div>); + const [LazyChild, resolveChild] = createLazy(); + + render( + <Suspense fallback={<div>Suspended...</div>}> + <Suspender1 /> + </Suspense>, + scratch + ); + + expect(scratch.innerHTML).to.equal(`<div>Hello1</div>`); + + let [resolve1] = suspend1(); + rerender(); + expect(scratch.innerHTML).to.equal('<div>Suspended...</div>'); + + return resolve1(() => <LazyChild />) + .then(() => { + rerender(); + expect(scratch.innerHTML).to.equal('<div>Suspended...</div>'); + + return resolveChild(() => <div>All done!</div>); + }) + .then(() => { + rerender(); + expect(scratch.innerHTML).to.equal('<div>All done!</div>'); + }); + }); + + it('should correctly render nested Suspense components', () => { + // Inspired by the nested-suspense demo from #1865 + // TODO: Explore writing a test that varies the loading orders + + const [Lazy1, resolve1] = createLazy(); + const [Lazy2, resolve2] = createLazy(); + const [Lazy3, resolve3] = createLazy(); + + const Loading = () => <div>Suspended...</div>; + const loadingHtml = `<div>Suspended...</div>`; + + render( + <Suspense fallback={<Loading />}> + <Lazy1 /> + <div> + <Suspense fallback={<Loading />}> + <Lazy2 /> + </Suspense> + <Lazy3 /> + </div> + <b>4</b> + </Suspense>, + scratch + ); + rerender(); // Rerender with the fallback HTML + + expect(scratch.innerHTML).to.equal(loadingHtml); + + return resolve1(() => <b>1</b>) + .then(() => { + rerender(); + expect(scratch.innerHTML).to.equal(loadingHtml); + + return resolve3(() => <b>3</b>); + }) + .then(() => { + rerender(); + expect(scratch.innerHTML).to.equal( + `<b>1</b><div>${loadingHtml}<b>3</b></div><b>4</b>` + ); + + return resolve2(() => <b>2</b>); + }) + .then(() => { + rerender(); + expect(scratch.innerHTML).to.equal( + `<b>1</b><div><b>2</b><b>3</b></div><b>4</b>` + ); + }); + }); + + it('should correctly render nested Suspense components without intermediate DOM #2747', () => { + const [ProfileDetails, resolveDetails] = createLazy(); + const [ProfileTimeline, resolveTimeline] = createLazy(); + + function ProfilePage() { + return ( + <Suspense fallback={<h1>Loading profile...</h1>}> + <ProfileDetails /> + <Suspense fallback={<h2>Loading posts...</h2>}> + <ProfileTimeline /> + </Suspense> + </Suspense> + ); + } + + render(<ProfilePage />, scratch); + rerender(); // Render fallback + + expect(scratch.innerHTML).to.equal('<h1>Loading profile...</h1>'); + + return resolveDetails(() => <h1>Ringo Starr</h1>) + .then(() => { + rerender(); + expect(scratch.innerHTML).to.equal( + '<h1>Ringo Starr</h1><h2>Loading posts...</h2>' + ); + + return resolveTimeline(() => <p>Timeline details</p>); + }) + .then(() => { + rerender(); + expect(scratch.innerHTML).to.equal( + '<h1>Ringo Starr</h1><p>Timeline details</p>' + ); + }); + }); + + it('should correctly render Suspense components inside Fragments', () => { + // Issue #2106. + + const [Lazy1, resolve1] = createLazy(); + const [Lazy2, resolve2] = createLazy(); + const [Lazy3, resolve3] = createLazy(); + + const Loading = () => <div>Suspended...</div>; + const loadingHtml = `<div>Suspended...</div>`; + + render( + <Fragment> + <Suspense fallback={<Loading />}> + <Lazy1 /> + </Suspense> + <Fragment> + <Suspense fallback={<Loading />}> + <Lazy2 /> + </Suspense> + </Fragment> + <Suspense fallback={<Loading />}> + <Lazy3 /> + </Suspense> + </Fragment>, + scratch + ); + + rerender(); + expect(scratch.innerHTML).to.eql( + `${loadingHtml}${loadingHtml}${loadingHtml}` + ); + + return resolve2(() => <span>2</span>) + .then(() => { + return resolve1(() => <span>1</span>); + }) + .then(() => { + rerender(); + expect(scratch.innerHTML).to.eql( + `<span>1</span><span>2</span>${loadingHtml}` + ); + return resolve3(() => <span>3</span>); + }) + .then(() => { + rerender(); + expect(scratch.innerHTML).to.eql( + `<span>1</span><span>2</span><span>3</span>` + ); + }); + }); + + it('should not render any of the children if one child suspends', () => { + const [Lazy, resolve] = createLazy(); + + const Loading = () => <div>Suspended...</div>; + const loadingHtml = `<div>Suspended...</div>`; + + render( + <Suspense fallback={<Loading />}> + <Lazy /> + <div>World</div> + </Suspense>, + scratch + ); + rerender(); + expect(scratch.innerHTML).to.eql(loadingHtml); + + return resolve(() => <div>Hello</div>).then(() => { + rerender(); + expect(scratch.innerHTML).to.equal(`<div>Hello</div><div>World</div>`); + }); + }); + + it('should render correctly when multiple children suspend with the same promise', () => { + /** @type {() => Promise<void>} */ + let resolve; + let resolved = false; + const promise = new Promise(_resolve => { + resolve = () => { + resolved = true; + _resolve(); + return promise; + }; + }); + + const Child = props => { + if (!resolved) { + throw promise; + } + return props.children; + }; + + const Loading = () => <div>Suspended...</div>; + const loadingHtml = `<div>Suspended...</div>`; + + render( + <Suspense fallback={<Loading />}> + <Child> + <div>A</div> + </Child> + <Child> + <div>B</div> + </Child> + </Suspense>, + scratch + ); + rerender(); + expect(scratch.innerHTML).to.eql(loadingHtml); + + return resolve().then(() => { + resolved = true; + rerender(); + expect(scratch.innerHTML).to.equal(`<div>A</div><div>B</div>`); + }); + }); + + it('should un-suspend when suspender unmounts', () => { + const [Suspender, suspend] = createSuspender(() => <div>Suspender</div>); + + let hide; + + class Conditional extends Component { + constructor(props) { + super(props); + this.state = { show: true }; + + hide = () => { + this.setState({ show: false }); + }; + } + + render(props, { show }) { + return ( + <div> + conditional {show ? 'show' : 'hide'} + {show && <Suspender />} + </div> + ); + } + } + + render( + <Suspense fallback={<div>Suspended...</div>}> + <Conditional /> + </Suspense>, + scratch + ); + + expect(scratch.innerHTML).to.eql( + `<div>conditional show<div>Suspender</div></div>` + ); + expect(Suspender.prototype.render).to.have.been.calledOnce; + + suspend(); + rerender(); + + expect(scratch.innerHTML).to.eql(`<div>Suspended...</div>`); + + hide(); + rerender(); + + expect(scratch.innerHTML).to.eql(`<div>conditional hide</div>`); + }); + + it('should allow suspended multiple times', async () => { + const [Suspender1, suspend1] = createSuspender(() => ( + <div>Suspender 1</div> + )); + const [Suspender2, suspend2] = createSuspender(() => ( + <div>Suspender 2</div> + )); + + let hide, resolve; + + class Conditional extends Component { + constructor(props) { + super(props); + this.state = { show: true }; + + hide = () => { + this.setState({ show: false }); + }; + } + + render(props, { show }) { + return ( + <div> + conditional {show ? 'show' : 'hide'} + {show && ( + <Suspense fallback="Suspended"> + <Suspender1 /> + <Suspender2 /> + </Suspense> + )} + </div> + ); + } + } + + render(<Conditional />, scratch); + expect(scratch.innerHTML).to.eql( + '<div>conditional show<div>Suspender 1</div><div>Suspender 2</div></div>' + ); + + resolve = suspend1()[0]; + rerender(); + expect(scratch.innerHTML).to.eql('<div>conditional showSuspended</div>'); + + await resolve(() => <div>Done 1</div>); + rerender(); + expect(scratch.innerHTML).to.eql( + '<div>conditional show<div>Done 1</div><div>Suspender 2</div></div>' + ); + + resolve = suspend2()[0]; + rerender(); + expect(scratch.innerHTML).to.eql('<div>conditional showSuspended</div>'); + + await resolve(() => <div>Done 2</div>); + rerender(); + expect(scratch.innerHTML).to.eql( + '<div>conditional show<div>Done 1</div><div>Done 2</div></div>' + ); + + hide(); + rerender(); + expect(scratch.innerHTML).to.eql('<div>conditional hide</div>'); + }); + + it('should allow same component to be suspended multiple times', async () => { + const cache = { '1': true }; + function Lazy({ value }) { + if (!cache[value]) { + throw new Promise(resolve => { + cache[value] = resolve; + }); + } + return <div>{`Lazy ${value}`}</div>; + } + + let hide, setValue; + + class Conditional extends Component { + constructor(props) { + super(props); + this.state = { show: true, value: '1' }; + + hide = () => { + this.setState({ show: false }); + }; + setValue = value => { + this.setState({ value }); + }; + } + + render(props, { show, value }) { + return ( + <div> + conditional {show ? 'show' : 'hide'} + {show && ( + <Suspense fallback="Suspended"> + <Lazy value={value} /> + </Suspense> + )} + </div> + ); + } + } + + render(<Conditional />, scratch); + expect(scratch.innerHTML).to.eql( + '<div>conditional show<div>Lazy 1</div></div>' + ); + + setValue('2'); + rerender(); + + expect(scratch.innerHTML).to.eql('<div>conditional showSuspended</div>'); + + await cache[2](); + rerender(); + + expect(scratch.innerHTML).to.eql( + '<div>conditional show<div>Lazy 2</div></div>' + ); + + setValue('3'); + rerender(); + + expect(scratch.innerHTML).to.eql('<div>conditional showSuspended</div>'); + + await cache[3](); + rerender(); + expect(scratch.innerHTML).to.eql( + '<div>conditional show<div>Lazy 3</div></div>' + ); + + hide(); + rerender(); + expect(scratch.innerHTML).to.eql('<div>conditional hide</div>'); + }); + + it('should allow resolve suspense promise after unmounts', async () => { + const [Suspender, suspend] = createSuspender(() => <div>Suspender</div>); + + let hide, resolve; + + class Conditional extends Component { + constructor(props) { + super(props); + this.state = { show: true }; + + hide = () => { + this.setState({ show: false }); + }; + } + + render(props, { show }) { + return ( + <div> + conditional {show ? 'show' : 'hide'} + {show && ( + <Suspense fallback="Suspended"> + <Suspender /> + </Suspense> + )} + </div> + ); + } + } + + render(<Conditional />, scratch); + expect(scratch.innerHTML).to.eql( + '<div>conditional show<div>Suspender</div></div>' + ); + + resolve = suspend()[0]; + rerender(); + expect(scratch.innerHTML).to.eql('<div>conditional showSuspended</div>'); + + hide(); + rerender(); + expect(scratch.innerHTML).to.eql('<div>conditional hide</div>'); + + await resolve(() => <div>Done</div>); + rerender(); + expect(scratch.innerHTML).to.eql('<div>conditional hide</div>'); + }); + + it('should support updating state while suspended', async () => { + const [Suspender, suspend] = createSuspender(() => <div>Suspender</div>); + + let increment; + + class Updater extends Component { + constructor(props) { + super(props); + this.state = { i: 0 }; + + increment = () => { + this.setState(({ i }) => ({ i: i + 1 })); + }; + } + + render(props, { i }) { + return ( + <div> + i: {i} + <Suspender /> + </div> + ); + } + } + + render( + <Suspense fallback={<div>Suspended...</div>}> + <Updater /> + </Suspense>, + scratch + ); + + expect(scratch.innerHTML).to.eql(`<div>i: 0<div>Suspender</div></div>`); + expect(Suspender.prototype.render).to.have.been.calledOnce; + + const [resolve] = suspend(); + rerender(); + + expect(scratch.innerHTML).to.eql(`<div>Suspended...</div>`); + + increment(); + rerender(); + + expect(scratch.innerHTML).to.eql(`<div>Suspended...</div>`); + + await resolve(() => <div>Resolved</div>); + rerender(); + + expect(scratch.innerHTML).to.equal(`<div>i: 1<div>Resolved</div></div>`); + + increment(); + rerender(); + + expect(scratch.innerHTML).to.equal(`<div>i: 2<div>Resolved</div></div>`); + }); + + it('should call componentWillUnmount on a suspended component', () => { + const cWUSpy = sinon.spy(); + + // eslint-disable-next-line react/require-render-return + class Suspender extends Component { + render() { + throw new Promise(() => {}); + } + } + + Suspender.prototype.componentWillUnmount = cWUSpy; + + let hide; + + let suspender = null; + let suspenderRef = s => { + // skip null values as we want to keep the ref even after unmount + if (s) { + suspender = s; + } + }; + + class Conditional extends Component { + constructor(props) { + super(props); + this.state = { show: true }; + + hide = () => { + this.setState({ show: false }); + }; + } + + render(props, { show }) { + return ( + <div> + conditional {show ? 'show' : 'hide'} + {show && <Suspender ref={suspenderRef} />} + </div> + ); + } + } + + render( + <Suspense fallback={<div>Suspended...</div>}> + <Conditional /> + </Suspense>, + scratch + ); + + expect(scratch.innerHTML).to.eql(`<div>conditional show</div>`); + expect(cWUSpy).to.not.have.been.called; + + hide(); + rerender(); + + expect(cWUSpy).to.have.been.calledOnce; + expect(suspender).not.to.be.undefined; + expect(suspender).not.to.be.null; + expect(cWUSpy.getCall(0).thisValue).to.eql(suspender); + expect(scratch.innerHTML).to.eql(`<div>conditional hide</div>`); + }); + + it('should support sCU=false when un-suspending', () => { + // See #2176 #2125 + const [Suspender, suspend] = createSuspender(() => <div>Hello</div>); + + render( + <Suspense fallback={<div>Suspended...</div>}> + Text + {/* Adding a <div> here will make things work... */} + <Suspender /> + </Suspense>, + scratch + ); + + expect(scratch.innerHTML).to.eql(`Text<div>Hello</div>`); + + const [resolve] = suspend(); + rerender(); + + expect(scratch.innerHTML).to.eql(`<div>Suspended...</div>`); + + Suspender.prototype.shouldComponentUpdate = () => false; + + return resolve(() => <div>Hello 2</div>).then(() => { + rerender(); + expect(scratch.innerHTML).to.eql(`Text<div>Hello 2</div>`); + }); + }); + + // TODO: Revisit later. Consider using an "options.commit" plugin to detect + // when a suspended component has rerendered and trigger a rerender on the + // parent Suspense + it.skip('should allow suspended children to update', () => { + const log = []; + class Logger extends Component { + constructor(props) { + super(props); + log.push('construct'); + } + + render({ children }) { + log.push('render'); + return children; + } + } + + let suspender; + class Suspender extends Component { + constructor(props) { + super(props); + this.state = { promise: new Promise(() => {}) }; + suspender = this; + } + + unsuspend() { + this.setState({ promise: null }); + } + + render() { + if (this.state.promise) { + throw this.state.promise; + } + + return <div>Suspender un-suspended</div>; + } + } + + render( + <section> + <Suspense fallback={<div>fallback</div>}> + <Suspender /> + <Logger /> + </Suspense> + </section>, + scratch + ); + + expect(log).to.eql(['construct', 'render']); + expect(scratch.innerHTML).to.eql('<section></section>'); + + // this rerender is needed because of Suspense issuing a forceUpdate itself + rerender(); + expect(scratch.innerHTML).to.eql('<section><div>fallback</div></section>'); + + suspender.unsuspend(); + + rerender(); + + expect(log).to.eql(['construct', 'render', 'render']); + expect(scratch.innerHTML).to.eql( + '<section><div>Suspender un-suspended</div></section>' + ); + }); + + // TODO: Revisit later. Consider using an "options.commit" plugin to detect + // when a suspended component has rerendered and trigger a rerender on the + // parent Suspense + it.skip('should allow multiple suspended children to update', () => { + function createSuspender() { + let suspender; + class Suspender extends Component { + constructor(props) { + super(props); + this.state = { promise: new Promise(() => {}) }; + suspender = this; + } + + unsuspend(content) { + this.setState({ promise: null, content }); + } + + render() { + if (this.state.promise) { + throw this.state.promise; + } + + return this.state.content; + } + } + return [content => suspender.unsuspend(content), Suspender]; + } + + const [unsuspender1, Suspender1] = createSuspender(); + const [unsuspender2, Suspender2] = createSuspender(); + + render( + <section> + <Suspense fallback={<div>fallback</div>}> + <Suspender1 /> + <div> + <Suspender2 /> + </div> + </Suspense> + </section>, + scratch + ); + + expect(scratch.innerHTML).to.eql('<section><div></div></section>'); + + // this rerender is needed because of Suspense issuing a forceUpdate itself + rerender(); + expect(scratch.innerHTML).to.eql('<section><div>fallback</div></section>'); + + unsuspender1( + <> + <div>Suspender un-suspended 1</div> + <div>Suspender un-suspended 2</div> + </> + ); + + rerender(); + expect(scratch.innerHTML).to.eql('<section><div>fallback</div></section>'); + + unsuspender2(<div>Suspender 2</div>); + + rerender(); + expect(scratch.innerHTML).to.eql( + '<section><div>Suspender un-suspended 1</div><div>Suspender un-suspended 2</div><div><div>Suspender 2</div></div></section>' + ); + }); + + // TODO: Revisit later. Consider using an "options.commit" plugin to detect + // when a suspended component has rerendered and trigger a rerender on the + // parent Suspense + it.skip('should allow suspended children children to update', () => { + function Suspender({ promise, content }) { + if (promise) { + throw promise; + } + return content; + } + + let parent; + class Parent extends Component { + constructor(props) { + super(props); + this.state = { promise: new Promise(() => {}), condition: true }; + parent = this; + } + + render() { + const { condition, promise, content } = this.state; + if (condition) { + return <Suspender promise={promise} content={content} />; + } + return <div>Parent</div>; + } + } + + render( + <section> + <Suspense fallback={<div>fallback</div>}> + <Parent /> + </Suspense> + </section>, + scratch + ); + + expect(scratch.innerHTML).to.eql('<section></section>'); + + // this rerender is needed because of Suspense issuing a forceUpdate itself + rerender(); + expect(scratch.innerHTML).to.eql('<section><div>fallback</div></section>'); + + // hide the <Suspender /> thus unsuspends + parent.setState({ condition: false }); + + rerender(); + expect(scratch.innerHTML).to.eql('<section><div>Parent</div></section>'); + + // show the <Suspender /> thus re-suspends + parent.setState({ condition: true }); + rerender(); + + expect(scratch.innerHTML).to.eql('<section><div>fallback</div></section>'); + + // update state so that <Suspender /> no longer suspends + parent.setState({ promise: null, content: <div>Content</div> }); + rerender(); + + expect(scratch.innerHTML).to.eql('<section><div>Content</div></section>'); + + // hide the <Suspender /> again + parent.setState({ condition: false }); + rerender(); + + expect(scratch.innerHTML).to.eql('<section><div>Parent</div></section>'); + }); + + it('should render delayed lazy components through components using shouldComponentUpdate', () => { + const [Suspender1, suspend1] = createSuspender(() => <i>1</i>); + const [Suspender2, suspend2] = createSuspender(() => <i>2</i>); + + class Blocker extends Component { + shouldComponentUpdate() { + return false; + } + render(props) { + return ( + <b> + <i>a</i> + {props.children} + <i>d</i> + </b> + ); + } + } + + render( + <Suspense fallback={<div>Suspended...</div>}> + <Blocker> + <Suspender1 /> + <Suspender2 /> + </Blocker> + </Suspense>, + scratch + ); + expect(scratch.innerHTML).to.equal( + '<b><i>a</i><i>1</i><i>2</i><i>d</i></b>' + ); + + const [resolve1] = suspend1(); + const [resolve2] = suspend2(); + rerender(); + expect(scratch.innerHTML).to.equal('<div>Suspended...</div>'); + + return resolve1(() => <i>b</i>) + .then(() => { + rerender(); + expect(scratch.innerHTML).to.equal('<div>Suspended...</div>'); + + return resolve2(() => <i>c</i>); + }) + .then(() => { + rerender(); + expect(scratch.innerHTML).to.equal( + '<b><i>a</i><i>b</i><i>c</i><i>d</i></b>' + ); + }); + }); + + it('should render initially lazy components through components using shouldComponentUpdate', () => { + const [Lazy1, resolve1] = createLazy(); + const [Lazy2, resolve2] = createLazy(); + + class Blocker extends Component { + shouldComponentUpdate() { + return false; + } + render(props) { + return ( + <b> + <i>a</i> + {props.children} + <i>d</i> + </b> + ); + } + } + + render( + <Suspense fallback={<div>Suspended...</div>}> + <Blocker> + <Lazy1 /> + <Lazy2 /> + </Blocker> + </Suspense>, + scratch + ); + rerender(); + expect(scratch.innerHTML).to.equal('<div>Suspended...</div>'); + + return resolve1(() => <i>b</i>) + .then(() => { + rerender(); + expect(scratch.innerHTML).to.equal('<div>Suspended...</div>'); + + return resolve2(() => <i>c</i>); + }) + .then(() => { + rerender(); + expect(scratch.innerHTML).to.equal( + '<b><i>a</i><i>b</i><i>c</i><i>d</i></b>' + ); + }); + }); + + it('should render initially lazy components through createContext', () => { + const ctx = createContext(null); + const [Lazy, resolve] = createLazy(); + + const suspense = ( + <Suspense fallback={<div>Suspended...</div>}> + <ctx.Provider value="123"> + <ctx.Consumer>{value => <Lazy value={value} />}</ctx.Consumer> + </ctx.Provider> + </Suspense> + ); + + render(suspense, scratch); + rerender(); + expect(scratch.innerHTML).to.equal(`<div>Suspended...</div>`); + + return resolve(props => <div>{props.value}</div>).then(() => { + rerender(); + expect(scratch.innerHTML).to.eql(`<div>123</div>`); + }); + }); + + it('should render delayed lazy components through createContext', () => { + const ctx = createContext(null); + const [Suspender, suspend] = createSuspender(({ value }) => ( + <span>{value}</span> + )); + + const suspense = ( + <Suspense fallback={<div>Suspended...</div>}> + <ctx.Provider value="123"> + <ctx.Consumer>{value => <Suspender value={value} />}</ctx.Consumer> + </ctx.Provider> + </Suspense> + ); + + render(suspense, scratch); + expect(scratch.innerHTML).to.equal('<span>123</span>'); + + const [resolve] = suspend(); + rerender(); + expect(scratch.innerHTML).to.equal(`<div>Suspended...</div>`); + + return resolve(props => <div>{props.value}</div>).then(() => { + rerender(); + expect(scratch.innerHTML).to.eql(`<div>123</div>`); + }); + }); +}); diff --git a/preact/compat/test/browser/svg.test.js b/preact/compat/test/browser/svg.test.js new file mode 100644 index 0000000..3b45859 --- /dev/null +++ b/preact/compat/test/browser/svg.test.js @@ -0,0 +1,94 @@ +import React, { createElement } from 'preact/compat'; +import { + setupScratch, + teardown, + serializeHtml, + sortAttributes +} from '../../../test/_util/helpers'; + +describe('svg', () => { + /** @type {HTMLDivElement} */ + let scratch; + + beforeEach(() => { + scratch = setupScratch(); + }); + + afterEach(() => { + teardown(scratch); + }); + + it('should render SVG to string', () => { + let svg = ( + <svg viewBox="0 0 360 360"> + <path + stroke="white" + fill="black" + d="M347.1 357.9L183.3 256.5 13 357.9V1.7h334.1v356.2zM58.5 47.2v231.4l124.8-74.1 118.3 72.8V47.2H58.5z" + /> + </svg> + ); + // string -> parse + expect(svg).to.eql(svg); + }); + + it('should render SVG to DOM #1', () => { + const Demo = () => ( + <svg viewBox="0 0 360 360"> + <path + stroke="white" + fill="black" + d="M347.1 357.9L183.3 256.5 L 13 357.9V1.7h334.1v356.2zM58.5 47.2v231.4l124.8-74.1 l 118.3 72.8V47.2H58.5z" + /> + </svg> + ); + React.render(<Demo />, scratch); + + expect(serializeHtml(scratch)).to.equal( + sortAttributes( + '<svg viewBox="0 0 360 360"><path stroke="white" fill="black" d="M 347.1 357.9 L 183.3 256.5 L 13 357.9 V 1.7 h 334.1 v 356.2 Z M 58.5 47.2 v 231.4 l 124.8 -74.1 l 118.3 72.8 V 47.2 H 58.5 Z"></path></svg>' + ) + ); + }); + + it('should render SVG to DOM #2', () => { + React.render( + <svg viewBox="0 0 100 100"> + <text textAnchor="mid">foo</text> + <path vectorEffect="non-scaling-stroke" d="M0 0 L100 100" /> + </svg>, + scratch + ); + + expect(serializeHtml(scratch)).to.equal( + sortAttributes( + '<svg viewBox="0 0 100 100"><text text-anchor="mid">foo</text><path vector-effect="non-scaling-stroke" d="M 0 0 L 100 100"></path></svg>' + ) + ); + }); + + it('should render correct SVG attribute names to the DOM', () => { + React.render( + <svg + clipPath="value" + clipRule="value" + clipPathUnits="value" + glyphOrientationHorizontal="value" + glyphRef="value" + markerStart="value" + markerHeight="value" + markerUnits="value" + markerWidth="value" + x1="value" + xChannelSelector="value" + />, + scratch + ); + + expect(serializeHtml(scratch)).to.eql( + sortAttributes( + '<svg clip-path="value" clip-rule="value" clipPathUnits="value" glyph-orientationhorizontal="value" glyphRef="value" marker-start="value" markerHeight="value" markerUnits="value" markerWidth="value" x1="value" xChannelSelector="value"></svg>' + ) + ); + }); +}); diff --git a/preact/compat/test/browser/testUtils.js b/preact/compat/test/browser/testUtils.js new file mode 100644 index 0000000..04ed784 --- /dev/null +++ b/preact/compat/test/browser/testUtils.js @@ -0,0 +1,24 @@ +/** + * Retrieve a Symbol if supported or use the fallback value + * @param {string} name The name of the Symbol to look up + * @param {number} fallback Fallback value if Symbols are not supported + */ +export function getSymbol(name, fallback) { + let out = fallback; + + try { + // eslint-disable-next-line + if ( + Function.prototype.toString + .call(eval('Symbol.for')) + .match(/\[native code\]/) + ) { + // Concatenate these string literals to prevent the test + // harness and/or Babel from modifying the symbol value. + // eslint-disable-next-line + out = eval('Sym' + 'bol.for("' + name + '")'); + } + } catch (e) {} + + return out; +} diff --git a/preact/compat/test/browser/textarea.test.js b/preact/compat/test/browser/textarea.test.js new file mode 100644 index 0000000..d1c114e --- /dev/null +++ b/preact/compat/test/browser/textarea.test.js @@ -0,0 +1,64 @@ +import React, { render, useState } from 'preact/compat'; +import { setupScratch, teardown } from '../../../test/_util/helpers'; +import { act } from 'preact/test-utils'; + +describe('Textarea', () => { + let scratch; + + beforeEach(() => { + scratch = setupScratch(); + }); + + afterEach(() => { + teardown(scratch); + }); + + it('should alias value to children', () => { + render(<textarea value="foo" />, scratch); + + expect(scratch.firstElementChild.value).to.equal('foo'); + }); + + it('should alias defaultValue to children', () => { + // TODO: IE11 doesn't update `node.value` when + // `node.defaultValue` is set. + if (/Trident/.test(navigator.userAgent)) return; + + render(<textarea defaultValue="foo" />, scratch); + + expect(scratch.firstElementChild.value).to.equal('foo'); + }); + + it('should support resetting the value', () => { + let set; + const App = () => { + const [state, setState] = useState(''); + set = setState; + return <textarea value={state} />; + }; + + render(<App />, scratch); + expect(scratch.innerHTML).to.equal('<textarea></textarea>'); + + act(() => { + set('hello'); + }); + // Note: This looks counterintuitive, but it's working correctly - the value + // missing from HTML because innerHTML doesn't serialize form field values. + // See demo: https://jsfiddle.net/4had2Lu8 + // Related renderToString PR: preactjs/preact-render-to-string#161 + // + // This is not true for IE11. It displays the value in + // node.innerHTML regardless. + if (!/Trident/.test(window.navigator.userAgent)) { + expect(scratch.innerHTML).to.equal('<textarea></textarea>'); + } + expect(scratch.firstElementChild.value).to.equal('hello'); + + act(() => { + set(''); + }); + expect(scratch.innerHTML).to.equal('<textarea></textarea>'); + expect(scratch.firstElementChild.value).to.equal(''); + }); +}); diff --git a/preact/compat/test/browser/unmountComponentAtNode.test.js b/preact/compat/test/browser/unmountComponentAtNode.test.js new file mode 100644 index 0000000..bcf87df --- /dev/null +++ b/preact/compat/test/browser/unmountComponentAtNode.test.js @@ -0,0 +1,28 @@ +import React, { createElement, unmountComponentAtNode } from 'preact/compat'; +import { setupScratch, teardown } from '../../../test/_util/helpers'; + +describe('unmountComponentAtNode', () => { + /** @type {HTMLDivElement} */ + let scratch; + + beforeEach(() => { + scratch = setupScratch(); + }); + + afterEach(() => { + teardown(scratch); + }); + + it('should unmount a root node', () => { + const App = () => <div>foo</div>; + React.render(<App />, scratch); + + expect(unmountComponentAtNode(scratch)).to.equal(true); + expect(scratch.innerHTML).to.equal(''); + }); + + it('should do nothing if root is not mounted', () => { + expect(unmountComponentAtNode(scratch)).to.equal(false); + expect(scratch.innerHTML).to.equal(''); + }); +}); diff --git a/preact/compat/test/browser/unstable_batchedUpdates.test.js b/preact/compat/test/browser/unstable_batchedUpdates.test.js new file mode 100644 index 0000000..61b2e8a --- /dev/null +++ b/preact/compat/test/browser/unstable_batchedUpdates.test.js @@ -0,0 +1,15 @@ +import { unstable_batchedUpdates } from 'preact/compat'; + +describe('unstable_batchedUpdates', () => { + it('should call the callback', () => { + const spy = sinon.spy(); + unstable_batchedUpdates(spy); + expect(spy).to.be.calledOnce; + }); + + it('should call callback with only one arg', () => { + const spy = sinon.spy(); + unstable_batchedUpdates(spy, 'foo', 'bar'); + expect(spy).to.be.calledWithExactly('foo'); + }); +}); diff --git a/preact/compat/test/ts/forward-ref.tsx b/preact/compat/test/ts/forward-ref.tsx new file mode 100644 index 0000000..9a920cb --- /dev/null +++ b/preact/compat/test/ts/forward-ref.tsx @@ -0,0 +1,26 @@ +import React from '../../src'; + +const MyInput: React.ForwardFn<{ id: string }, { focus(): void }> = ( + props, + ref +) => { + const inputRef = React.useRef<HTMLInputElement>(null); + + React.useImperativeHandle(ref, () => ({ + focus: () => { + if (inputRef.current) { + inputRef.current.focus(); + } + } + })); + + return <input {...props} ref={inputRef} />; +}; + +export const foo = React.forwardRef(MyInput); + +export const Bar = React.forwardRef<HTMLDivElement, { children: any }>( + (props, ref) => { + return <div ref={ref}>{props.children}</div>; + } +); diff --git a/preact/compat/test/ts/lazy.tsx b/preact/compat/test/ts/lazy.tsx new file mode 100644 index 0000000..b3797c1 --- /dev/null +++ b/preact/compat/test/ts/lazy.tsx @@ -0,0 +1,17 @@ +import * as React from '../../src'; + +export interface LazyProps { + isProp: boolean; +} + +interface LazyState { + forState: string; +} +export default class IsLazyComponent extends React.Component< + LazyProps, + LazyState +> { + render({ isProp }: LazyProps) { + return <div>{isProp ? 'Super Lazy TRUE' : 'Super Lazy FALSE'}</div>; + } +} diff --git a/preact/compat/test/ts/memo.tsx b/preact/compat/test/ts/memo.tsx new file mode 100644 index 0000000..4e89e26 --- /dev/null +++ b/preact/compat/test/ts/memo.tsx @@ -0,0 +1,56 @@ +import * as React from '../../src'; +import { expectType } from './utils'; + +interface MemoProps { + required: string; + optional?: string; + defaulted: string; +} + +interface MemoPropsExceptDefaults { + required: string; + optional?: string; +} + +const ComponentExceptDefaults = () => <div></div>; + +const ReadonlyBaseComponent = (props: Readonly<MemoProps>) => ( + <div>{props.required + props.optional + props.defaulted}</div> +); +ReadonlyBaseComponent.defaultProps = { defaulted: '' }; + +const BaseComponent = (props: MemoProps) => ( + <div>{props.required + props.optional + props.defaulted}</div> +); +BaseComponent.defaultProps = { defaulted: '' }; + +// memo for readonly component with default comparison +const MemoedReadonlyComponent = React.memo(ReadonlyBaseComponent); +expectType<React.FunctionComponent<MemoProps>>(MemoedReadonlyComponent); +export const memoedReadonlyComponent = ( + <MemoedReadonlyComponent required="hi" /> +); + +// memo for non-readonly component with default comparison +const MemoedComponent = React.memo(BaseComponent); +expectType<React.FunctionComponent<MemoProps>>(MemoedComponent); +export const memoedComponent = <MemoedComponent required="hi" />; + +// memo with custom comparison +const CustomMemoedComponent = React.memo(BaseComponent, (a, b) => { + expectType<MemoProps>(a); + expectType<MemoProps>(b); + return a.required === b.required; +}); +expectType<React.FunctionComponent<MemoProps>>(CustomMemoedComponent); +export const customMemoedComponent = <CustomMemoedComponent required="hi" />; + +const MemoedComponentExceptDefaults = React.memo<MemoPropsExceptDefaults>( + ComponentExceptDefaults +); +expectType<React.FunctionComponent<MemoPropsExceptDefaults>>( + MemoedComponentExceptDefaults +); +export const memoedComponentExceptDefaults = ( + <MemoedComponentExceptDefaults required="hi" /> +); diff --git a/preact/compat/test/ts/react-default.tsx b/preact/compat/test/ts/react-default.tsx new file mode 100644 index 0000000..f46c0b4 --- /dev/null +++ b/preact/compat/test/ts/react-default.tsx @@ -0,0 +1,6 @@ +import React from '../../src'; +class ReactIsh extends React.Component { + render() { + return <div>Text</div>; + } +} diff --git a/preact/compat/test/ts/react-star.tsx b/preact/compat/test/ts/react-star.tsx new file mode 100644 index 0000000..da82690 --- /dev/null +++ b/preact/compat/test/ts/react-star.tsx @@ -0,0 +1,7 @@ +// import React from '../../src'; +import * as React from '../../src'; +class ReactIsh extends React.Component { + render() { + return <div>Text</div>; + } +} diff --git a/preact/compat/test/ts/scheduler.ts b/preact/compat/test/ts/scheduler.ts new file mode 100644 index 0000000..999e652 --- /dev/null +++ b/preact/compat/test/ts/scheduler.ts @@ -0,0 +1,19 @@ +import { + unstable_runWithPriority, + unstable_NormalPriority, + unstable_LowPriority, + unstable_IdlePriority, + unstable_UserBlockingPriority, + unstable_ImmediatePriority, + unstable_now +} from '../../src'; + +const noop = () => null; +unstable_runWithPriority(unstable_IdlePriority, noop); +unstable_runWithPriority(unstable_LowPriority, noop); +unstable_runWithPriority(unstable_NormalPriority, noop); +unstable_runWithPriority(unstable_UserBlockingPriority, noop); +unstable_runWithPriority(unstable_ImmediatePriority, noop); + +if (typeof unstable_now() === 'number') { +} diff --git a/preact/compat/test/ts/suspense.tsx b/preact/compat/test/ts/suspense.tsx new file mode 100644 index 0000000..916dd6b --- /dev/null +++ b/preact/compat/test/ts/suspense.tsx @@ -0,0 +1,52 @@ +import * as React from '../../src'; + +interface LazyProps { + isProp: boolean; +} + +const IsLazyFunctional = (props: LazyProps) => ( + <div>{props.isProp ? 'Super Lazy TRUE' : 'Super Lazy FALSE'}</div> +); + +const FallBack = () => <div>Still working...</div>; +/** + * Have to mock dynamic import as import() throws a syntax error in the test runner + */ +const componentPromise = new Promise<{ default: typeof IsLazyFunctional }>( + resolve => { + setTimeout(() => { + resolve({ default: IsLazyFunctional }); + }, 800); + } +); + +/** + * For usage with import: + * const IsLazyComp = lazy(() => import('./lazy')); + */ +const IsLazyFunc = React.lazy(() => componentPromise); + +// Suspense using lazy component +class SuspensefulFunc extends React.Component { + render() { + return ( + <React.Suspense fallback={<FallBack />}> + <IsLazyFunc isProp={false} /> + </React.Suspense> + ); + } +} + +//SuspenseList using lazy components +function SuspenseListTester(props: any) { + return ( + <React.SuspenseList revealOrder="together"> + <React.Suspense fallback={<FallBack />}> + <IsLazyFunc isProp={false} /> + </React.Suspense> + <React.Suspense fallback={<FallBack />}> + <IsLazyFunc isProp={false} /> + </React.Suspense> + </React.SuspenseList> + ); +} diff --git a/preact/compat/test/ts/tsconfig.json b/preact/compat/test/ts/tsconfig.json new file mode 100644 index 0000000..742a039 --- /dev/null +++ b/preact/compat/test/ts/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "es6", + "module": "es6", + "moduleResolution": "node", + "lib": [ + "es6", + "dom", + ], + "strict": true, + "forceConsistentCasingInFileNames": true, + "jsx": "react", + "noEmit": true, + "allowSyntheticDefaultImports": true + }, + "include": [ + "./**/*.ts", + "./**/*.tsx" + ] +} diff --git a/preact/compat/test/ts/utils.ts b/preact/compat/test/ts/utils.ts new file mode 100644 index 0000000..16ec22d --- /dev/null +++ b/preact/compat/test/ts/utils.ts @@ -0,0 +1,4 @@ +/** + * Assert the parameter is of a specific type. + */ +export const expectType = <T>(_: T): void => undefined; |