summaryrefslogtreecommitdiff
path: root/preact/compat/test
diff options
context:
space:
mode:
authorSebastian <sebasjm@gmail.com>2021-08-23 16:46:06 -0300
committerSebastian <sebasjm@gmail.com>2021-08-23 16:48:30 -0300
commit38acabfa6089ab8ac469c12b5f55022fb96935e5 (patch)
tree453dbf70000cc5e338b06201af1eaca8343f8f73 /preact/compat/test
parentf26125e039143b92dc0d84e7775f508ab0cdcaa8 (diff)
downloadnode-vendor-38acabfa6089ab8ac469c12b5f55022fb96935e5.tar.gz
node-vendor-38acabfa6089ab8ac469c12b5f55022fb96935e5.tar.bz2
node-vendor-38acabfa6089ab8ac469c12b5f55022fb96935e5.zip
added web vendorsHEADmaster
Diffstat (limited to 'preact/compat/test')
-rw-r--r--preact/compat/test/browser/Children.test.js185
-rw-r--r--preact/compat/test/browser/PureComponent.test.js125
-rw-r--r--preact/compat/test/browser/cloneElement.test.js96
-rw-r--r--preact/compat/test/browser/compat.options.test.js85
-rw-r--r--preact/compat/test/browser/component.test.js243
-rw-r--r--preact/compat/test/browser/createElement.test.js49
-rw-r--r--preact/compat/test/browser/createFactory.test.js26
-rw-r--r--preact/compat/test/browser/events.test.js278
-rw-r--r--preact/compat/test/browser/exports.test.js85
-rw-r--r--preact/compat/test/browser/findDOMNode.test.js51
-rw-r--r--preact/compat/test/browser/forwardRef.test.js460
-rw-r--r--preact/compat/test/browser/hydrate.test.js34
-rw-r--r--preact/compat/test/browser/isValidElement.test.js22
-rw-r--r--preact/compat/test/browser/memo.test.js234
-rw-r--r--preact/compat/test/browser/portals.test.js627
-rw-r--r--preact/compat/test/browser/render.test.js454
-rw-r--r--preact/compat/test/browser/scheduler.test.js39
-rw-r--r--preact/compat/test/browser/select.test.js33
-rw-r--r--preact/compat/test/browser/suspense-hydration.test.js778
-rw-r--r--preact/compat/test/browser/suspense-list.test.js588
-rw-r--r--preact/compat/test/browser/suspense-utils.js116
-rw-r--r--preact/compat/test/browser/suspense.test.js2091
-rw-r--r--preact/compat/test/browser/svg.test.js94
-rw-r--r--preact/compat/test/browser/testUtils.js24
-rw-r--r--preact/compat/test/browser/textarea.test.js64
-rw-r--r--preact/compat/test/browser/unmountComponentAtNode.test.js28
-rw-r--r--preact/compat/test/browser/unstable_batchedUpdates.test.js15
-rw-r--r--preact/compat/test/ts/forward-ref.tsx26
-rw-r--r--preact/compat/test/ts/lazy.tsx17
-rw-r--r--preact/compat/test/ts/memo.tsx56
-rw-r--r--preact/compat/test/ts/react-default.tsx6
-rw-r--r--preact/compat/test/ts/react-star.tsx7
-rw-r--r--preact/compat/test/ts/scheduler.ts19
-rw-r--r--preact/compat/test/ts/suspense.tsx52
-rw-r--r--preact/compat/test/ts/tsconfig.json20
-rw-r--r--preact/compat/test/ts/utils.ts4
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;