summaryrefslogtreecommitdiff
path: root/preact/test
diff options
context:
space:
mode:
Diffstat (limited to 'preact/test')
-rw-r--r--preact/test/TODO.md13
-rw-r--r--preact/test/_util/bench.js46
-rw-r--r--preact/test/_util/dom.js66
-rw-r--r--preact/test/_util/helpers.js292
-rw-r--r--preact/test/_util/logCall.js66
-rw-r--r--preact/test/_util/optionSpies.js39
-rw-r--r--preact/test/benchmarks/performance.test.js470
-rw-r--r--preact/test/benchmarks/text.test.js101
-rw-r--r--preact/test/browser/cloneElement.test.js87
-rw-r--r--preact/test/browser/components.test.js2664
-rw-r--r--preact/test/browser/context.test.js237
-rw-r--r--preact/test/browser/createContext.test.js931
-rw-r--r--preact/test/browser/customBuiltInElements.test.js40
-rw-r--r--preact/test/browser/events.test.js202
-rw-r--r--preact/test/browser/focus.test.js548
-rw-r--r--preact/test/browser/fragments.test.js2805
-rw-r--r--preact/test/browser/getDomSibling.test.js362
-rw-r--r--preact/test/browser/hydrate.test.js454
-rw-r--r--preact/test/browser/isValidElement.test.js4
-rw-r--r--preact/test/browser/keys.test.js627
-rw-r--r--preact/test/browser/lifecycles/componentDidCatch.test.js672
-rw-r--r--preact/test/browser/lifecycles/componentDidMount.test.js36
-rw-r--r--preact/test/browser/lifecycles/componentDidUpdate.test.js385
-rw-r--r--preact/test/browser/lifecycles/componentWillMount.test.js43
-rw-r--r--preact/test/browser/lifecycles/componentWillReceiveProps.test.js296
-rw-r--r--preact/test/browser/lifecycles/componentWillUnmount.test.js72
-rw-r--r--preact/test/browser/lifecycles/componentWillUpdate.test.js95
-rw-r--r--preact/test/browser/lifecycles/getDerivedStateFromError.test.js659
-rw-r--r--preact/test/browser/lifecycles/getDerivedStateFromProps.test.js419
-rw-r--r--preact/test/browser/lifecycles/getSnapshotBeforeUpdate.test.js211
-rw-r--r--preact/test/browser/lifecycles/lifecycle.test.js672
-rw-r--r--preact/test/browser/lifecycles/shouldComponentUpdate.test.js916
-rw-r--r--preact/test/browser/placeholders.test.js308
-rw-r--r--preact/test/browser/refs.test.js481
-rw-r--r--preact/test/browser/render.test.js1164
-rw-r--r--preact/test/browser/replaceNode.test.js239
-rw-r--r--preact/test/browser/select.test.js72
-rw-r--r--preact/test/browser/spec.test.js151
-rw-r--r--preact/test/browser/style.test.js225
-rw-r--r--preact/test/browser/svg.test.js226
-rw-r--r--preact/test/browser/toChildArray.test.js207
-rw-r--r--preact/test/extensions.d.ts5
-rw-r--r--preact/test/fixtures/preact.js626
-rw-r--r--preact/test/node/index.test.js15
-rw-r--r--preact/test/polyfills.js260
-rw-r--r--preact/test/shared/createContext.test.js24
-rw-r--r--preact/test/shared/createElement.test.js299
-rw-r--r--preact/test/shared/exports.test.js32
-rw-r--r--preact/test/shared/isValidElement.test.js5
-rw-r--r--preact/test/shared/isValidElementTests.js37
-rw-r--r--preact/test/ts/Component-test.tsx183
-rw-r--r--preact/test/ts/VNode-test.tsx197
-rw-r--r--preact/test/ts/custom-elements.tsx85
-rw-r--r--preact/test/ts/hoc-test.tsx50
-rw-r--r--preact/test/ts/jsx-namespacce-test.tsx16
-rw-r--r--preact/test/ts/preact-global-test.tsx6
-rw-r--r--preact/test/ts/preact.tsx297
-rw-r--r--preact/test/ts/refs.tsx76
-rw-r--r--preact/test/ts/tsconfig.json15
59 files changed, 19831 insertions, 0 deletions
diff --git a/preact/test/TODO.md b/preact/test/TODO.md
new file mode 100644
index 0000000..b7b1aab
--- /dev/null
+++ b/preact/test/TODO.md
@@ -0,0 +1,13 @@
+# Tests skipped to get CI to pass
+
+- Fragment
+ - ✖ should not preserve state between array nested in fragment and double nested array
+ - ✖ should preserve state between double nested fragment and double nested array
+- hydrate
+ - ✖ should override incorrect pre-existing DOM with VNodes passed into render
+
+Also:
+
+- Extend Fragment preserving state tests to track unmounting lifecycle callbacks to verify
+ components are properly unmounted. I think all 'should not preserve' tests are the ones
+ that will have unmount operations.
diff --git a/preact/test/_util/bench.js b/preact/test/_util/bench.js
new file mode 100644
index 0000000..5085daa
--- /dev/null
+++ b/preact/test/_util/bench.js
@@ -0,0 +1,46 @@
+global._ = require('lodash');
+const Benchmark = (global.Benchmark = require('benchmark'));
+
+export default function bench(benches, callback) {
+ return new Promise(resolve => {
+ const suite = new Benchmark.Suite();
+
+ let i = 0;
+ Object.keys(benches).forEach(name => {
+ let run = benches[name];
+ suite.add(name, () => {
+ run(++i);
+ });
+ });
+
+ suite.on('complete', () => {
+ const result = {
+ fastest: suite.filter('fastest')[0],
+ results: [],
+ text: ''
+ };
+ const useKilo = suite.filter(b => b.hz < 10000).length === 0;
+ suite.forEach((bench, index) => {
+ let r = {
+ name: bench.name,
+ slowdown:
+ bench.name === result.fastest.name
+ ? 0
+ : (((result.fastest.hz - bench.hz) / result.fastest.hz) * 100) |
+ 0,
+ hz: bench.hz.toFixed(bench.hz < 100 ? 2 : 0),
+ rme: bench.stats.rme.toFixed(2),
+ size: bench.stats.sample.length,
+ error: bench.error ? String(bench.error) : undefined
+ };
+ result.text += `\n ${r.name}: ${
+ useKilo ? `${(r.hz / 1000) | 0} kHz` : `${r.hz} Hz`
+ }${r.slowdown ? ` (-${r.slowdown}%)` : ''}`;
+ result.results[index] = result.results[r.name] = r;
+ });
+ resolve(result);
+ if (callback) callback(result);
+ });
+ suite.run({ async: true });
+ });
+}
diff --git a/preact/test/_util/dom.js b/preact/test/_util/dom.js
new file mode 100644
index 0000000..b24bd3e
--- /dev/null
+++ b/preact/test/_util/dom.js
@@ -0,0 +1,66 @@
+/**
+ * Serialize contents
+ * @typedef {string | number | Array<string | number>} Contents
+ * @param {Contents} contents
+ */
+const serialize = contents =>
+ Array.isArray(contents) ? contents.join('') : contents.toString();
+
+/**
+ * A helper to generate innerHTML validation strings containing spans
+ * @param {Contents} contents The contents of the span, as a string
+ */
+export const span = contents => `<span>${serialize(contents)}</span>`;
+
+/**
+ * A helper to generate innerHTML validation strings containing divs
+ * @param {Contents} contents The contents of the div, as a string
+ */
+export const div = contents => `<div>${serialize(contents)}</div>`;
+
+/**
+ * A helper to generate innerHTML validation strings containing p
+ * @param {Contents} contents The contents of the p, as a string
+ */
+export const p = contents => `<p>${serialize(contents)}</p>`;
+
+/**
+ * A helper to generate innerHTML validation strings containing sections
+ * @param {Contents} contents The contents of the section, as a string
+ */
+export const section = contents => `<section>${serialize(contents)}</section>`;
+
+/**
+ * A helper to generate innerHTML validation strings containing uls
+ * @param {Contents} contents The contents of the ul, as a string
+ */
+export const ul = contents => `<ul>${serialize(contents)}</ul>`;
+
+/**
+ * A helper to generate innerHTML validation strings containing ols
+ * @param {Contents} contents The contents of the ol, as a string
+ */
+export const ol = contents => `<ol>${serialize(contents)}</ol>`;
+
+/**
+ * A helper to generate innerHTML validation strings containing lis
+ * @param {Contents} contents The contents of the li, as a string
+ */
+export const li = contents => `<li>${serialize(contents)}</li>`;
+
+/**
+ * A helper to generate innerHTML validation strings containing inputs
+ */
+export const input = () => `<input type="text">`;
+
+/**
+ * A helper to generate innerHTML validation strings containing h1
+ * @param {Contents} contents The contents of the h1
+ */
+export const h1 = contents => `<h1>${serialize(contents)}</h1>`;
+
+/**
+ * A helper to generate innerHTML validation strings containing h2
+ * @param {Contents} contents The contents of the h2
+ */
+export const h2 = contents => `<h2>${serialize(contents)}</h2>`;
diff --git a/preact/test/_util/helpers.js b/preact/test/_util/helpers.js
new file mode 100644
index 0000000..8f0cf02
--- /dev/null
+++ b/preact/test/_util/helpers.js
@@ -0,0 +1,292 @@
+import { createElement, options } from 'preact';
+import { clearLog, getLog } from './logCall';
+import { teardown as testUtilTeardown } from 'preact/test-utils';
+
+/** @jsx createElement */
+
+/**
+ * Assign properties from `props` to `obj`
+ * @template O, P The obj and props types
+ * @param {O} obj The object to copy properties to
+ * @param {P} props The object to copy properties from
+ * @returns {O & P}
+ */
+function assign(obj, props) {
+ for (let i in props) obj[i] = props[i];
+ return /** @type {O & P} */ (obj);
+}
+
+export function supportsPassiveEvents() {
+ let supported = false;
+ try {
+ let options = {
+ get passive() {
+ supported = true;
+ return undefined;
+ }
+ };
+
+ window.addEventListener('test', options, options);
+ window.removeEventListener('test', options, options);
+ } catch (err) {
+ supported = false;
+ }
+ return supported;
+}
+
+export function supportsDataList() {
+ return (
+ 'list' in document.createElement('input') &&
+ Boolean(
+ document.createElement('datalist') && 'HTMLDataListElement' in window
+ )
+ );
+}
+
+const VOID_ELEMENTS = /^(area|base|br|col|embed|hr|img|input|link|meta|param|source|track|wbr)$/;
+
+function encodeEntities(str) {
+ return str.replace(/&/g, '&amp;');
+}
+
+/**
+ * Normalize svg paths spacing. Some browsers insert spaces around letters,
+ * others do not.
+ * @param {string} str
+ * @returns {string}
+ */
+function normalizePath(str) {
+ let len = str.length;
+ let out = '';
+ for (let i = 0; i < len; i++) {
+ const char = str[i];
+ if (/[A-Za-z]/.test(char)) {
+ if (i == 0) out += char + ' ';
+ else
+ out += (str[i - 1] == ' ' ? '' : ' ') + char + (i < len - 1 ? ' ' : '');
+ } else if (char == '-' && str[i - 1] !== ' ') out += ' ' + char;
+ else out += char;
+ }
+
+ return out.replace(/\s\s+/g, ' ').replace(/z/g, 'Z');
+}
+
+export function serializeHtml(node) {
+ let str = '';
+ let child = node.firstChild;
+ while (child) {
+ str += serializeDomTree(child);
+ child = child.nextSibling;
+ }
+ return str;
+}
+
+/**
+ * Serialize a DOM tree.
+ * Uses deterministic sorting where necessary to ensure consistent tests.
+ * @param {Element|Node} node The root node to serialize
+ * @returns {string} html
+ */
+function serializeDomTree(node) {
+ if (node.nodeType === 3) {
+ return encodeEntities(node.data);
+ } else if (node.nodeType === 8) {
+ return '<!--' + encodeEntities(node.data) + '-->';
+ } else if (node.nodeType === 1 || node.nodeType === 9) {
+ let str = '<' + node.localName;
+ const attrs = [];
+ for (let i = 0; i < node.attributes.length; i++) {
+ attrs.push(node.attributes[i].name);
+ }
+ attrs.sort();
+ for (let i = 0; i < attrs.length; i++) {
+ const name = attrs[i];
+ let value = node.getAttribute(name);
+
+ // don't render attributes with null or undefined values
+ if (value == null) continue;
+
+ // normalize empty class attribute
+ if (!value && name === 'class') continue;
+
+ str += ' ' + name;
+ value = encodeEntities(value);
+
+ // normalize svg <path d="value">
+ if (node.localName === 'path' && name === 'd') {
+ value = normalizePath(value);
+ }
+ str += '="' + value + '"';
+ }
+ str += '>';
+
+ // For elements that don't have children (e.g. <wbr />) don't descend.
+ if (!VOID_ELEMENTS.test(node.localName)) {
+ // IE puts the value of a textarea as its children while other browsers don't.
+ // Normalize those differences by forcing textarea to not have children.
+ if (node.localName != 'textarea') {
+ let child = node.firstChild;
+ while (child) {
+ str += serializeDomTree(child);
+ child = child.nextSibling;
+ }
+ }
+
+ str += '</' + node.localName + '>';
+ }
+ return str;
+ }
+}
+
+/**
+ * Normalize event creation in browsers
+ * @param {string} name
+ * @returns {Event}
+ */
+export function createEvent(name) {
+ // Modern browsers
+ if (typeof Event == 'function') {
+ return new Event(name);
+ }
+
+ // IE 11...
+ let event = document.createEvent('Event');
+ event.initEvent(name, true, true);
+ return event;
+}
+
+/**
+ * Sort a cssText alphabetically.
+ * @param {string} cssText
+ */
+export function sortCss(cssText) {
+ return (
+ cssText
+ .split(';')
+ .filter(Boolean)
+ .map(s => s.replace(/^\s+|\s+$/g, '').replace(/(\s*:\s*)/g, ': '))
+ .sort((a, b) => {
+ // CSS Variables are typically positioned at the start
+ if (a[0] === '-') {
+ // If both are a variable we just compare them
+ if (b[0] === '-') return a.localeCompare(b);
+ return -1;
+ }
+ // b is a css var
+ if (b[0] === '-') return 1;
+
+ return a.localeCompare(b);
+ })
+ .join('; ') + ';'
+ );
+}
+
+/**
+ * Setup the test environment
+ * @returns {HTMLDivElement}
+ */
+export function setupScratch() {
+ const scratch = document.createElement('div');
+ scratch.id = 'scratch';
+ (document.body || document.documentElement).appendChild(scratch);
+ return scratch;
+}
+
+let oldOptions = null;
+export function clearOptions() {
+ oldOptions = assign({}, options);
+ delete options.vnode;
+ delete options.diffed;
+ delete options.unmount;
+ delete options._diff;
+}
+
+/**
+ * Teardown test environment and reset preact's internal state
+ * @param {HTMLDivElement} scratch
+ */
+export function teardown(scratch) {
+ if (scratch) {
+ scratch.parentNode.removeChild(scratch);
+ }
+
+ if (oldOptions != null) {
+ assign(options, oldOptions);
+ oldOptions = null;
+ }
+
+ testUtilTeardown();
+
+ if (getLog().length > 0) {
+ clearLog();
+ }
+
+ restoreElementAttributes();
+}
+
+const Foo = () => 'd';
+export const getMixedArray = () =>
+ // Make it a function so each test gets a new copy of the array
+ [0, 'a', 'b', <span>c</span>, <Foo />, null, undefined, false, ['e', 'f'], 1];
+export const mixedArrayHTML = '0ab<span>c</span>def1';
+
+/**
+ * Reset obj to empty to keep reference
+ * @param {object} obj
+ */
+export function clear(obj) {
+ Object.keys(obj).forEach(key => delete obj[key]);
+}
+
+/**
+ * Hacky normalization of attribute order across browsers.
+ * @param {string} html
+ */
+export function sortAttributes(html) {
+ return html.replace(
+ /<([a-z0-9-]+)((?:\s[a-z0-9:_.-]+=".*?")+)((?:\s*\/)?>)/gi,
+ (s, pre, attrs, after) => {
+ let list = attrs
+ .match(/\s[a-z0-9:_.-]+=".*?"/gi)
+ .sort((a, b) => (a > b ? 1 : -1));
+ if (~after.indexOf('/')) after = '></' + pre + '>';
+ return '<' + pre + list.join('') + after;
+ }
+ );
+}
+
+let attributesSpy, originalAttributesPropDescriptor;
+
+export function spyOnElementAttributes() {
+ const test = Object.getOwnPropertyDescriptor(Element.prototype, 'attributes');
+
+ // IE11 doesn't correctly restore the prototype methods so we have to check
+ // whether this prototype method is already a sinon spy.
+ if (!attributesSpy && !(test && test.get && test.get.isSinonProxy)) {
+ if (!originalAttributesPropDescriptor) {
+ originalAttributesPropDescriptor = Object.getOwnPropertyDescriptor(
+ Element.prototype,
+ 'attributes'
+ );
+ }
+
+ attributesSpy = sinon.spy(Element.prototype, 'attributes', ['get']);
+ } else if (test && test.get && test.get.isSinonProxy) {
+ // Due to IE11 not resetting we will do this manually when it is a proxy.
+ test.get.resetHistory();
+ }
+
+ return attributesSpy || test;
+}
+
+function restoreElementAttributes() {
+ if (originalAttributesPropDescriptor) {
+ // Workaround bug in Sinon where getter/setter spies don't get auto-restored
+ Object.defineProperty(
+ Element.prototype,
+ 'attributes',
+ originalAttributesPropDescriptor
+ );
+ attributesSpy = null;
+ }
+}
diff --git a/preact/test/_util/logCall.js b/preact/test/_util/logCall.js
new file mode 100644
index 0000000..0eff282
--- /dev/null
+++ b/preact/test/_util/logCall.js
@@ -0,0 +1,66 @@
+/**
+ * Serialize an object
+ * @param {Object} obj
+ * @return {string}
+ */
+function serialize(obj) {
+ if (obj instanceof Text) return '#text';
+ if (obj instanceof Element) return `<${obj.localName}>${obj.textContent}`;
+ if (obj === document) return 'document';
+ if (typeof obj == 'string') return obj;
+ return Object.prototype.toString.call(obj).replace(/(^\[object |\]$)/g, '');
+}
+
+/** @type {string[]} */
+let log = [];
+
+/**
+ * Modify obj's original method to log calls and arguments on logger object
+ * @template T
+ * @param {T} obj
+ * @param {keyof T} method
+ */
+export function logCall(obj, method) {
+ let old = obj[method];
+ obj[method] = function(...args) {
+ let c = '';
+ for (let i = 0; i < args.length; i++) {
+ if (c) c += ', ';
+ c += serialize(args[i]);
+ }
+
+ // Normalize removeChild -> remove to keep output clean and readable
+ const operation =
+ method != 'removeChild'
+ ? `${serialize(this)}.${method}(${c})`
+ : `${serialize(c)}.remove()`;
+ log.push(operation);
+ return old.apply(this, args);
+ };
+
+ return () => (obj[method] = old);
+}
+
+/**
+ * Return log object
+ * @return {string[]} log
+ */
+export function getLog() {
+ return log;
+}
+
+/** Clear log object */
+export function clearLog() {
+ log = [];
+}
+
+export function getLogSummary() {
+ /** @type {{ [key: string]: number }} */
+ const summary = {};
+
+ for (let entry of log) {
+ summary[entry] = (summary[entry] || 0) + 1;
+ }
+
+ return summary;
+}
diff --git a/preact/test/_util/optionSpies.js b/preact/test/_util/optionSpies.js
new file mode 100644
index 0000000..de32c52
--- /dev/null
+++ b/preact/test/_util/optionSpies.js
@@ -0,0 +1,39 @@
+import { options as rawOptions } from 'preact';
+
+/** @type {import('preact/src/internal').Options} */
+let options = rawOptions;
+
+let oldVNode = options.vnode;
+let oldEvent = options.event || (e => e);
+let oldAfterDiff = options.diffed;
+let oldUnmount = options.unmount;
+
+let oldRoot = options._root;
+let oldBeforeDiff = options._diff;
+let oldBeforeRender = options._render;
+let oldBeforeCommit = options._commit;
+let oldHook = options._hook;
+let oldCatchError = options._catchError;
+
+export const vnodeSpy = sinon.spy(oldVNode);
+export const eventSpy = sinon.spy(oldEvent);
+export const afterDiffSpy = sinon.spy(oldAfterDiff);
+export const unmountSpy = sinon.spy(oldUnmount);
+
+export const rootSpy = sinon.spy(oldRoot);
+export const beforeDiffSpy = sinon.spy(oldBeforeDiff);
+export const beforeRenderSpy = sinon.spy(oldBeforeRender);
+export const beforeCommitSpy = sinon.spy(oldBeforeCommit);
+export const hookSpy = sinon.spy(oldHook);
+export const catchErrorSpy = sinon.spy(oldCatchError);
+
+options.vnode = vnodeSpy;
+options.event = eventSpy;
+options.diffed = afterDiffSpy;
+options.unmount = unmountSpy;
+options._root = rootSpy;
+options._diff = beforeDiffSpy;
+options._render = beforeRenderSpy;
+options._commit = beforeCommitSpy;
+options._hook = hookSpy;
+options._catchError = catchErrorSpy;
diff --git a/preact/test/benchmarks/performance.test.js b/preact/test/benchmarks/performance.test.js
new file mode 100644
index 0000000..8729878
--- /dev/null
+++ b/preact/test/benchmarks/performance.test.js
@@ -0,0 +1,470 @@
+/*global COVERAGE, ENABLE_PERFORMANCE*/
+/*eslint no-console:0*/
+/** @jsx createElement */
+import { setupScratch, teardown } from '../_util/helpers';
+import {
+ createElement,
+ Component,
+ render,
+ hydrate
+} from 'preact/dist/preact.module';
+
+const MULTIPLIER = ENABLE_PERFORMANCE ? (COVERAGE ? 5 : 1) : 999999;
+
+// let now = typeof performance!=='undefined' && performance.now ? () => performance.now() : () => +new Date();
+if (typeof performance == 'undefined') {
+ window.performance = { now: () => +new Date() };
+}
+
+function loop(iter, time) {
+ let start = performance.now(),
+ count = 0;
+ while (performance.now() - start < time) {
+ count++;
+ iter();
+ }
+ return count;
+}
+
+function benchmark(iter, callback) {
+ let a = 0; // eslint-disable-line no-unused-vars
+ function noop() {
+ try {
+ a++;
+ } finally {
+ a += Math.random();
+ }
+ }
+
+ // warm
+ for (let i = 100; i--; ) noop(), iter();
+
+ let count = 4,
+ time = 500,
+ passes = 0,
+ noops = loop(noop, time),
+ iterations = 0;
+
+ function next() {
+ iterations += loop(iter, time);
+ setTimeout(++passes === count ? done : next, 10);
+ }
+
+ function done() {
+ let ticks = Math.round((noops / iterations) * count),
+ hz = (iterations / count / time) * 1000,
+ message = `${hz | 0}/s (${ticks} ticks)`;
+ callback({ iterations, noops, count, time, ticks, hz, message });
+ }
+
+ next();
+}
+
+describe('performance', function() {
+ let scratch;
+
+ this.timeout(10000);
+
+ before(function() {
+ if (!ENABLE_PERFORMANCE) this.skip();
+ if (COVERAGE) {
+ console.warn(
+ 'WARNING: Code coverage is enabled, which dramatically reduces performance. Do not pay attention to these numbers.'
+ );
+ }
+ });
+
+ beforeEach(() => {
+ scratch = setupScratch();
+ });
+
+ afterEach(() => {
+ teardown(scratch);
+ });
+
+ it('should rerender without changes fast', done => {
+ let jsx = (
+ <div class="foo bar" data-foo="bar" p={2}>
+ <header>
+ <h1 class="asdf">
+ a {'b'} c {0} d
+ </h1>
+ <nav>
+ <a href="/foo">Foo</a>
+ <a href="/bar">Bar</a>
+ </nav>
+ </header>
+ <main>
+ <form onSubmit={() => {}}>
+ <input type="checkbox" checked />
+ <input type="checkbox" checked={false} />
+ <fieldset>
+ <label>
+ <input type="radio" checked />
+ </label>
+ <label>
+ <input type="radio" />
+ </label>
+ </fieldset>
+ <button-bar>
+ <button style="width:10px; height:10px; border:1px solid #FFF;">
+ Normal CSS
+ </button>
+ <button style="top:0 ; right: 20">Poor CSS</button>
+ <button
+ style="invalid-prop:1;padding:1px;font:12px/1.1 arial,sans-serif;"
+ icon
+ >
+ Poorer CSS
+ </button>
+ <button
+ style={{ margin: 0, padding: '10px', overflow: 'visible' }}
+ >
+ Object CSS
+ </button>
+ </button-bar>
+ </form>
+ </main>
+ </div>
+ );
+
+ benchmark(
+ () => {
+ render(jsx, scratch);
+ },
+ ({ ticks, message }) => {
+ console.log(`PERF: empty diff: ${message}`);
+ expect(ticks).to.be.below(150 * MULTIPLIER);
+ done();
+ }
+ );
+ });
+
+ it('should rerender repeated trees fast', done => {
+ class Header extends Component {
+ render() {
+ return (
+ <header>
+ <h1 class="asdf">
+ a {'b'} c {0} d
+ </h1>
+ <nav>
+ <a href="/foo">Foo</a>
+ <a href="/bar">Bar</a>
+ </nav>
+ </header>
+ );
+ }
+ }
+ class Form extends Component {
+ render() {
+ return (
+ <form onSubmit={() => {}}>
+ <input type="checkbox" checked />
+ <input type="checkbox" checked={false} />
+ <fieldset>
+ <label>
+ <input type="radio" checked />
+ </label>
+ <label>
+ <input type="radio" />
+ </label>
+ </fieldset>
+ <ButtonBar />
+ </form>
+ );
+ }
+ }
+ class ButtonBar extends Component {
+ render() {
+ return (
+ <button-bar>
+ <Button style="width:10px; height:10px; border:1px solid #FFF;">
+ Normal CSS
+ </Button>
+ <Button style="top:0 ; right: 20">Poor CSS</Button>
+ <Button
+ style="invalid-prop:1;padding:1px;font:12px/1.1 arial,sans-serif;"
+ icon
+ >
+ Poorer CSS
+ </Button>
+ <Button style={{ margin: 0, padding: '10px', overflow: 'visible' }}>
+ Object CSS
+ </Button>
+ </button-bar>
+ );
+ }
+ }
+ class Button extends Component {
+ render(props) {
+ return <button {...props} />;
+ }
+ }
+ class Main extends Component {
+ render() {
+ return <Form />;
+ }
+ }
+ class Root extends Component {
+ render() {
+ return (
+ <div class="foo bar" data-foo="bar" p={2}>
+ <Header />
+ <Main />
+ </div>
+ );
+ }
+ }
+ class Empty extends Component {
+ render() {
+ return <div />;
+ }
+ }
+ class Parent extends Component {
+ render({ child: C }) {
+ return <C />;
+ }
+ }
+
+ benchmark(
+ () => {
+ render(<Parent child={Root} />, scratch);
+ render(<Parent child={Empty} />, scratch);
+ },
+ ({ ticks, message }) => {
+ console.log(`PERF: repeat diff: ${message}`);
+ expect(ticks).to.be.below(3000 * MULTIPLIER);
+ done();
+ }
+ );
+ });
+
+ it('should construct large VDOM trees fast', done => {
+ const FIELDS = [];
+ for (let i = 100; i--; ) FIELDS.push((i * 999).toString(36));
+
+ let out = [];
+ function digest(vnode) {
+ out.push(vnode);
+ out.length = 0;
+ }
+ benchmark(
+ () => {
+ digest(
+ <div class="foo bar" data-foo="bar" p={2}>
+ <header>
+ <h1 class="asdf">
+ a {'b'} c {0} d
+ </h1>
+ <nav>
+ <a href="/foo">Foo</a>
+ <a href="/bar">Bar</a>
+ </nav>
+ </header>
+ <main>
+ <form onSubmit={() => {}}>
+ <input type="checkbox" checked />
+ <input type="checkbox" />
+ <fieldset>
+ {FIELDS.map(field => (
+ <label>
+ {field}:
+ <input placeholder={field} />
+ </label>
+ ))}
+ </fieldset>
+ <button-bar>
+ <button style="width:10px; height:10px; border:1px solid #FFF;">
+ Normal CSS
+ </button>
+ <button style="top:0 ; right: 20">Poor CSS</button>
+ <button
+ style="invalid-prop:1;padding:1px;font:12px/1.1 arial,sans-serif;"
+ icon
+ >
+ Poorer CSS
+ </button>
+ <button
+ style={{ margin: 0, padding: '10px', overflow: 'visible' }}
+ >
+ Object CSS
+ </button>
+ </button-bar>
+ </form>
+ </main>
+ </div>
+ );
+ },
+ ({ ticks, message }) => {
+ console.log(`PERF: large VTree: ${message}`);
+ expect(ticks).to.be.below(200 * MULTIPLIER);
+ done();
+ }
+ );
+ });
+
+ it('should mutate styles/properties quickly', done => {
+ let counter = 0;
+ const keyLooper = n => c => (c % n ? `${c}px` : c);
+ const get = (obj, i) => obj[i % obj.length];
+ const CLASSES = ['foo', 'foo bar', '', 'baz-bat', null];
+ const STYLES = [];
+ const MULTIVALUE = [
+ '0 1px',
+ '0 0 1px 0',
+ '0',
+ '1px',
+ '20px 10px',
+ '7em 5px',
+ '1px 0 5em 2px'
+ ];
+ const STYLEKEYS = [
+ ['left', keyLooper(3)],
+ ['top', keyLooper(2)],
+ ['margin', c => get(MULTIVALUE, c).replace('1px', c + 'px')],
+ ['padding', c => get(MULTIVALUE, c)],
+ ['position', c => (c % 5 ? (c % 2 ? 'absolute' : 'relative') : null)],
+ ['display', c => (c % 10 ? (c % 2 ? 'block' : 'inline') : 'none')],
+ [
+ 'color',
+ c =>
+ `rgba(${c % 255}, ${255 - (c % 255)}, ${50 + (c % 150)}, ${(c % 50) /
+ 50})`
+ ],
+ [
+ 'border',
+ c =>
+ c % 5
+ ? `${c % 10}px ${c % 2 ? 'solid' : 'dotted'} ${STYLEKEYS[6][1](c)}`
+ : ''
+ ]
+ ];
+ for (let i = 0; i < 1000; i++) {
+ let style = {};
+ for (let j = 0; j < i % 10; j++) {
+ let conf = get(STYLEKEYS, ++counter);
+ style[conf[0]] = conf[1](counter);
+ }
+ STYLES[i] = style;
+ }
+
+ const app = index => (
+ <div
+ class={get(CLASSES, index)}
+ data-index={index}
+ title={index.toString(36)}
+ >
+ <input type="checkbox" checked={index % 3 == 0} />
+ <input
+ value={`test ${(index / 4) | 0}`}
+ disabled={index % 10 ? null : true}
+ />
+ <div class={get(CLASSES, index * 10)}>
+ <p style={get(STYLES, index)}>p1</p>
+ <p style={get(STYLES, index + 1)}>p2</p>
+ <p style={get(STYLES, index * 2)}>p3</p>
+ <p style={get(STYLES, index * 3 + 1)}>p4</p>
+ </div>
+ </div>
+ );
+
+ let count = 0;
+ benchmark(
+ () => {
+ render(app(++count), scratch);
+ },
+ ({ ticks, message }) => {
+ console.log(`PERF: style/prop mutation: ${message}`);
+ expect(ticks).to.be.below(350 * MULTIPLIER);
+ done();
+ }
+ );
+ });
+
+ it('should hydrate from SSR quickly', done => {
+ class Header extends Component {
+ render() {
+ return (
+ <header>
+ <h1 class="asdf">
+ a {'b'} c {0} d
+ </h1>
+ <nav>
+ <a href="/foo">Foo</a>
+ <a href="/bar">Bar</a>
+ </nav>
+ </header>
+ );
+ }
+ }
+ class Form extends Component {
+ render() {
+ return (
+ <form onSubmit={() => {}}>
+ <input type="checkbox" checked />
+ <input type="checkbox" checked={false} />
+ <fieldset>
+ <label>
+ <input type="radio" checked />
+ </label>
+ <label>
+ <input type="radio" />
+ </label>
+ </fieldset>
+ <ButtonBar />
+ </form>
+ );
+ }
+ }
+ const ButtonBar = () => (
+ <button-bar>
+ <Button style="width:10px; height:10px; border:1px solid #FFF;">
+ Normal CSS
+ </Button>
+ <Button style="top:0 ; right: 20">Poor CSS</Button>
+ <Button
+ style="invalid-prop:1;padding:1px;font:12px/1.1 arial,sans-serif;"
+ icon
+ >
+ Poorer CSS
+ </Button>
+ <Button style={{ margin: 0, padding: '10px', overflow: 'visible' }}>
+ Object CSS
+ </Button>
+ </button-bar>
+ );
+ class Button extends Component {
+ handleClick() {}
+ render(props) {
+ return <button onClick={this.handleClick} {...props} />;
+ }
+ }
+ const Main = () => <Form />;
+ class App extends Component {
+ render() {
+ return (
+ <div class="foo bar" data-foo="bar" p={2}>
+ <Header />
+ <Main />
+ </div>
+ );
+ }
+ }
+
+ render(<App />, scratch);
+ let html = scratch.innerHTML;
+
+ benchmark(
+ () => {
+ scratch.innerHTML = html;
+ hydrate(<App />, scratch);
+ },
+ ({ ticks, message }) => {
+ console.log(`PERF: SSR Hydrate: ${message}`);
+ expect(ticks).to.be.below(3000 * MULTIPLIER);
+ done();
+ }
+ );
+ });
+});
diff --git a/preact/test/benchmarks/text.test.js b/preact/test/benchmarks/text.test.js
new file mode 100644
index 0000000..9106e52
--- /dev/null
+++ b/preact/test/benchmarks/text.test.js
@@ -0,0 +1,101 @@
+/*global COVERAGE, ENABLE_PERFORMANCE */
+/*eslint no-console:0*/
+/** @jsx createElement */
+
+import { setupScratch, teardown } from '../_util/helpers';
+import bench from '../_util/bench';
+import preact8 from '../fixtures/preact';
+import * as preactX from '../../dist/preact.module';
+const MULTIPLIER = ENABLE_PERFORMANCE ? (COVERAGE ? 5 : 1) : 999999;
+
+describe('benchmarks', function() {
+ let scratch;
+
+ this.timeout(100000);
+
+ before(function() {
+ if (!ENABLE_PERFORMANCE) this.skip();
+ if (COVERAGE) {
+ console.warn(
+ 'WARNING: Code coverage is enabled, which dramatically reduces performance. Do not pay attention to these numbers.'
+ );
+ }
+ });
+
+ beforeEach(() => {
+ scratch = setupScratch();
+ });
+
+ afterEach(() => {
+ teardown(scratch);
+ });
+
+ it('in-place text update', done => {
+ function createTest({ createElement, render }) {
+ const parent = document.createElement('div');
+ scratch.appendChild(parent);
+
+ function component(randomValue) {
+ return (
+ <div>
+ <h2>Test {randomValue}</h2>
+ <h1>==={randomValue}===</h1>
+ </div>
+ );
+ }
+
+ return value => {
+ const t = value % 100;
+ render(component(t), parent);
+ };
+ }
+
+ function createVanillaTest() {
+ const parent = document.createElement('div');
+ let div, h1, h2, text1, text2;
+ parent.appendChild((div = document.createElement('div')));
+ div.appendChild((h2 = document.createElement('h2')));
+ h2.appendChild(document.createTextNode('Vanilla '));
+ h2.appendChild((text1 = document.createTextNode('0')));
+ div.appendChild((h1 = document.createElement('h1')));
+ h1.appendChild(document.createTextNode('==='));
+ h1.appendChild((text2 = document.createTextNode('0')));
+ h1.appendChild(document.createTextNode('==='));
+ scratch.appendChild(parent);
+
+ return value => {
+ const t = value % 100;
+ text1.data = '' + t;
+ text2.data = '' + t;
+ };
+ }
+
+ const preact8Test = createTest(preact8);
+ const preactXTest = createTest(preactX);
+ const vanillaTest = createVanillaTest();
+
+ for (let i = 100; i--; ) {
+ preact8Test(i);
+ preactXTest(i);
+ vanillaTest(i);
+ }
+
+ bench(
+ {
+ vanilla: vanillaTest,
+ preact8: preact8Test,
+ preactX: preactXTest
+ },
+ ({ text, results }) => {
+ const THRESHOLD = 10 * MULTIPLIER;
+ // const slowdown = Math.sqrt(results.preactX.hz * results.vanilla.hz);
+ const slowdown = results.vanilla.hz / results.preactX.hz;
+ console.log(
+ `in-place text update is ${slowdown.toFixed(2)}x slower:` + text
+ );
+ expect(slowdown).to.be.below(THRESHOLD);
+ done();
+ }
+ );
+ });
+});
diff --git a/preact/test/browser/cloneElement.test.js b/preact/test/browser/cloneElement.test.js
new file mode 100644
index 0000000..80c44c8
--- /dev/null
+++ b/preact/test/browser/cloneElement.test.js
@@ -0,0 +1,87 @@
+import { createElement, cloneElement, createRef } from 'preact';
+
+/** @jsx createElement */
+
+describe('cloneElement', () => {
+ it('should clone components', () => {
+ function Comp() {}
+ const instance = <Comp prop1={1}>hello</Comp>;
+ const clone = cloneElement(instance);
+
+ expect(clone.prototype).to.equal(instance.prototype);
+ expect(clone.type).to.equal(instance.type);
+ expect(clone.props).not.to.equal(instance.props); // Should be a different object...
+ expect(clone.props).to.deep.equal(instance.props); // with the same properties
+ });
+
+ it('should merge new props', () => {
+ function Foo() {}
+ const instance = <Foo prop1={1} prop2={2} />;
+ const clone = cloneElement(instance, { prop1: -1, newProp: -2 });
+
+ expect(clone.prototype).to.equal(instance.prototype);
+ expect(clone.type).to.equal(instance.type);
+ expect(clone.props).not.to.equal(instance.props);
+ expect(clone.props.prop1).to.equal(-1);
+ expect(clone.props.prop2).to.equal(2);
+ expect(clone.props.newProp).to.equal(-2);
+ });
+
+ it('should override children if specified', () => {
+ function Foo() {}
+ const instance = <Foo>hello</Foo>;
+ const clone = cloneElement(instance, null, 'world', '!');
+
+ expect(clone.prototype).to.equal(instance.prototype);
+ expect(clone.type).to.equal(instance.type);
+ expect(clone.props).not.to.equal(instance.props);
+ expect(clone.props.children).to.deep.equal(['world', '!']);
+ });
+
+ it('should override children if null is provided as an argument', () => {
+ function Foo() {}
+ const instance = <Foo>foo</Foo>;
+ const clone = cloneElement(instance, { children: 'bar' }, null);
+
+ expect(clone.prototype).to.equal(instance.prototype);
+ expect(clone.type).to.equal(instance.type);
+ expect(clone.props).not.to.equal(instance.props);
+ expect(clone.props.children).to.be.null;
+ });
+
+ it('should override key if specified', () => {
+ function Foo() {}
+ const instance = <Foo key="1">hello</Foo>;
+
+ let clone = cloneElement(instance);
+ expect(clone.key).to.equal('1');
+
+ clone = cloneElement(instance, { key: '2' });
+ expect(clone.key).to.equal('2');
+ });
+
+ it('should override ref if specified', () => {
+ function a() {}
+ function b() {}
+ function Foo() {}
+ const instance = <Foo ref={a}>hello</Foo>;
+
+ let clone = cloneElement(instance);
+ expect(clone.ref).to.equal(a);
+
+ clone = cloneElement(instance, { ref: b });
+ expect(clone.ref).to.equal(b);
+ });
+
+ it('should normalize props (ref)', () => {
+ const div = <div>hello</div>;
+ const clone = cloneElement(div, { ref: createRef() });
+ expect(clone.props.ref).to.equal(undefined);
+ });
+
+ it('should normalize props (key)', () => {
+ const div = <div>hello</div>;
+ const clone = cloneElement(div, { key: 'myKey' });
+ expect(clone.props.key).to.equal(undefined);
+ });
+});
diff --git a/preact/test/browser/components.test.js b/preact/test/browser/components.test.js
new file mode 100644
index 0000000..405d608
--- /dev/null
+++ b/preact/test/browser/components.test.js
@@ -0,0 +1,2664 @@
+import { createElement, render, Component, Fragment } from 'preact';
+import { setupRerender } from 'preact/test-utils';
+import {
+ setupScratch,
+ teardown,
+ getMixedArray,
+ mixedArrayHTML,
+ serializeHtml,
+ sortAttributes
+} from '../_util/helpers';
+import { div, span, p } from '../_util/dom';
+
+/** @jsx createElement */
+const h = createElement;
+
+function getAttributes(node) {
+ let attrs = {};
+ if (node.attributes) {
+ for (let i = node.attributes.length; i--; ) {
+ attrs[node.attributes[i].name] = node.attributes[i].value;
+ }
+ }
+ return attrs;
+}
+
+describe('Components', () => {
+ /** @type {HTMLDivElement} */
+ let scratch;
+
+ /** @type {() => void} */
+ let rerender;
+
+ beforeEach(() => {
+ scratch = setupScratch();
+ rerender = setupRerender();
+ });
+
+ afterEach(() => {
+ teardown(scratch);
+ });
+
+ describe('Component construction', () => {
+ /** @type {object} */
+ let instance;
+ let PROPS;
+ let STATE;
+
+ beforeEach(() => {
+ instance = null;
+ PROPS = { foo: 'bar', onBaz: () => {} };
+ STATE = { text: 'Hello' };
+ });
+
+ it('should render components', () => {
+ class C1 extends Component {
+ render() {
+ return <div>C1</div>;
+ }
+ }
+ sinon.spy(C1.prototype, 'render');
+ render(<C1 />, scratch);
+
+ expect(C1.prototype.render)
+ .to.have.been.calledOnce.and.to.have.been.calledWithMatch({}, {})
+ .and.to.have.returned(sinon.match({ type: 'div' }));
+
+ expect(scratch.innerHTML).to.equal('<div>C1</div>');
+ });
+
+ it('should render functional components', () => {
+ const C3 = sinon.spy(props => <div {...props} />);
+
+ render(<C3 {...PROPS} />, scratch);
+
+ expect(C3)
+ .to.have.been.calledOnce.and.to.have.been.calledWithMatch(PROPS)
+ .and.to.have.returned(
+ sinon.match({
+ type: 'div',
+ props: PROPS
+ })
+ );
+
+ expect(scratch.innerHTML).to.equal('<div foo="bar"></div>');
+ });
+
+ it('should render components with props', () => {
+ let constructorProps;
+
+ class C2 extends Component {
+ constructor(props) {
+ super(props);
+ constructorProps = props;
+ }
+ render(props) {
+ return <div {...props} />;
+ }
+ }
+ sinon.spy(C2.prototype, 'render');
+
+ render(<C2 {...PROPS} />, scratch);
+
+ expect(constructorProps).to.deep.equal(PROPS);
+
+ expect(C2.prototype.render)
+ .to.have.been.calledOnce.and.to.have.been.calledWithMatch(PROPS, {})
+ .and.to.have.returned(
+ sinon.match({
+ type: 'div',
+ props: PROPS
+ })
+ );
+
+ expect(scratch.innerHTML).to.equal('<div foo="bar"></div>');
+ });
+
+ it('should not crash when setting state in constructor', () => {
+ class Foo extends Component {
+ constructor(props) {
+ super(props);
+ // the following line made `this._nextState !== this.state` be truthy prior to the fix for preactjs/preact#2638
+ this.state = {};
+ this.setState({ preact: 'awesome' });
+ }
+ }
+
+ expect(() => render(<Foo foo="bar" />, scratch)).not.to.throw();
+ rerender();
+ });
+
+ it('should not crash when setting state with cb in constructor', () => {
+ let spy = sinon.spy();
+ class Foo extends Component {
+ constructor(props) {
+ super(props);
+ this.setState({ preact: 'awesome' }, spy);
+ }
+ }
+
+ expect(() => render(<Foo foo="bar" />, scratch)).not.to.throw();
+ rerender();
+ expect(spy).to.not.be.called;
+ });
+
+ it('should not crash when calling forceUpdate with cb in constructor', () => {
+ let spy = sinon.spy();
+ class Foo extends Component {
+ constructor(props) {
+ super(props);
+ this.forceUpdate(spy);
+ }
+ }
+
+ expect(() => render(<Foo foo="bar" />, scratch)).not.to.throw();
+ rerender();
+ expect(spy).to.not.be.called;
+ });
+
+ it('should accurately call nested setState callbacks', () => {
+ let states = [];
+ let finalState;
+ class Foo extends Component {
+ constructor(props) {
+ super(props);
+ this.state = { a: 'b' };
+ }
+
+ componentDidMount() {
+ states.push(this.state);
+ expect(scratch.innerHTML).to.equal('<p>b</p>');
+
+ // eslint-disable-next-line
+ this.setState({ a: 'a' }, () => {
+ states.push(this.state);
+ expect(scratch.innerHTML).to.equal('<p>a</p>');
+
+ this.setState({ a: 'c' }, () => {
+ expect(scratch.innerHTML).to.equal('<p>c</p>');
+ states.push(this.state);
+ });
+ });
+ }
+
+ render() {
+ finalState = this.state;
+ return <p>{this.state.a}</p>;
+ }
+ }
+
+ render(<Foo />, scratch);
+ rerender(); // First setState
+ rerender(); // Second setState
+
+ let [firstState, secondState, thirdState] = states;
+ expect(finalState).to.deep.equal({ a: 'c' });
+ expect(firstState).to.deep.equal({ a: 'b' });
+ expect(secondState).to.deep.equal({ a: 'a' });
+ expect(thirdState).to.deep.equal({ a: 'c' });
+ });
+
+ it('should initialize props & context but not state in Component constructor', () => {
+ // Not initializing state matches React behavior: https://codesandbox.io/s/rml19v8o2q
+ class Foo extends Component {
+ constructor(props, context) {
+ super(props, context);
+ expect(this.props).to.equal(props);
+ expect(this.state).to.deep.equal(undefined);
+ expect(this.context).to.equal(context);
+
+ instance = this;
+ }
+ render(props) {
+ return <div {...props}>Hello</div>;
+ }
+ }
+
+ sinon.spy(Foo.prototype, 'render');
+
+ render(<Foo {...PROPS} />, scratch);
+
+ expect(Foo.prototype.render)
+ .to.have.been.calledOnce.and.to.have.been.calledWithMatch(PROPS, {}, {})
+ .and.to.have.returned(sinon.match({ type: 'div', props: PROPS }));
+ expect(instance.props).to.deep.equal(PROPS);
+ expect(instance.state).to.deep.equal({});
+ expect(instance.context).to.deep.equal({});
+
+ expect(scratch.innerHTML).to.equal('<div foo="bar">Hello</div>');
+ });
+
+ it("should render Component classes that don't pass args into the Component constructor", () => {
+ function Foo() {
+ Component.call(this);
+ instance = this;
+ this.state = STATE;
+ }
+ Foo.prototype.render = sinon.spy((props, state) => (
+ <div {...props}>{state.text}</div>
+ ));
+
+ render(<Foo {...PROPS} />, scratch);
+
+ expect(Foo.prototype.render)
+ .to.have.been.calledOnce.and.to.have.been.calledWithMatch(
+ PROPS,
+ STATE,
+ {}
+ )
+ .and.to.have.returned(sinon.match({ type: 'div', props: PROPS }));
+ expect(instance.props).to.deep.equal(PROPS);
+ expect(instance.state).to.deep.equal(STATE);
+ expect(instance.context).to.deep.equal({});
+
+ expect(scratch.innerHTML).to.equal('<div foo="bar">Hello</div>');
+ });
+
+ it('should also update the current dom', () => {
+ let trigger;
+
+ class A extends Component {
+ constructor(props) {
+ super(props);
+ this.state = { show: false };
+ trigger = this.set = this.set.bind(this);
+ }
+
+ set() {
+ this.setState({ show: true });
+ }
+
+ render() {
+ return this.state.show ? <div>A</div> : null;
+ }
+ }
+
+ const B = () => <p>B</p>;
+
+ render(
+ <div>
+ <A />
+ <B />
+ </div>,
+ scratch
+ );
+ expect(scratch.innerHTML).to.equal('<div><p>B</p></div>');
+
+ trigger();
+ rerender();
+ expect(scratch.innerHTML).to.equal('<div><div>A</div><p>B</p></div>');
+ });
+
+ it('should not orphan children', () => {
+ let triggerC, triggerA;
+ const B = () => <p>B</p>;
+
+ // Component with state which swaps its returned element type
+ class C extends Component {
+ constructor(props) {
+ super(props);
+ this.state = { show: false };
+ triggerC = this.set = this.set.bind(this);
+ }
+
+ set() {
+ this.setState({ show: true });
+ }
+
+ render() {
+ return this.state.show ? <div>data</div> : <p>Loading</p>;
+ }
+ }
+
+ const WrapC = () => <C />;
+
+ class A extends Component {
+ constructor(props) {
+ super(props);
+ this.state = { show: false };
+ triggerA = this.set = this.set.bind(this);
+ }
+
+ set() {
+ this.setState({ show: true });
+ }
+
+ render() {
+ return this.state.show ? <B /> : <WrapC />;
+ }
+ }
+
+ render(<A />, scratch);
+ expect(scratch.innerHTML).to.equal('<p>Loading</p>');
+
+ triggerC();
+ rerender();
+ expect(scratch.innerHTML).to.equal('<div>data</div>');
+
+ triggerA();
+ rerender();
+ expect(scratch.innerHTML).to.equal('<p>B</p>');
+ });
+
+ it("should render components that don't pass args into the Component constructor (unistore pattern)", () => {
+ // Pattern unistore uses for connect: https://git.io/fxRqu
+ function Wrapper() {
+ instance = this;
+ this.state = STATE;
+ this.render = sinon.spy((props, state) => (
+ <div {...props}>{state.text}</div>
+ ));
+ }
+ (Wrapper.prototype = new Component()).constructor = Wrapper;
+
+ render(<Wrapper {...PROPS} />, scratch);
+
+ expect(instance.render)
+ .to.have.been.calledOnce.and.to.have.been.calledWithMatch(
+ PROPS,
+ STATE,
+ {}
+ )
+ .and.to.have.returned(sinon.match({ type: 'div', props: PROPS }));
+ expect(instance.props).to.deep.equal(PROPS);
+ expect(instance.state).to.deep.equal(STATE);
+ expect(instance.context).to.deep.equal({});
+
+ expect(scratch.innerHTML).to.equal('<div foo="bar">Hello</div>');
+ });
+
+ it("should render components that don't call Component constructor", () => {
+ function Foo() {
+ instance = this;
+ this.state = STATE;
+ }
+ Foo.prototype = Object.create(Component);
+ Foo.prototype.render = sinon.spy((props, state) => (
+ <div {...props}>{state.text}</div>
+ ));
+
+ render(<Foo {...PROPS} />, scratch);
+
+ expect(Foo.prototype.render)
+ .to.have.been.calledOnce.and.to.have.been.calledWithMatch(
+ PROPS,
+ STATE,
+ {}
+ )
+ .and.to.have.returned(sinon.match({ type: 'div', props: PROPS }));
+ expect(instance.props).to.deep.equal(PROPS);
+ expect(instance.state).to.deep.equal(STATE);
+ expect(instance.context).to.deep.equal({});
+
+ expect(scratch.innerHTML).to.equal('<div foo="bar">Hello</div>');
+ });
+
+ it("should render components that don't call Component constructor and don't initialize state", () => {
+ function Foo() {
+ instance = this;
+ }
+ Foo.prototype.render = sinon.spy(props => <div {...props}>Hello</div>);
+
+ render(<Foo {...PROPS} />, scratch);
+
+ expect(Foo.prototype.render)
+ .to.have.been.calledOnce.and.to.have.been.calledWithMatch(PROPS, {}, {})
+ .and.to.have.returned(sinon.match({ type: 'div', props: PROPS }));
+ expect(instance.props).to.deep.equal(PROPS);
+ expect(instance.state).to.deep.equal({});
+ expect(instance.context).to.deep.equal({});
+
+ expect(scratch.innerHTML).to.equal('<div foo="bar">Hello</div>');
+ });
+
+ it("should render components that don't inherit from Component", () => {
+ class Foo {
+ constructor() {
+ instance = this;
+ this.state = STATE;
+ }
+ render(props, state) {
+ return <div {...props}>{state.text}</div>;
+ }
+ }
+ sinon.spy(Foo.prototype, 'render');
+
+ render(<Foo {...PROPS} />, scratch);
+
+ expect(Foo.prototype.render)
+ .to.have.been.calledOnce.and.to.have.been.calledWithMatch(
+ PROPS,
+ STATE,
+ {}
+ )
+ .and.to.have.returned(sinon.match({ type: 'div', props: PROPS }));
+ expect(instance.props).to.deep.equal(PROPS);
+ expect(instance.state).to.deep.equal(STATE);
+ expect(instance.context).to.deep.equal({});
+
+ expect(scratch.innerHTML).to.equal('<div foo="bar">Hello</div>');
+ });
+
+ it("should render components that don't inherit from Component (unistore pattern)", () => {
+ // Pattern unistore uses for Provider: https://git.io/fxRqR
+ function Provider() {
+ instance = this;
+ this.state = STATE;
+ }
+ Provider.prototype.render = sinon.spy((props, state) => (
+ <div {...PROPS}>{state.text}</div>
+ ));
+
+ render(<Provider {...PROPS} />, scratch);
+
+ expect(Provider.prototype.render)
+ .to.have.been.calledOnce.and.to.have.been.calledWithMatch(
+ PROPS,
+ STATE,
+ {}
+ )
+ .and.to.have.returned(sinon.match({ type: 'div', props: PROPS }));
+ expect(instance.props).to.deep.equal(PROPS);
+ expect(instance.state).to.deep.equal(STATE);
+ expect(instance.context).to.deep.equal({});
+
+ expect(scratch.innerHTML).to.equal('<div foo="bar">Hello</div>');
+ });
+
+ it("should render components that don't inherit from Component and don't initialize state", () => {
+ class Foo {
+ constructor() {
+ instance = this;
+ }
+ render(props, state) {
+ return <div {...props}>Hello</div>;
+ }
+ }
+ sinon.spy(Foo.prototype, 'render');
+
+ render(<Foo {...PROPS} />, scratch);
+
+ expect(Foo.prototype.render)
+ .to.have.been.calledOnce.and.to.have.been.calledWithMatch(PROPS, {}, {})
+ .and.to.have.returned(sinon.match({ type: 'div', props: PROPS }));
+ expect(instance.props).to.deep.equal(PROPS);
+ expect(instance.state).to.deep.equal({});
+ expect(instance.context).to.deep.equal({});
+
+ expect(scratch.innerHTML).to.equal('<div foo="bar">Hello</div>');
+ });
+
+ it('should render class components that inherit from Component without a render method', () => {
+ class Foo extends Component {
+ constructor(props, context) {
+ super(props, context);
+ instance = this;
+ }
+ }
+
+ sinon.spy(Foo.prototype, 'render');
+
+ render(<Foo {...PROPS} />, scratch);
+
+ expect(Foo.prototype.render)
+ .to.have.been.calledOnce.and.to.have.been.calledWithMatch(PROPS, {}, {})
+ .and.to.have.returned(undefined);
+ expect(instance.props).to.deep.equal(PROPS);
+ expect(instance.state).to.deep.equal({});
+ expect(instance.context).to.deep.equal({});
+
+ expect(scratch.innerHTML).to.equal('');
+ });
+ });
+
+ it('should render string', () => {
+ class StringComponent extends Component {
+ render() {
+ return 'Hi there';
+ }
+ }
+
+ render(<StringComponent />, scratch);
+ expect(scratch.innerHTML).to.equal('Hi there');
+ });
+
+ it('should render number as string', () => {
+ class NumberComponent extends Component {
+ render() {
+ return 42;
+ }
+ }
+
+ render(<NumberComponent />, scratch);
+ expect(scratch.innerHTML).to.equal('42');
+ });
+
+ it('should render null as empty string', () => {
+ class NullComponent extends Component {
+ render() {
+ return null;
+ }
+ }
+
+ render(<NullComponent />, scratch);
+ expect(scratch.innerHTML).to.equal('');
+ });
+
+ // Test for Issue #73
+ it('should remove orphaned elements replaced by Components', () => {
+ class Comp extends Component {
+ render() {
+ return <span>span in a component</span>;
+ }
+ }
+
+ let root;
+ function test(content) {
+ root = render(content, scratch, root);
+ }
+
+ test(<Comp />);
+ test(<div>just a div</div>);
+ test(<Comp />);
+
+ expect(scratch.innerHTML).to.equal('<span>span in a component</span>');
+ });
+
+ // Test for Issue preactjs/preact#176
+ it('should remove children when root changes to text node', () => {
+ /** @type {import('preact').Component} */
+ let comp;
+
+ class Comp extends Component {
+ constructor() {
+ super();
+ comp = this;
+ }
+ render(_, { alt }) {
+ return alt ? 'asdf' : <div>test</div>;
+ }
+ }
+
+ render(<Comp />, scratch);
+
+ comp.setState({ alt: true });
+ comp.forceUpdate();
+ rerender();
+ expect(scratch.innerHTML, 'switching to textnode').to.equal('asdf');
+
+ comp.setState({ alt: false });
+ comp.forceUpdate();
+ rerender();
+ expect(scratch.innerHTML, 'switching to element').to.equal(
+ '<div>test</div>'
+ );
+
+ comp.setState({ alt: true });
+ comp.forceUpdate();
+ rerender();
+ expect(scratch.innerHTML, 'switching to textnode 2').to.equal('asdf');
+ });
+
+ // Test for Issue preactjs/preact#1616
+ it('should maintain order when setting state (that inserts dom-elements)', () => {
+ let add, addTwice, reset;
+ const Entry = props => <div>{props.children}</div>;
+
+ class App extends Component {
+ constructor(props) {
+ super(props);
+
+ this.state = { values: ['abc'] };
+
+ add = this.add = this.add.bind(this);
+ addTwice = this.addTwice = this.addTwice.bind(this);
+ reset = this.reset = this.reset.bind(this);
+ }
+
+ add() {
+ this.setState({ values: [...this.state.values, 'def'] });
+ }
+
+ addTwice() {
+ this.setState({ values: [...this.state.values, 'def', 'ghi'] });
+ }
+
+ reset() {
+ this.setState({ values: ['abc'] });
+ }
+
+ render() {
+ return (
+ <div>
+ {this.state.values.map(v => (
+ <Entry>{v}</Entry>
+ ))}
+ <button>First Button</button>
+ <button>Second Button</button>
+ <button>Third Button</button>
+ </div>
+ );
+ }
+ }
+
+ render(<App />, scratch);
+ expect(scratch.firstChild.innerHTML).to.equal(
+ '<div>abc</div>' +
+ '<button>First Button</button><button>Second Button</button><button>Third Button</button>'
+ );
+
+ add();
+ rerender();
+ expect(scratch.firstChild.innerHTML).to.equal(
+ '<div>abc</div><div>def' +
+ '</div><button>First Button</button><button>Second Button</button><button>Third Button</button>'
+ );
+
+ add();
+ rerender();
+ expect(scratch.firstChild.innerHTML).to.equal(
+ '<div>abc</div><div>def</div><div>def' +
+ '</div><button>First Button</button><button>Second Button</button><button>Third Button</button>'
+ );
+
+ reset();
+ rerender();
+ expect(scratch.firstChild.innerHTML).to.equal(
+ '<div>abc</div>' +
+ '<button>First Button</button><button>Second Button</button><button>Third Button</button>'
+ );
+
+ addTwice();
+ rerender();
+ expect(scratch.firstChild.innerHTML).to.equal(
+ '<div>abc</div><div>def</div><div>ghi' +
+ '</div><button>First Button</button><button>Second Button</button><button>Third Button</button>'
+ );
+ });
+
+ // Test for Issue preactjs/preact#254
+ it('should not recycle common class children with different keys', () => {
+ let idx = 0;
+ let msgs = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H'];
+ let sideEffect = sinon.spy();
+
+ class Comp extends Component {
+ componentWillMount() {
+ this.innerMsg = msgs[idx++ % 8];
+ sideEffect();
+ }
+ render() {
+ return <div>{this.innerMsg}</div>;
+ }
+ }
+ sinon.spy(Comp.prototype, 'componentWillMount');
+
+ let good, bad;
+ class GoodContainer extends Component {
+ constructor(props) {
+ super(props);
+ this.state = { alt: false };
+ good = this;
+ }
+
+ render(_, { alt }) {
+ return (
+ <div>
+ {alt ? null : <Comp key={1} alt={alt} />}
+ {alt ? null : <Comp key={2} alt={alt} />}
+ {alt ? <Comp key={3} alt={alt} /> : null}
+ </div>
+ );
+ }
+ }
+
+ class BadContainer extends Component {
+ constructor(props) {
+ super(props);
+ this.state = { alt: false };
+ bad = this;
+ }
+
+ render(_, { alt }) {
+ return (
+ <div>
+ {alt ? null : <Comp alt={alt} />}
+ {alt ? null : <Comp alt={alt} />}
+ {alt ? <Comp alt={alt} /> : null}
+ </div>
+ );
+ }
+ }
+
+ render(<GoodContainer />, scratch);
+ expect(scratch.textContent, 'new component with key present').to.equal(
+ 'AB'
+ );
+ expect(Comp.prototype.componentWillMount).to.have.been.calledTwice;
+ expect(sideEffect).to.have.been.calledTwice;
+
+ sideEffect.resetHistory();
+ Comp.prototype.componentWillMount.resetHistory();
+ good.setState({ alt: true });
+ rerender();
+ expect(
+ scratch.textContent,
+ 'new component with key present re-rendered'
+ ).to.equal('C');
+ //we are recycling the first 2 components already rendered, just need a new one
+ expect(Comp.prototype.componentWillMount).to.have.been.calledOnce;
+ expect(sideEffect).to.have.been.calledOnce;
+
+ sideEffect.resetHistory();
+ Comp.prototype.componentWillMount.resetHistory();
+ render(<BadContainer />, scratch);
+ expect(scratch.textContent, 'new component without key').to.equal('DE');
+ expect(Comp.prototype.componentWillMount).to.have.been.calledTwice;
+ expect(sideEffect).to.have.been.calledTwice;
+
+ sideEffect.resetHistory();
+ Comp.prototype.componentWillMount.resetHistory();
+ bad.setState({ alt: true });
+ rerender();
+
+ expect(
+ scratch.textContent,
+ 'use null placeholders to detect new component is appended'
+ ).to.equal('F');
+ expect(Comp.prototype.componentWillMount).to.be.calledOnce;
+ expect(sideEffect).to.be.calledOnce;
+ });
+
+ describe('array children', () => {
+ it("should render DOM element's array children", () => {
+ render(<div>{getMixedArray()}</div>, scratch);
+ expect(scratch.firstChild.innerHTML).to.equal(mixedArrayHTML);
+ });
+
+ it("should render Component's array children", () => {
+ const Foo = () => getMixedArray();
+
+ render(<Foo />, scratch);
+
+ expect(scratch.innerHTML).to.equal(mixedArrayHTML);
+ });
+
+ it("should render Fragment's array children", () => {
+ const Foo = () => <Fragment>{getMixedArray()}</Fragment>;
+
+ render(<Foo />, scratch);
+
+ expect(scratch.innerHTML).to.equal(mixedArrayHTML);
+ });
+
+ it('should render sibling array children', () => {
+ const Todo = () => (
+ <ul>
+ <li>A header</li>
+ {['a', 'b'].map(value => (
+ <li>{value}</li>
+ ))}
+ <li>A divider</li>
+ {['c', 'd'].map(value => (
+ <li>{value}</li>
+ ))}
+ <li>A footer</li>
+ </ul>
+ );
+
+ render(<Todo />, scratch);
+
+ let ul = scratch.firstChild;
+ expect(ul.childNodes.length).to.equal(7);
+ expect(ul.childNodes[0].textContent).to.equal('A header');
+ expect(ul.childNodes[1].textContent).to.equal('a');
+ expect(ul.childNodes[2].textContent).to.equal('b');
+ expect(ul.childNodes[3].textContent).to.equal('A divider');
+ expect(ul.childNodes[4].textContent).to.equal('c');
+ expect(ul.childNodes[5].textContent).to.equal('d');
+ expect(ul.childNodes[6].textContent).to.equal('A footer');
+ });
+ });
+
+ describe('props.children', () => {
+ let children;
+
+ let Foo = props => {
+ children = props.children;
+ return <div>{props.children}</div>;
+ };
+
+ let FunctionFoo = props => {
+ children = props.children;
+ return <div>{props.children(2)}</div>;
+ };
+
+ let Bar = () => <span>Bar</span>;
+
+ beforeEach(() => {
+ children = undefined;
+ });
+
+ it('should support passing children as a prop', () => {
+ const Foo = props => <div {...props} />;
+
+ render(
+ <Foo a="b" children={[<span class="bar">bar</span>, '123', 456]} />,
+ scratch
+ );
+
+ expect(scratch.innerHTML).to.equal(
+ '<div a="b"><span class="bar">bar</span>123456</div>'
+ );
+ });
+
+ it('should be ignored when explicit children exist', () => {
+ const Foo = props => <div {...props}>a</div>;
+
+ render(<Foo children={'b'} />, scratch);
+
+ expect(scratch.innerHTML).to.equal('<div>a</div>');
+ });
+
+ it('should be undefined with no child', () => {
+ render(<Foo />, scratch);
+
+ expect(children).to.be.undefined;
+ expect(scratch.innerHTML).to.equal('<div></div>');
+ });
+
+ it('should be null with null as a child', () => {
+ render(<Foo>{null}</Foo>, scratch);
+
+ expect(children).to.be.null;
+ expect(scratch.innerHTML).to.equal('<div></div>');
+ });
+
+ it('should be false with false as a child', () => {
+ render(<Foo>{false}</Foo>, scratch);
+
+ expect(children).to.be.false;
+ expect(scratch.innerHTML).to.equal('<div></div>');
+ });
+
+ it('should be true with true as a child', () => {
+ render(<Foo>{true}</Foo>, scratch);
+
+ expect(children).to.be.true;
+ expect(scratch.innerHTML).to.equal('<div></div>');
+ });
+
+ it('should be a string with a text child', () => {
+ render(<Foo>text</Foo>, scratch);
+
+ expect(children).to.be.a('string');
+ expect(children).to.equal('text');
+ expect(scratch.innerHTML).to.equal('<div>text</div>');
+ });
+
+ it('should be a string with a number child', () => {
+ render(<Foo>1</Foo>, scratch);
+
+ expect(children).to.be.a('string');
+ expect(children).to.equal('1');
+ expect(scratch.innerHTML).to.equal('<div>1</div>');
+ });
+
+ it('should be a VNode with a DOM node child', () => {
+ render(
+ <Foo>
+ <span />
+ </Foo>,
+ scratch
+ );
+
+ expect(children).to.be.an('object');
+ expect(children.type).to.equal('span');
+ expect(scratch.innerHTML).to.equal('<div><span></span></div>');
+ });
+
+ it('should be a VNode with a Component child', () => {
+ render(
+ <Foo>
+ <Bar />
+ </Foo>,
+ scratch
+ );
+
+ expect(children).to.be.an('object');
+ expect(children.type).to.equal(Bar);
+ expect(scratch.innerHTML).to.equal('<div><span>Bar</span></div>');
+ });
+
+ it('should be a function with a function child', () => {
+ const child = num => num.toFixed(2);
+ render(<FunctionFoo>{child}</FunctionFoo>, scratch);
+
+ expect(children).to.be.an('function');
+ expect(children).to.equal(child);
+ expect(scratch.innerHTML).to.equal('<div>2.00</div>');
+ });
+
+ it('should be an array with multiple children', () => {
+ render(
+ <Foo>
+ 0<span />
+ <input />
+ <div />1
+ </Foo>,
+ scratch
+ );
+
+ expect(children).to.be.an('array');
+ expect(children[0]).to.equal('0');
+ expect(children[1].type).to.equal('span');
+ expect(children[2].type).to.equal('input');
+ expect(children[3].type).to.equal('div');
+ expect(children[4]).to.equal('1');
+ expect(scratch.innerHTML).to.equal(
+ `<div>0<span></span><input><div></div>1</div>`
+ );
+ });
+
+ it('should be an array with an array as children', () => {
+ const mixedArray = getMixedArray();
+ render(<Foo>{mixedArray}</Foo>, scratch);
+
+ expect(children).to.be.an('array');
+ expect(children).to.deep.equal(mixedArray);
+ expect(scratch.innerHTML).to.equal(`<div>${mixedArrayHTML}</div>`);
+ });
+
+ it('should not flatten sibling and nested arrays', () => {
+ const list1 = [0, 1];
+ const list2 = [2, 3];
+ const list3 = [4, 5];
+ const list4 = [6, 7];
+ const list5 = [8, 9];
+
+ render(
+ <Foo>
+ {[list1, list2]}
+ {[list3, list4]}
+ {list5}
+ </Foo>,
+ scratch
+ );
+
+ expect(children).to.be.an('array');
+ expect(children).to.deep.equal([[list1, list2], [list3, list4], list5]);
+ expect(scratch.innerHTML).to.equal('<div>0123456789</div>');
+ });
+ });
+
+ describe('High-Order Components', () => {
+ it('should render wrapper HOCs', () => {
+ const text = "We'll throw some happy little limbs on this tree.";
+
+ function withBobRoss(ChildComponent) {
+ return class BobRossIpsum extends Component {
+ getChildContext() {
+ return { text };
+ }
+
+ render(props) {
+ return <ChildComponent {...props} />;
+ }
+ };
+ }
+
+ const PaintSomething = (props, context) => <div>{context.text}</div>;
+ const Paint = withBobRoss(PaintSomething);
+
+ render(<Paint />, scratch);
+ expect(scratch.innerHTML).to.equal(`<div>${text}</div>`);
+ });
+
+ it('should render HOCs with generic children', () => {
+ const text =
+ "Let your imagination just wonder around when you're doing these things.";
+
+ class BobRossProvider extends Component {
+ getChildContext() {
+ return { text };
+ }
+
+ render(props) {
+ return props.children;
+ }
+ }
+
+ function BobRossConsumer(props, context) {
+ return props.children(context.text);
+ }
+
+ const Say = props => <div>{props.text}</div>;
+
+ const Speak = () => (
+ <BobRossProvider>
+ <span>A span</span>
+ <BobRossConsumer>{text => <Say text={text} />}</BobRossConsumer>
+ <span>A final span</span>
+ </BobRossProvider>
+ );
+
+ render(<Speak />, scratch);
+
+ expect(scratch.innerHTML).to.equal(
+ `<span>A span</span><div>${text}</div><span>A final span</span>`
+ );
+ });
+
+ it('should render nested functional components', () => {
+ const PROPS = { foo: 'bar', onBaz: () => {} };
+
+ const Outer = sinon.spy(props => <Inner {...props} />);
+
+ const Inner = sinon.spy(props => <div {...props}>inner</div>);
+
+ render(<Outer {...PROPS} />, scratch);
+
+ expect(Outer)
+ .to.have.been.calledOnce.and.to.have.been.calledWithMatch(PROPS)
+ .and.to.have.returned(
+ sinon.match({
+ type: Inner,
+ props: PROPS
+ })
+ );
+
+ expect(Inner)
+ .to.have.been.calledOnce.and.to.have.been.calledWithMatch(PROPS)
+ .and.to.have.returned(
+ sinon.match({
+ type: 'div',
+ props: { ...PROPS, children: 'inner' }
+ })
+ );
+
+ expect(scratch.innerHTML).to.equal('<div foo="bar">inner</div>');
+ });
+
+ it('should re-render nested functional components', () => {
+ let doRender = null;
+ class Outer extends Component {
+ componentDidMount() {
+ let i = 1;
+ doRender = () => this.setState({ i: ++i });
+ }
+ componentWillUnmount() {}
+ render(props, { i }) {
+ return <Inner i={i} {...props} />;
+ }
+ }
+ sinon.spy(Outer.prototype, 'render');
+ sinon.spy(Outer.prototype, 'componentWillUnmount');
+
+ let j = 0;
+ const Inner = sinon.spy(props => (
+ <div j={++j} {...props}>
+ inner
+ </div>
+ ));
+
+ render(<Outer foo="bar" />, scratch);
+
+ // update & flush
+ doRender();
+ rerender();
+
+ expect(Outer.prototype.componentWillUnmount).not.to.have.been.called;
+
+ expect(Inner).to.have.been.calledTwice;
+
+ expect(Inner.secondCall)
+ .to.have.been.calledWithMatch({ foo: 'bar', i: 2 })
+ .and.to.have.returned(
+ sinon.match({
+ props: {
+ j: 2,
+ i: 2,
+ foo: 'bar'
+ }
+ })
+ );
+
+ expect(getAttributes(scratch.firstElementChild)).to.eql({
+ j: '2',
+ i: '2',
+ foo: 'bar'
+ });
+
+ // update & flush
+ doRender();
+ rerender();
+
+ expect(Inner).to.have.been.calledThrice;
+
+ expect(Inner.thirdCall)
+ .to.have.been.calledWithMatch({ foo: 'bar', i: 3 })
+ .and.to.have.returned(
+ sinon.match({
+ props: {
+ j: 3,
+ i: 3,
+ foo: 'bar'
+ }
+ })
+ );
+
+ expect(getAttributes(scratch.firstElementChild)).to.eql({
+ j: '3',
+ i: '3',
+ foo: 'bar'
+ });
+ });
+
+ it('should re-render nested components', () => {
+ let doRender = null,
+ alt = false;
+
+ class Outer extends Component {
+ componentDidMount() {
+ let i = 1;
+ doRender = () => this.setState({ i: ++i });
+ }
+ componentWillUnmount() {}
+ render(props, { i }) {
+ if (alt) return <div is-alt />;
+ return <Inner i={i} {...props} />;
+ }
+ }
+ sinon.spy(Outer.prototype, 'render');
+ sinon.spy(Outer.prototype, 'componentDidMount');
+ sinon.spy(Outer.prototype, 'componentWillUnmount');
+
+ let j = 0;
+ class Inner extends Component {
+ constructor(...args) {
+ super();
+ }
+ componentWillMount() {}
+ componentDidMount() {}
+ componentWillUnmount() {}
+ render(props) {
+ return (
+ <div j={++j} {...props}>
+ inner
+ </div>
+ );
+ }
+ }
+ sinon.spy(Inner.prototype, 'render');
+ sinon.spy(Inner.prototype, 'componentWillMount');
+ sinon.spy(Inner.prototype, 'componentDidMount');
+ sinon.spy(Inner.prototype, 'componentWillUnmount');
+
+ render(<Outer foo="bar" />, scratch);
+
+ expect(Outer.prototype.componentDidMount).to.have.been.calledOnce;
+
+ // update & flush
+ doRender();
+ rerender();
+
+ expect(Outer.prototype.componentWillUnmount).not.to.have.been.called;
+
+ expect(Inner.prototype.componentWillUnmount).not.to.have.been.called;
+ expect(Inner.prototype.componentWillMount).to.have.been.calledOnce;
+ expect(Inner.prototype.componentDidMount).to.have.been.calledOnce;
+ expect(Inner.prototype.render).to.have.been.calledTwice;
+
+ expect(Inner.prototype.render.secondCall)
+ .to.have.been.calledWithMatch({ foo: 'bar', i: 2 })
+ .and.to.have.returned(
+ sinon.match({
+ props: {
+ j: 2,
+ i: 2,
+ foo: 'bar'
+ }
+ })
+ );
+
+ expect(getAttributes(scratch.firstElementChild)).to.eql({
+ j: '2',
+ i: '2',
+ foo: 'bar'
+ });
+
+ expect(serializeHtml(scratch)).to.equal(
+ sortAttributes('<div foo="bar" j="2" i="2">inner</div>')
+ );
+
+ // update & flush
+ doRender();
+ rerender();
+
+ expect(Inner.prototype.componentWillUnmount).not.to.have.been.called;
+ expect(Inner.prototype.componentWillMount).to.have.been.calledOnce;
+ expect(Inner.prototype.componentDidMount).to.have.been.calledOnce;
+ expect(Inner.prototype.render).to.have.been.calledThrice;
+
+ expect(Inner.prototype.render.thirdCall)
+ .to.have.been.calledWithMatch({ foo: 'bar', i: 3 })
+ .and.to.have.returned(
+ sinon.match({
+ props: {
+ j: 3,
+ i: 3,
+ foo: 'bar'
+ }
+ })
+ );
+
+ expect(getAttributes(scratch.firstElementChild)).to.eql({
+ j: '3',
+ i: '3',
+ foo: 'bar'
+ });
+
+ // update & flush
+ alt = true;
+ doRender();
+ rerender();
+
+ expect(Inner.prototype.componentWillUnmount).to.have.been.calledOnce;
+
+ expect(scratch.innerHTML).to.equal('<div is-alt="true"></div>');
+
+ // update & flush
+ alt = false;
+ doRender();
+ rerender();
+
+ expect(serializeHtml(scratch)).to.equal(
+ sortAttributes('<div foo="bar" j="4" i="5">inner</div>')
+ );
+ });
+
+ it('should resolve intermediary functional component', () => {
+ let ctx = {};
+ class Root extends Component {
+ getChildContext() {
+ return { ctx };
+ }
+ render() {
+ return <Func />;
+ }
+ }
+ const Func = () => <Inner />;
+ class Inner extends Component {
+ componentWillMount() {}
+ componentDidMount() {}
+ componentWillUnmount() {}
+ render() {
+ return <div>inner</div>;
+ }
+ }
+
+ sinon.spy(Inner.prototype, 'componentWillUnmount');
+ sinon.spy(Inner.prototype, 'componentWillMount');
+ sinon.spy(Inner.prototype, 'componentDidMount');
+ sinon.spy(Inner.prototype, 'render');
+
+ render(<Root />, scratch);
+
+ expect(Inner.prototype.componentWillMount).to.have.been.calledOnce;
+ expect(Inner.prototype.componentDidMount).to.have.been.calledOnce;
+ expect(Inner.prototype.componentWillMount).to.have.been.calledBefore(
+ Inner.prototype.componentDidMount
+ );
+
+ render(<asdf />, scratch);
+
+ expect(Inner.prototype.componentWillUnmount).to.have.been.calledOnce;
+ });
+
+ it('should unmount children of high-order components without unmounting parent', () => {
+ let outer,
+ inner2,
+ counter = 0;
+
+ class Outer extends Component {
+ constructor(props, context) {
+ super(props, context);
+ outer = this;
+ this.state = {
+ child: this.props.child
+ };
+ }
+ componentWillUnmount() {}
+ componentWillMount() {}
+ componentDidMount() {}
+ render(_, { child: C }) {
+ return <C />;
+ }
+ }
+ sinon.spy(Outer.prototype, 'componentWillUnmount');
+ sinon.spy(Outer.prototype, 'componentWillMount');
+ sinon.spy(Outer.prototype, 'componentDidMount');
+ sinon.spy(Outer.prototype, 'render');
+
+ class Inner extends Component {
+ componentWillUnmount() {}
+ componentWillMount() {}
+ componentDidMount() {}
+ render() {
+ return h('element' + ++counter);
+ }
+ }
+ sinon.spy(Inner.prototype, 'componentWillUnmount');
+ sinon.spy(Inner.prototype, 'componentWillMount');
+ sinon.spy(Inner.prototype, 'componentDidMount');
+ sinon.spy(Inner.prototype, 'render');
+
+ class Inner2 extends Component {
+ constructor(props, context) {
+ super(props, context);
+ inner2 = this;
+ }
+ componentWillUnmount() {}
+ componentWillMount() {}
+ componentDidMount() {}
+ render() {
+ return h('element' + ++counter);
+ }
+ }
+ sinon.spy(Inner2.prototype, 'componentWillUnmount');
+ sinon.spy(Inner2.prototype, 'componentWillMount');
+ sinon.spy(Inner2.prototype, 'componentDidMount');
+ sinon.spy(Inner2.prototype, 'render');
+
+ render(<Outer child={Inner} />, scratch);
+
+ // outer should only have been mounted once
+ expect(Outer.prototype.componentWillMount, 'outer initial').to.have.been
+ .calledOnce;
+ expect(Outer.prototype.componentDidMount, 'outer initial').to.have.been
+ .calledOnce;
+ expect(Outer.prototype.componentWillUnmount, 'outer initial').not.to.have
+ .been.called;
+
+ // inner should only have been mounted once
+ expect(Inner.prototype.componentWillMount, 'inner initial').to.have.been
+ .calledOnce;
+ expect(Inner.prototype.componentDidMount, 'inner initial').to.have.been
+ .calledOnce;
+ expect(Inner.prototype.componentWillUnmount, 'inner initial').not.to.have
+ .been.called;
+
+ outer.setState({ child: Inner2 });
+ outer.forceUpdate();
+ rerender();
+
+ expect(Inner2.prototype.render).to.have.been.calledOnce;
+
+ // outer should still only have been mounted once
+ expect(Outer.prototype.componentWillMount, 'outer swap').to.have.been
+ .calledOnce;
+ expect(Outer.prototype.componentDidMount, 'outer swap').to.have.been
+ .calledOnce;
+ expect(Outer.prototype.componentWillUnmount, 'outer swap').not.to.have
+ .been.called;
+
+ // inner should only have been mounted once
+ expect(Inner2.prototype.componentWillMount, 'inner2 swap').to.have.been
+ .calledOnce;
+ expect(Inner2.prototype.componentDidMount, 'inner2 swap').to.have.been
+ .calledOnce;
+ expect(Inner2.prototype.componentWillUnmount, 'inner2 swap').not.to.have
+ .been.called;
+
+ inner2.forceUpdate();
+ rerender();
+
+ expect(Inner2.prototype.render, 'inner2 update').to.have.been.calledTwice;
+ expect(Inner2.prototype.componentWillMount, 'inner2 update').to.have.been
+ .calledOnce;
+ expect(Inner2.prototype.componentDidMount, 'inner2 update').to.have.been
+ .calledOnce;
+ expect(Inner2.prototype.componentWillUnmount, 'inner2 update').not.to.have
+ .been.called;
+ });
+
+ it('should remount when swapping between HOC child types', () => {
+ class Outer extends Component {
+ render({ child: Child }) {
+ return <Child />;
+ }
+ }
+
+ class Inner extends Component {
+ componentWillMount() {}
+ componentWillUnmount() {}
+ render() {
+ return <div class="inner">foo</div>;
+ }
+ }
+ sinon.spy(Inner.prototype, 'componentWillMount');
+ sinon.spy(Inner.prototype, 'componentWillUnmount');
+ sinon.spy(Inner.prototype, 'render');
+
+ const InnerFunc = () => <div class="inner-func">bar</div>;
+
+ render(<Outer child={Inner} />, scratch);
+
+ expect(Inner.prototype.componentWillMount, 'initial mount').to.have.been
+ .calledOnce;
+ expect(Inner.prototype.componentWillUnmount, 'initial mount').not.to.have
+ .been.called;
+
+ Inner.prototype.componentWillMount.resetHistory();
+ render(<Outer child={InnerFunc} />, scratch);
+
+ expect(Inner.prototype.componentWillMount, 'unmount').not.to.have.been
+ .called;
+ expect(Inner.prototype.componentWillUnmount, 'unmount').to.have.been
+ .calledOnce;
+
+ Inner.prototype.componentWillUnmount.resetHistory();
+ render(<Outer child={Inner} />, scratch);
+
+ expect(Inner.prototype.componentWillMount, 'remount').to.have.been
+ .calledOnce;
+ expect(Inner.prototype.componentWillUnmount, 'remount').not.to.have.been
+ .called;
+ });
+ });
+
+ describe('Component Nesting', () => {
+ let useIntermediary = false;
+
+ let createComponent = Intermediary => {
+ class C extends Component {
+ componentWillMount() {}
+ render({ children }) {
+ if (!useIntermediary) return children;
+ let I = useIntermediary === true ? Intermediary : useIntermediary;
+ return <I>{children}</I>;
+ }
+ }
+ sinon.spy(C.prototype, 'componentWillMount');
+ sinon.spy(C.prototype, 'render');
+ return C;
+ };
+
+ let createFunction = () => sinon.spy(({ children }) => children);
+
+ let F1 = createFunction();
+ let F2 = createFunction();
+ let F3 = createFunction();
+
+ let C1 = createComponent(F1);
+ let C2 = createComponent(F2);
+ let C3 = createComponent(F3);
+
+ let reset = () =>
+ [C1, C2, C3]
+ .reduce(
+ (acc, c) =>
+ acc.concat(c.prototype.render, c.prototype.componentWillMount),
+ [F1, F2, F3]
+ )
+ .forEach(c => c.resetHistory());
+
+ it('should handle lifecycle for no intermediary in component tree', () => {
+ reset();
+ render(
+ <C1>
+ <C2>
+ <C3>Some Text</C3>
+ </C2>
+ </C1>,
+ scratch
+ );
+
+ expect(C1.prototype.componentWillMount, 'initial mount').to.have.been
+ .calledOnce;
+ expect(C2.prototype.componentWillMount, 'initial mount').to.have.been
+ .calledOnce;
+ expect(C3.prototype.componentWillMount, 'initial mount').to.have.been
+ .calledOnce;
+
+ reset();
+ render(
+ <C1>
+ <C2>Some Text</C2>
+ </C1>,
+ scratch
+ );
+
+ expect(C1.prototype.componentWillMount, 'unmount innermost, C1').not.to
+ .have.been.called;
+ expect(C2.prototype.componentWillMount, 'unmount innermost, C2').not.to
+ .have.been.called;
+
+ reset();
+ render(
+ <C1>
+ <C3>Some Text</C3>
+ </C1>,
+ scratch
+ );
+
+ expect(C1.prototype.componentWillMount, 'swap innermost').not.to.have.been
+ .called;
+ expect(C3.prototype.componentWillMount, 'swap innermost').to.have.been
+ .calledOnce;
+
+ reset();
+ render(
+ <C1>
+ <C2>
+ <C3>Some Text</C3>
+ </C2>
+ </C1>,
+ scratch
+ );
+
+ expect(C1.prototype.componentWillMount, 'inject between, C1').not.to.have
+ .been.called;
+ expect(C2.prototype.componentWillMount, 'inject between, C2').to.have.been
+ .calledOnce;
+ expect(C3.prototype.componentWillMount, 'inject between, C3').to.have.been
+ .calledOnce;
+ });
+
+ it('should handle lifecycle for nested intermediary functional components', () => {
+ useIntermediary = true;
+
+ render(<div />, scratch);
+ reset();
+ render(
+ <C1>
+ <C2>
+ <C3>Some Text</C3>
+ </C2>
+ </C1>,
+ scratch
+ );
+
+ expect(
+ C1.prototype.componentWillMount,
+ 'initial mount w/ intermediary fn, C1'
+ ).to.have.been.calledOnce;
+ expect(
+ C2.prototype.componentWillMount,
+ 'initial mount w/ intermediary fn, C2'
+ ).to.have.been.calledOnce;
+ expect(
+ C3.prototype.componentWillMount,
+ 'initial mount w/ intermediary fn, C3'
+ ).to.have.been.calledOnce;
+
+ reset();
+ render(
+ <C1>
+ <C2>Some Text</C2>
+ </C1>,
+ scratch
+ );
+
+ expect(
+ C1.prototype.componentWillMount,
+ 'unmount innermost w/ intermediary fn, C1'
+ ).not.to.have.been.called;
+ expect(
+ C2.prototype.componentWillMount,
+ 'unmount innermost w/ intermediary fn, C2'
+ ).not.to.have.been.called;
+
+ reset();
+ render(
+ <C1>
+ <C3>Some Text</C3>
+ </C1>,
+ scratch
+ );
+
+ expect(
+ C1.prototype.componentWillMount,
+ 'swap innermost w/ intermediary fn'
+ ).not.to.have.been.called;
+ expect(
+ C3.prototype.componentWillMount,
+ 'swap innermost w/ intermediary fn'
+ ).to.have.been.calledOnce;
+
+ reset();
+ render(
+ <C1>
+ <C2>
+ <C3>Some Text</C3>
+ </C2>
+ </C1>,
+ scratch
+ );
+
+ expect(
+ C1.prototype.componentWillMount,
+ 'inject between, C1 w/ intermediary fn'
+ ).not.to.have.been.called;
+ expect(
+ C2.prototype.componentWillMount,
+ 'inject between, C2 w/ intermediary fn'
+ ).to.have.been.calledOnce;
+ expect(
+ C3.prototype.componentWillMount,
+ 'inject between, C3 w/ intermediary fn'
+ ).to.have.been.calledOnce;
+ });
+
+ it('should render components by depth', () => {
+ let spy = sinon.spy();
+ let update;
+ class Child extends Component {
+ constructor(props) {
+ super(props);
+ update = () => {
+ this.props.update();
+ this.setState({});
+ };
+ }
+
+ render() {
+ spy();
+ let items = [];
+ for (let i = 0; i < this.props.items; i++) items.push(i);
+ return <div>{items.join(',')}</div>;
+ }
+ }
+
+ let i = 0;
+ class Parent extends Component {
+ render() {
+ return <Child items={++i} update={() => this.setState({})} />;
+ }
+ }
+
+ render(<Parent />, scratch);
+ expect(spy).to.be.calledOnce;
+
+ update();
+ rerender();
+ expect(spy).to.be.calledTwice;
+ });
+
+ it('should handle lifecycle for nested intermediary elements', () => {
+ useIntermediary = 'div';
+
+ render(<div />, scratch);
+ reset();
+ render(
+ <C1>
+ <C2>
+ <C3>Some Text</C3>
+ </C2>
+ </C1>,
+ scratch
+ );
+
+ expect(
+ C1.prototype.componentWillMount,
+ 'initial mount w/ intermediary div, C1'
+ ).to.have.been.calledOnce;
+ expect(
+ C2.prototype.componentWillMount,
+ 'initial mount w/ intermediary div, C2'
+ ).to.have.been.calledOnce;
+ expect(
+ C3.prototype.componentWillMount,
+ 'initial mount w/ intermediary div, C3'
+ ).to.have.been.calledOnce;
+
+ reset();
+ render(
+ <C1>
+ <C2>Some Text</C2>
+ </C1>,
+ scratch
+ );
+
+ expect(
+ C1.prototype.componentWillMount,
+ 'unmount innermost w/ intermediary div, C1'
+ ).not.to.have.been.called;
+ expect(
+ C2.prototype.componentWillMount,
+ 'unmount innermost w/ intermediary div, C2'
+ ).not.to.have.been.called;
+
+ reset();
+ render(
+ <C1>
+ <C3>Some Text</C3>
+ </C1>,
+ scratch
+ );
+
+ expect(
+ C1.prototype.componentWillMount,
+ 'swap innermost w/ intermediary div'
+ ).not.to.have.been.called;
+ expect(
+ C3.prototype.componentWillMount,
+ 'swap innermost w/ intermediary div'
+ ).to.have.been.calledOnce;
+
+ reset();
+ render(
+ <C1>
+ <C2>
+ <C3>Some Text</C3>
+ </C2>
+ </C1>,
+ scratch
+ );
+
+ expect(
+ C1.prototype.componentWillMount,
+ 'inject between, C1 w/ intermediary div'
+ ).not.to.have.been.called;
+ expect(
+ C2.prototype.componentWillMount,
+ 'inject between, C2 w/ intermediary div'
+ ).to.have.been.calledOnce;
+ expect(
+ C3.prototype.componentWillMount,
+ 'inject between, C3 w/ intermediary div'
+ ).to.have.been.calledOnce;
+ });
+ });
+
+ it('should set component._vnode._dom when sCU returns false', () => {
+ let parent;
+ class Parent extends Component {
+ render() {
+ parent = this;
+ return <Child />;
+ }
+ }
+
+ let renderChildDiv = false;
+
+ let child;
+ class Child extends Component {
+ shouldComponentUpdate() {
+ return false;
+ }
+ render() {
+ child = this;
+ if (!renderChildDiv) return null;
+ return <div class="child" />;
+ }
+ }
+
+ let app;
+ class App extends Component {
+ render() {
+ app = this;
+ return <Parent />;
+ }
+ }
+
+ // TODO: Consider rewriting test to not rely on internal properties
+ // and instead capture user-facing bug that would occur if this
+ // behavior were broken
+ const getDom = c => ('__v' in c ? c.__v.__e : c._vnode._dom);
+
+ render(<App />, scratch);
+ expect(getDom(child)).to.equalNode(child.base);
+
+ app.forceUpdate();
+ expect(getDom(child)).to.equalNode(child.base);
+
+ parent.setState({});
+ renderChildDiv = true;
+ child.forceUpdate();
+ expect(getDom(child)).to.equalNode(child.base);
+ rerender();
+
+ expect(getDom(child)).to.equalNode(child.base);
+
+ renderChildDiv = false;
+ app.setState({});
+ child.forceUpdate();
+ rerender();
+ expect(getDom(child)).to.equalNode(child.base);
+ });
+
+ // preact/#1323
+ it('should handle hoisted component vnodes without DOM', () => {
+ let x = 0;
+ let mounted = '';
+ let unmounted = '';
+ let updateAppState;
+
+ class X extends Component {
+ constructor(props) {
+ super(props);
+ this.name = `${x++}`;
+ }
+
+ componentDidMount() {
+ mounted += `,${this.name}`;
+ }
+
+ componentWillUnmount() {
+ unmounted += `,${this.name}`;
+ }
+
+ render() {
+ return null;
+ }
+ }
+
+ // Statically create X element
+ const A = <X />;
+
+ class App extends Component {
+ constructor(props) {
+ super(props);
+ this.state = { i: 0 };
+ updateAppState = () => this.setState({ i: this.state.i + 1 });
+ }
+
+ render() {
+ return (
+ <div key={this.state.i}>
+ {A}
+ {A}
+ </div>
+ );
+ }
+ }
+
+ render(<App />, scratch);
+
+ updateAppState();
+ rerender();
+ updateAppState();
+ rerender();
+
+ expect(mounted).to.equal(',0,1,2,3,4,5');
+ expect(unmounted).to.equal(',0,1,2,3');
+ });
+
+ describe('c.base', () => {
+ /* eslint-disable lines-around-comment */
+ /** @type {import('../../src').Component} */
+ let parentDom1;
+ /** @type {import('../../src').Component} */
+ let parent1;
+ /** @type {import('../../src').Component} */
+ let parent2;
+ /** @type {import('../../src').Component} */
+ let maybe;
+ /** @type {import('../../src').Component} */
+ let child;
+ /** @type {import('../../src').Component} */
+ let sibling;
+ /** @type {import('../../src').Component} */
+ let nullInst;
+
+ /** @type {() => void} */
+ let toggleMaybeNull;
+ /** @type {() => void} */
+ let swapChildTag;
+
+ function ParentWithDom(props) {
+ parentDom1 = this;
+ return <div>{props.children}</div>;
+ }
+
+ class Parent1 extends Component {
+ render() {
+ parent1 = this;
+ return this.props.children;
+ }
+ }
+
+ function Parent2(props) {
+ parent2 = this;
+ return props.children;
+ }
+
+ class MaybeNull extends Component {
+ constructor(props) {
+ super(props);
+ maybe = this;
+ this.state = { active: props.active || false };
+ toggleMaybeNull = () =>
+ this.setState(prev => ({
+ active: !prev.active
+ }));
+ }
+ render() {
+ return this.state.active ? <div>maybe</div> : null;
+ }
+ }
+
+ class Child extends Component {
+ constructor(props) {
+ super(props);
+ child = this;
+ this.state = { tagName: 'p' };
+ swapChildTag = () =>
+ this.setState(prev => ({
+ tagName: prev.tagName == 'p' ? 'span' : 'p'
+ }));
+ }
+ render() {
+ return h(this.state.tagName, null, 'child');
+ }
+ }
+
+ function Sibling(props) {
+ sibling = this;
+ return <p />;
+ }
+
+ function Null() {
+ nullInst = this;
+ return null;
+ }
+
+ afterEach(() => {
+ parentDom1 = null;
+ parent1 = null;
+ parent2 = null;
+ child = null;
+ sibling = null;
+ });
+
+ it('should keep c.base up to date if a nested child component changes DOM nodes', () => {
+ render(
+ <ParentWithDom>
+ <Parent1>
+ <Parent2>
+ <Child />
+ </Parent2>
+ </Parent1>
+ </ParentWithDom>,
+ scratch
+ );
+
+ expect(scratch.innerHTML).to.equal('<div><p>child</p></div>');
+ expect(child.base).to.equalNode(scratch.firstChild.firstChild);
+ expect(parent2.base).to.equalNode(child.base);
+ expect(parent1.base).to.equalNode(child.base);
+ expect(parentDom1.base).to.equalNode(scratch.firstChild);
+
+ swapChildTag();
+ rerender();
+
+ expect(scratch.innerHTML).to.equal('<div><span>child</span></div>');
+ expect(child.base).to.equalNode(scratch.firstChild.firstChild);
+ expect(parent2.base).to.equalNode(child.base);
+ expect(parent1.base).to.equalNode(child.base);
+ expect(parentDom1.base).to.equalNode(scratch.firstChild);
+ });
+
+ it('should not update sibling c.base if child component changes DOM nodes', () => {
+ let s1 = {},
+ s2 = {},
+ s3 = {},
+ s4 = {};
+
+ render(
+ <Fragment>
+ <ParentWithDom>
+ <Parent1>
+ <Parent2>
+ <Child />
+ <Sibling ref={s1} />
+ </Parent2>
+ <Sibling ref={s2} />
+ </Parent1>
+ <Sibling ref={s3} />
+ </ParentWithDom>
+ <Sibling ref={s4} />
+ </Fragment>,
+ scratch
+ );
+
+ expect(scratch.innerHTML).to.equal(
+ '<div><p>child</p><p></p><p></p><p></p></div><p></p>'
+ );
+ expect(child.base).to.equalNode(scratch.firstChild.firstChild);
+ expect(parent2.base).to.equalNode(child.base);
+ expect(parent1.base).to.equalNode(child.base);
+ expect(parentDom1.base).to.equalNode(scratch.firstChild);
+ expect(s1.current.base).to.equalNode(scratch.firstChild.childNodes[1]);
+ expect(s2.current.base).to.equalNode(scratch.firstChild.childNodes[2]);
+ expect(s3.current.base).to.equalNode(scratch.firstChild.childNodes[3]);
+ expect(s4.current.base).to.equalNode(scratch.lastChild);
+
+ swapChildTag();
+ rerender();
+
+ expect(scratch.innerHTML).to.equal(
+ '<div><span>child</span><p></p><p></p><p></p></div><p></p>'
+ );
+ expect(child.base).to.equalNode(scratch.firstChild.firstChild);
+ expect(parent2.base).to.equalNode(child.base);
+ expect(parent1.base).to.equalNode(child.base);
+ expect(parentDom1.base).to.equalNode(scratch.firstChild);
+ expect(s1.current.base).to.equalNode(scratch.firstChild.childNodes[1]);
+ expect(s2.current.base).to.equalNode(scratch.firstChild.childNodes[2]);
+ expect(s3.current.base).to.equalNode(scratch.firstChild.childNodes[3]);
+ expect(s4.current.base).to.equalNode(scratch.lastChild);
+ });
+
+ it('should not update parent c.base if child component changes DOM nodes and it is not first child component', () => {
+ render(
+ <Parent1>
+ <Sibling />
+ <Child />
+ </Parent1>,
+ scratch
+ );
+
+ expect(scratch.innerHTML).to.equal('<p></p><p>child</p>');
+ expect(child.base).to.equalNode(scratch.lastChild);
+ expect(sibling.base).to.equalNode(scratch.firstChild);
+ expect(parent1.base).to.equalNode(sibling.base);
+
+ swapChildTag();
+ rerender();
+
+ expect(scratch.innerHTML).to.equal('<p></p><span>child</span>');
+ expect(child.base).to.equalNode(scratch.lastChild);
+ expect(sibling.base).to.equalNode(scratch.firstChild);
+ expect(parent1.base).to.equalNode(sibling.base);
+ });
+
+ it('should update parent c.base if child component changes DOM nodes and it is first non-null child component', () => {
+ render(
+ <Parent1>
+ <Null />
+ <Child />
+ <Sibling />
+ </Parent1>,
+ scratch
+ );
+
+ expect(scratch.innerHTML).to.equal('<p>child</p><p></p>');
+ expect(nullInst.base).to.equalNode(null);
+ expect(child.base).to.equalNode(scratch.firstChild);
+ expect(sibling.base).to.equalNode(scratch.lastChild);
+ expect(parent1.base).to.equalNode(child.base);
+
+ swapChildTag();
+ rerender();
+
+ expect(scratch.innerHTML).to.equal('<span>child</span><p></p>');
+ expect(nullInst.base).to.equalNode(null);
+ expect(child.base).to.equalNode(scratch.firstChild);
+ expect(sibling.base).to.equalNode(scratch.lastChild);
+ expect(parent1.base).to.equalNode(child.base);
+ });
+
+ it('should not update parent c.base if child component changes DOM nodes and a parent is not first child component', () => {
+ render(
+ <ParentWithDom>
+ <Parent1>
+ <Sibling />
+ <Parent2>
+ <Child />
+ </Parent2>
+ </Parent1>
+ </ParentWithDom>,
+ scratch
+ );
+
+ expect(scratch.innerHTML).to.equal('<div><p></p><p>child</p></div>');
+ expect(child.base).to.equalNode(scratch.firstChild.lastChild);
+ expect(parent2.base).to.equalNode(child.base);
+ expect(sibling.base).to.equalNode(scratch.firstChild.firstChild);
+ expect(parent1.base).to.equalNode(sibling.base);
+ expect(parentDom1.base).to.equalNode(scratch.firstChild);
+
+ swapChildTag();
+ rerender();
+
+ expect(scratch.innerHTML).to.equal(
+ '<div><p></p><span>child</span></div>'
+ );
+ expect(child.base).to.equalNode(scratch.firstChild.lastChild);
+ expect(parent2.base).to.equalNode(child.base);
+ expect(sibling.base).to.equalNode(scratch.firstChild.firstChild);
+ expect(parent1.base).to.equalNode(sibling.base);
+ expect(parentDom1.base).to.equalNode(scratch.firstChild);
+ });
+
+ it('should update parent c.base if first child becomes null', () => {
+ render(
+ <Parent1>
+ <MaybeNull active />
+ <Parent2>
+ <Child />
+ </Parent2>
+ </Parent1>,
+ scratch
+ );
+
+ expect(scratch.innerHTML).to.equal([div('maybe'), p('child')].join(''));
+ expect(maybe.base).to.equalNode(
+ scratch.firstChild,
+ 'initial - maybe.base'
+ );
+ expect(child.base).to.equalNode(
+ scratch.lastChild,
+ 'initial - child.base'
+ );
+ expect(parent2.base).to.equalNode(child.base, 'initial - parent2.base');
+ expect(parent1.base).to.equalNode(maybe.base, 'initial - parent1.base');
+
+ toggleMaybeNull();
+ rerender();
+
+ expect(scratch.innerHTML).to.equal(p('child'));
+ expect(maybe.base).to.equalNode(null, 'toggleMaybe - maybe.base');
+ expect(child.base).to.equalNode(
+ scratch.firstChild,
+ 'toggleMaybe - child.base'
+ );
+ expect(parent2.base).to.equalNode(
+ child.base,
+ 'toggleMaybe - parent2.base'
+ );
+ expect(parent1.base).to.equalNode(
+ child.base,
+ 'toggleMaybe - parent1.base'
+ );
+
+ swapChildTag();
+ rerender();
+
+ expect(scratch.innerHTML).to.equal(span('child'));
+ expect(maybe.base).to.equalNode(null, 'swapChildTag - maybe.base');
+ expect(child.base).to.equalNode(
+ scratch.firstChild,
+ 'swapChildTag - child.base'
+ );
+ expect(parent2.base).to.equalNode(
+ child.base,
+ 'swapChildTag - parent2.base'
+ );
+ expect(parent1.base).to.equalNode(
+ child.base,
+ 'swapChildTag - parent1.base'
+ );
+ });
+
+ it('should update parent c.base if first child becomes non-null', () => {
+ render(
+ <Parent1>
+ <MaybeNull />
+ <Parent2>
+ <Child />
+ </Parent2>
+ </Parent1>,
+ scratch
+ );
+
+ expect(scratch.innerHTML).to.equal(p('child'));
+ expect(maybe.base).to.equalNode(null, 'initial - maybe.base');
+ expect(child.base).to.equalNode(
+ scratch.firstChild,
+ 'initial - child.base'
+ );
+ expect(parent2.base).to.equalNode(child.base, 'initial - parent2.base');
+ expect(parent1.base).to.equalNode(child.base, 'initial - parent1.base');
+
+ swapChildTag();
+ rerender();
+
+ expect(scratch.innerHTML).to.equal(span('child'));
+ expect(maybe.base).to.equalNode(null, 'swapChildTag - maybe.base');
+ expect(child.base).to.equalNode(
+ scratch.firstChild,
+ 'swapChildTag - child.base'
+ );
+ expect(parent2.base).to.equalNode(
+ child.base,
+ 'swapChildTag - parent2.base'
+ );
+ expect(parent1.base).to.equalNode(
+ child.base,
+ 'swapChildTag - parent1.base'
+ );
+
+ toggleMaybeNull();
+ rerender();
+
+ expect(scratch.innerHTML).to.equal(
+ [div('maybe'), span('child')].join('')
+ );
+ expect(maybe.base).to.equalNode(
+ scratch.firstChild,
+ 'toggleMaybe - maybe.base'
+ );
+ expect(child.base).to.equalNode(
+ scratch.lastChild,
+ 'toggleMaybe - child.base'
+ );
+ expect(parent2.base).to.equalNode(
+ child.base,
+ 'toggleMaybe - parent2.base'
+ );
+ expect(parent1.base).to.equalNode(
+ maybe.base,
+ 'toggleMaybe - parent1.base'
+ );
+ });
+
+ it('should update parent c.base if first non-null child becomes null with multiple null siblings', () => {
+ render(
+ <Parent1>
+ <Null />
+ <Null />
+ <Parent2>
+ <MaybeNull active />
+ <Child />
+ </Parent2>
+ </Parent1>,
+ scratch
+ );
+
+ expect(scratch.innerHTML).to.equal([div('maybe'), p('child')].join(''));
+ expect(maybe.base).to.equalNode(
+ scratch.firstChild,
+ 'initial - maybe.base'
+ );
+ expect(child.base).to.equalNode(
+ scratch.lastChild,
+ 'initial - child.base'
+ );
+ expect(parent2.base).to.equalNode(maybe.base, 'initial - parent2.base');
+ expect(parent1.base).to.equalNode(maybe.base, 'initial - parent1.base');
+
+ toggleMaybeNull();
+ rerender();
+
+ expect(scratch.innerHTML).to.equal(p('child'));
+ expect(maybe.base).to.equalNode(null, 'toggleMaybe - maybe.base');
+ expect(child.base).to.equalNode(
+ scratch.firstChild,
+ 'toggleMaybe - child.base'
+ );
+ expect(parent2.base).to.equalNode(
+ child.base,
+ 'toggleMaybe - parent2.base'
+ );
+ expect(parent1.base).to.equalNode(
+ child.base,
+ 'toggleMaybe - parent1.base'
+ );
+
+ swapChildTag();
+ rerender();
+
+ expect(scratch.innerHTML).to.equal(span('child'));
+ expect(maybe.base).to.equalNode(null, 'swapChildTag - maybe.base');
+ expect(child.base).to.equalNode(
+ scratch.firstChild,
+ 'swapChildTag - child.base'
+ );
+ expect(parent2.base).to.equalNode(
+ child.base,
+ 'swapChildTag - parent2.base'
+ );
+ expect(parent1.base).to.equalNode(
+ child.base,
+ 'swapChildTag - parent1.base'
+ );
+ });
+
+ it('should update parent c.base if a null child returns DOM with multiple null siblings', () => {
+ render(
+ <Parent1>
+ <Null />
+ <Null />
+ <Parent2>
+ <MaybeNull />
+ <Child />
+ </Parent2>
+ </Parent1>,
+ scratch
+ );
+
+ expect(scratch.innerHTML).to.equal(p('child'));
+ expect(maybe.base).to.equalNode(null, 'initial - maybe.base');
+ expect(child.base).to.equalNode(
+ scratch.firstChild,
+ 'initial - child.base'
+ );
+ expect(parent2.base).to.equalNode(child.base, 'initial - parent2.base');
+ expect(parent1.base).to.equalNode(child.base, 'initial - parent1.base');
+
+ swapChildTag();
+ rerender();
+
+ expect(scratch.innerHTML).to.equal(span('child'));
+ expect(maybe.base).to.equalNode(null, 'swapChildTag - maybe.base');
+ expect(child.base).to.equalNode(
+ scratch.firstChild,
+ 'swapChildTag - child.base'
+ );
+ expect(parent2.base).to.equalNode(
+ child.base,
+ 'swapChildTag - parent2.base'
+ );
+ expect(parent1.base).to.equalNode(
+ child.base,
+ 'swapChildTag - parent1.base'
+ );
+
+ toggleMaybeNull();
+ rerender();
+
+ expect(scratch.innerHTML).to.equal(
+ [div('maybe'), span('child')].join('')
+ );
+ expect(maybe.base).to.equalNode(
+ scratch.firstChild,
+ 'toggleMaybe - maybe.base'
+ );
+ expect(child.base).to.equalNode(
+ scratch.lastChild,
+ 'toggleMaybe - child.base'
+ );
+ expect(parent2.base).to.equalNode(
+ maybe.base,
+ 'toggleMaybe - parent2.base'
+ );
+ expect(parent1.base).to.equalNode(
+ maybe.base,
+ 'toggleMaybe - parent1.base'
+ );
+ });
+
+ it('should update parent c.base to null if last child becomes null', () => {
+ let fragRef = {};
+ render(
+ <Fragment ref={fragRef}>
+ <Parent1>
+ <Null />
+ <Null />
+ <Parent2>
+ <MaybeNull active />
+ </Parent2>
+ <Null />
+ </Parent1>
+ <Child />
+ </Fragment>,
+ scratch
+ );
+
+ expect(scratch.innerHTML).to.equal([div('maybe'), p('child')].join(''));
+ expect(maybe.base).to.equalNode(
+ scratch.firstChild,
+ 'initial - maybe.base'
+ );
+ expect(child.base).to.equalNode(
+ scratch.lastChild,
+ 'initial - child.base'
+ );
+ expect(parent2.base).to.equalNode(maybe.base, 'initial - parent2.base');
+ expect(parent1.base).to.equalNode(maybe.base, 'initial - parent1.base');
+ expect(fragRef.current.base).to.equalNode(
+ maybe.base,
+ 'initial - fragRef.current.base'
+ );
+
+ toggleMaybeNull();
+ rerender();
+
+ expect(scratch.innerHTML).to.equal(p('child'));
+ expect(maybe.base).to.equalNode(null, 'toggleMaybe - maybe.base');
+ expect(child.base).to.equalNode(
+ scratch.firstChild,
+ 'toggleMaybe - child.base'
+ );
+ expect(parent2.base).to.equalNode(
+ maybe.base,
+ 'toggleMaybe - parent2.base'
+ );
+ expect(parent1.base).to.equalNode(
+ maybe.base,
+ 'toggleMaybe - parent1.base'
+ );
+ expect(fragRef.current.base).to.equalNode(
+ child.base,
+ 'toggleMaybe - fragRef.current.base'
+ );
+ });
+
+ it('should update parent c.base if last child returns dom', () => {
+ let fragRef = {};
+ render(
+ <Fragment ref={fragRef}>
+ <Parent1>
+ <Null />
+ <Null />
+ <Parent2>
+ <MaybeNull />
+ </Parent2>
+ <Null />
+ </Parent1>
+ <Child />
+ </Fragment>,
+ scratch
+ );
+
+ expect(scratch.innerHTML).to.equal(p('child'));
+ expect(maybe.base).to.equalNode(null, 'initial - maybe.base');
+ expect(child.base).to.equalNode(
+ scratch.firstChild,
+ 'initial - child.base'
+ );
+ expect(parent2.base).to.equalNode(maybe.base, 'initial - parent2.base');
+ expect(parent1.base).to.equalNode(maybe.base, 'initial - parent1.base');
+ expect(fragRef.current.base).to.equalNode(
+ child.base,
+ 'initial - fragRef.current.base'
+ );
+
+ toggleMaybeNull();
+ rerender();
+
+ expect(scratch.innerHTML).to.equal([div('maybe'), p('child')].join(''));
+ expect(maybe.base).to.equalNode(
+ scratch.firstChild,
+ 'toggleMaybe - maybe.base'
+ );
+ expect(child.base).to.equalNode(
+ scratch.lastChild,
+ 'toggleMaybe - child.base'
+ );
+ expect(parent2.base).to.equalNode(maybe.base, 'initial - parent2.base');
+ expect(parent1.base).to.equalNode(
+ maybe.base,
+ 'toggleMaybe - parent1.base'
+ );
+ expect(fragRef.current.base).to.equalNode(
+ maybe.base,
+ 'toggleMaybe - fragRef.current.base'
+ );
+ });
+
+ it('should not update parent if it is a DOM node', () => {
+ let divVNode = (
+ <div>
+ <Child />
+ </div>
+ );
+ render(divVNode, scratch);
+
+ // TODO: Consider rewriting test to not rely on internal properties
+ // and instead capture user-facing bug that would occur if this
+ // behavior were broken
+ const domProp = '__e' in divVNode ? '__e' : '_dom';
+
+ expect(scratch.innerHTML).to.equal('<div><p>child</p></div>');
+ expect(divVNode[domProp]).to.equalNode(
+ scratch.firstChild,
+ 'initial - divVNode._dom'
+ );
+ expect(child.base).to.equalNode(
+ scratch.firstChild.firstChild,
+ 'initial - child.base'
+ );
+
+ swapChildTag();
+ rerender();
+
+ expect(scratch.innerHTML).to.equal('<div><span>child</span></div>');
+ expect(divVNode[domProp]).to.equalNode(
+ scratch.firstChild,
+ 'swapChildTag - divVNode._dom'
+ );
+ expect(child.base).to.equalNode(
+ scratch.firstChild.firstChild,
+ 'swapChildTag - child.base'
+ );
+ });
+ });
+
+ describe('setState', () => {
+ it('should not error if called on an unmounted component', () => {
+ /** @type {() => void} */
+ let increment;
+
+ class Foo extends Component {
+ constructor(props) {
+ super(props);
+ this.state = { count: 0 };
+ increment = () => this.setState({ count: this.state.count + 1 });
+ }
+ render(props, state) {
+ return <div>{state.count}</div>;
+ }
+ }
+
+ render(<Foo />, scratch);
+ expect(scratch.innerHTML).to.equal('<div>0</div>');
+
+ increment();
+ rerender();
+ expect(scratch.innerHTML).to.equal('<div>1</div>');
+
+ render(null, scratch);
+ expect(scratch.innerHTML).to.equal('');
+
+ expect(() => increment()).to.not.throw();
+ expect(() => rerender()).to.not.throw();
+ expect(scratch.innerHTML).to.equal('');
+ });
+
+ it('setState callbacks should have latest state, even when called in render', () => {
+ let callbackState;
+ let i = 0;
+
+ class Foo extends Component {
+ constructor(props) {
+ super(props);
+ this.state = { foo: 'bar' };
+ }
+ render() {
+ // So we don't get infinite loop
+ if (i++ === 0) {
+ this.setState({ foo: 'baz' }, () => {
+ callbackState = this.state;
+ });
+ }
+ return String(this.state.foo);
+ }
+ }
+
+ render(<Foo />, scratch);
+ expect(scratch.innerHTML).to.equal('bar');
+
+ rerender();
+ expect(scratch.innerHTML).to.equal('baz');
+ expect(callbackState).to.deep.equal({ foo: 'baz' });
+ });
+
+ // #2716
+ it('should work with readonly state', () => {
+ let update;
+ class Foo extends Component {
+ constructor(props) {
+ super(props);
+ this.state = { foo: 'bar' };
+ update = () =>
+ this.setState(prev => {
+ Object.defineProperty(prev, 'foo', {
+ writable: false
+ });
+
+ return prev;
+ });
+ }
+
+ render() {
+ return <div />;
+ }
+ }
+
+ render(<Foo />, scratch);
+ expect(() => {
+ update();
+ rerender();
+ }).to.not.throw();
+ });
+ });
+
+ describe('forceUpdate', () => {
+ it('should not error if called on an unmounted component', () => {
+ /** @type {() => void} */
+ let forceUpdate;
+
+ class Foo extends Component {
+ constructor(props) {
+ super(props);
+ forceUpdate = () => this.forceUpdate();
+ }
+ render(props, state) {
+ return <div>Hello</div>;
+ }
+ }
+
+ render(<Foo />, scratch);
+ expect(scratch.innerHTML).to.equal('<div>Hello</div>');
+
+ render(null, scratch);
+ expect(scratch.innerHTML).to.equal('');
+
+ expect(() => forceUpdate()).to.not.throw();
+ expect(() => rerender()).to.not.throw();
+ expect(scratch.innerHTML).to.equal('');
+ });
+
+ it('should update old dom on forceUpdate in a lifecycle', () => {
+ let i = 0;
+ class App extends Component {
+ componentWillReceiveProps() {
+ this.forceUpdate();
+ }
+ render() {
+ if (i++ == 0) return <div>foo</div>;
+ return <div>bar</div>;
+ }
+ }
+
+ render(<App />, scratch);
+ render(<App />, scratch);
+
+ expect(scratch.innerHTML).to.equal('<div>bar</div>');
+ });
+ });
+});
diff --git a/preact/test/browser/context.test.js b/preact/test/browser/context.test.js
new file mode 100644
index 0000000..ce3a57a
--- /dev/null
+++ b/preact/test/browser/context.test.js
@@ -0,0 +1,237 @@
+import { createElement, render, Component, Fragment } from 'preact';
+import { setupScratch, teardown } from '../_util/helpers';
+
+/** @jsx createElement */
+
+describe('context', () => {
+ let scratch;
+
+ beforeEach(() => {
+ scratch = setupScratch();
+ });
+
+ afterEach(() => {
+ teardown(scratch);
+ });
+
+ it('should pass context to grandchildren', () => {
+ const CONTEXT = { a: 'a' };
+ const PROPS = { b: 'b' };
+ // let inner;
+
+ class Outer extends Component {
+ getChildContext() {
+ return CONTEXT;
+ }
+ render(props) {
+ return (
+ <div>
+ <Inner {...props} />
+ </div>
+ );
+ }
+ }
+ sinon.spy(Outer.prototype, 'getChildContext');
+
+ class Inner extends Component {
+ // constructor() {
+ // super();
+ // inner = this;
+ // }
+ shouldComponentUpdate() {
+ return true;
+ }
+ componentWillReceiveProps() {}
+ componentWillUpdate() {}
+ componentDidUpdate() {}
+ render(props, state, context) {
+ return <div>{context && context.a}</div>;
+ }
+ }
+ sinon.spy(Inner.prototype, 'shouldComponentUpdate');
+ sinon.spy(Inner.prototype, 'componentWillReceiveProps');
+ sinon.spy(Inner.prototype, 'componentWillUpdate');
+ sinon.spy(Inner.prototype, 'componentDidUpdate');
+ sinon.spy(Inner.prototype, 'render');
+
+ render(<Outer />, scratch);
+
+ expect(Outer.prototype.getChildContext).to.have.been.calledOnce;
+
+ // initial render does not invoke anything but render():
+ expect(Inner.prototype.render).to.have.been.calledWith({}, {}, CONTEXT);
+
+ CONTEXT.foo = 'bar';
+ render(<Outer {...PROPS} />, scratch);
+
+ expect(Outer.prototype.getChildContext).to.have.been.calledTwice;
+
+ expect(
+ Inner.prototype.shouldComponentUpdate
+ ).to.have.been.calledOnce.and.calledWith(PROPS, {}, CONTEXT);
+ expect(Inner.prototype.componentWillReceiveProps).to.have.been.calledWith(
+ PROPS,
+ CONTEXT
+ );
+ expect(Inner.prototype.componentWillUpdate).to.have.been.calledWith(
+ PROPS,
+ {},
+ CONTEXT
+ );
+ expect(Inner.prototype.componentDidUpdate).to.have.been.calledWith(
+ {},
+ {},
+ undefined
+ );
+ expect(Inner.prototype.render).to.have.been.calledWith(PROPS, {}, CONTEXT);
+
+ /* Future:
+ * Newly created context objects are *not* currently cloned.
+ * This test checks that they *are* cloned.
+ */
+ // Inner.prototype.render.resetHistory();
+ // CONTEXT.foo = 'baz';
+ // inner.forceUpdate();
+ // expect(Inner.prototype.render).to.have.been.calledWith(PROPS, {}, { a:'a', foo:'bar' });
+ });
+
+ it('should pass context to direct children', () => {
+ const CONTEXT = { a: 'a' };
+ const PROPS = { b: 'b' };
+
+ class Outer extends Component {
+ getChildContext() {
+ return CONTEXT;
+ }
+ render(props) {
+ return <Inner {...props} />;
+ }
+ }
+ sinon.spy(Outer.prototype, 'getChildContext');
+
+ class Inner extends Component {
+ shouldComponentUpdate() {
+ return true;
+ }
+ componentWillReceiveProps() {}
+ componentWillUpdate() {}
+ componentDidUpdate() {}
+ render(props, state, context) {
+ return <div>{context && context.a}</div>;
+ }
+ }
+ sinon.spy(Inner.prototype, 'shouldComponentUpdate');
+ sinon.spy(Inner.prototype, 'componentWillReceiveProps');
+ sinon.spy(Inner.prototype, 'componentWillUpdate');
+ sinon.spy(Inner.prototype, 'componentDidUpdate');
+ sinon.spy(Inner.prototype, 'render');
+
+ render(<Outer />, scratch);
+
+ expect(Outer.prototype.getChildContext).to.have.been.calledOnce;
+
+ // initial render does not invoke anything but render():
+ expect(Inner.prototype.render).to.have.been.calledWith({}, {}, CONTEXT);
+
+ CONTEXT.foo = 'bar';
+ render(<Outer {...PROPS} />, scratch);
+
+ expect(Outer.prototype.getChildContext).to.have.been.calledTwice;
+
+ expect(
+ Inner.prototype.shouldComponentUpdate
+ ).to.have.been.calledOnce.and.calledWith(PROPS, {}, CONTEXT);
+ expect(Inner.prototype.componentWillReceiveProps).to.have.been.calledWith(
+ PROPS,
+ CONTEXT
+ );
+ expect(Inner.prototype.componentWillUpdate).to.have.been.calledWith(
+ PROPS,
+ {},
+ CONTEXT
+ );
+ expect(Inner.prototype.componentDidUpdate).to.have.been.calledWith(
+ {},
+ {},
+ undefined
+ );
+ expect(Inner.prototype.render).to.have.been.calledWith(PROPS, {}, CONTEXT);
+
+ // make sure render() could make use of context.a
+ expect(Inner.prototype.render).to.have.returned(
+ sinon.match({ props: { children: 'a' } })
+ );
+ });
+
+ it('should preserve existing context properties when creating child contexts', () => {
+ let outerContext = { outer: true },
+ innerContext = { inner: true };
+ class Outer extends Component {
+ getChildContext() {
+ return { outerContext };
+ }
+ render() {
+ return (
+ <div>
+ <Inner />
+ </div>
+ );
+ }
+ }
+
+ class Inner extends Component {
+ getChildContext() {
+ return { innerContext };
+ }
+ render() {
+ return <InnerMost />;
+ }
+ }
+
+ class InnerMost extends Component {
+ render() {
+ return <strong>test</strong>;
+ }
+ }
+
+ sinon.spy(Inner.prototype, 'render');
+ sinon.spy(InnerMost.prototype, 'render');
+
+ render(<Outer />, scratch);
+
+ expect(Inner.prototype.render).to.have.been.calledWith(
+ {},
+ {},
+ { outerContext }
+ );
+ expect(InnerMost.prototype.render).to.have.been.calledWith(
+ {},
+ {},
+ { outerContext, innerContext }
+ );
+ });
+
+ it('should pass context through Fragments', () => {
+ const context = { foo: 'bar' };
+
+ const Foo = sinon.spy(() => <div />);
+
+ class Wrapper extends Component {
+ getChildContext() {
+ return context;
+ }
+
+ render() {
+ return (
+ <Fragment>
+ <Foo />
+ <Foo />
+ </Fragment>
+ );
+ }
+ }
+
+ render(<Wrapper />, scratch);
+ expect(Foo.args[0][1]).to.deep.equal(context);
+ });
+});
diff --git a/preact/test/browser/createContext.test.js b/preact/test/browser/createContext.test.js
new file mode 100644
index 0000000..092a2ba
--- /dev/null
+++ b/preact/test/browser/createContext.test.js
@@ -0,0 +1,931 @@
+import { setupRerender, act } from 'preact/test-utils';
+import {
+ createElement,
+ render,
+ Component,
+ createContext,
+ Fragment
+} from 'preact';
+import { setupScratch, teardown } from '../_util/helpers';
+
+/** @jsx createElement */
+
+describe('createContext', () => {
+ let scratch;
+ let rerender;
+
+ beforeEach(() => {
+ scratch = setupScratch();
+ rerender = setupRerender();
+ });
+
+ afterEach(() => {
+ teardown(scratch);
+ });
+
+ it('should pass context to a consumer', () => {
+ const { Provider, Consumer } = createContext();
+ const CONTEXT = { a: 'a' };
+
+ let receivedContext;
+
+ class Inner extends Component {
+ render(props) {
+ return <div>{props.a}</div>;
+ }
+ }
+
+ sinon.spy(Inner.prototype, 'render');
+
+ render(
+ <Provider value={CONTEXT}>
+ <div>
+ <Consumer>
+ {data => {
+ receivedContext = data;
+ return <Inner {...data} />;
+ }}
+ </Consumer>
+ </div>
+ </Provider>,
+ scratch
+ );
+
+ // initial render does not invoke anything but render():
+ expect(Inner.prototype.render).to.have.been.calledWithMatch(CONTEXT);
+ expect(receivedContext).to.equal(CONTEXT);
+ expect(scratch.innerHTML).to.equal('<div><div>a</div></div>');
+ });
+
+ // This optimization helps
+ // to prevent a Provider from rerendering the children, this means
+ // we only propagate to children.
+ // Strict equal vnode optimization
+ it('skips referentially equal children to Provider', () => {
+ const { Provider, Consumer } = createContext();
+ let set,
+ renders = 0;
+ const Layout = ({ children }) => {
+ renders++;
+ return children;
+ };
+ class State extends Component {
+ constructor(props) {
+ super(props);
+ this.state = { i: 0 };
+ set = this.setState.bind(this);
+ }
+ render() {
+ const { children } = this.props;
+ return <Provider value={this.state}>{children}</Provider>;
+ }
+ }
+ const App = () => (
+ <State>
+ <Layout>
+ <Consumer>{({ i }) => <p>{i}</p>}</Consumer>
+ </Layout>
+ </State>
+ );
+ render(<App />, scratch);
+ expect(renders).to.equal(1);
+ set({ i: 2 });
+ rerender();
+ expect(renders).to.equal(1);
+ });
+
+ it('should preserve provider context through nesting providers', done => {
+ const { Provider, Consumer } = createContext();
+ const CONTEXT = { a: 'a' };
+ const CHILD_CONTEXT = { b: 'b' };
+
+ let parentContext, childContext;
+
+ class Inner extends Component {
+ render(props) {
+ return (
+ <div>
+ {props.a} - {props.b}
+ </div>
+ );
+ }
+ }
+
+ sinon.spy(Inner.prototype, 'render');
+
+ render(
+ <Provider value={CONTEXT}>
+ <Consumer>
+ {data => {
+ parentContext = data;
+ return (
+ <Provider value={CHILD_CONTEXT}>
+ <Consumer>
+ {childData => {
+ childContext = childData;
+ return <Inner {...data} {...childData} />;
+ }}
+ </Consumer>
+ </Provider>
+ );
+ }}
+ </Consumer>
+ </Provider>,
+ scratch
+ );
+
+ // initial render does not invoke anything but render():
+ expect(Inner.prototype.render).to.have.been.calledWithMatch({
+ ...CONTEXT,
+ ...CHILD_CONTEXT
+ });
+ expect(Inner.prototype.render).to.be.calledOnce;
+ expect(parentContext).to.equal(CONTEXT);
+ expect(childContext).to.equal(CHILD_CONTEXT);
+ expect(scratch.innerHTML).to.equal('<div>a - b</div>');
+ setTimeout(() => {
+ expect(Inner.prototype.render).to.be.calledOnce;
+ done();
+ }, 0);
+ });
+
+ it('should preserve provider context between different providers', () => {
+ const {
+ Provider: ThemeProvider,
+ Consumer: ThemeConsumer
+ } = createContext();
+ const { Provider: DataProvider, Consumer: DataConsumer } = createContext();
+ const THEME_CONTEXT = { theme: 'black' };
+ const DATA_CONTEXT = { global: 'a' };
+
+ let receivedTheme;
+ let receivedData;
+
+ class Inner extends Component {
+ render(props) {
+ return (
+ <div>
+ {props.theme} - {props.global}
+ </div>
+ );
+ }
+ }
+
+ sinon.spy(Inner.prototype, 'render');
+
+ render(
+ <ThemeProvider value={THEME_CONTEXT.theme}>
+ <DataProvider value={DATA_CONTEXT}>
+ <ThemeConsumer>
+ {theme => {
+ receivedTheme = theme;
+ return (
+ <DataConsumer>
+ {data => {
+ receivedData = data;
+ return <Inner theme={theme} {...data} />;
+ }}
+ </DataConsumer>
+ );
+ }}
+ </ThemeConsumer>
+ </DataProvider>
+ </ThemeProvider>,
+ scratch
+ );
+
+ // initial render does not invoke anything but render():
+ expect(Inner.prototype.render).to.have.been.calledWithMatch({
+ ...THEME_CONTEXT,
+ ...DATA_CONTEXT
+ });
+ expect(receivedTheme).to.equal(THEME_CONTEXT.theme);
+ expect(receivedData).to.equal(DATA_CONTEXT);
+ expect(scratch.innerHTML).to.equal('<div>black - a</div>');
+ });
+
+ it('should preserve provider context through nesting consumers', () => {
+ const { Provider, Consumer } = createContext();
+ const CONTEXT = { a: 'a' };
+
+ let receivedData;
+ let receivedChildData;
+
+ class Inner extends Component {
+ render(props) {
+ return <div>{props.a}</div>;
+ }
+ }
+
+ sinon.spy(Inner.prototype, 'render');
+
+ render(
+ <Provider value={CONTEXT}>
+ <Consumer>
+ {data => {
+ receivedData = data;
+ return (
+ <Consumer>
+ {childData => {
+ receivedChildData = childData;
+ return <Inner {...data} {...childData} />;
+ }}
+ </Consumer>
+ );
+ }}
+ </Consumer>
+ </Provider>,
+ scratch
+ );
+
+ // initial render does not invoke anything but render():
+ expect(Inner.prototype.render).to.have.been.calledWithMatch({ ...CONTEXT });
+ expect(receivedData).to.equal(CONTEXT);
+ expect(receivedChildData).to.equal(CONTEXT);
+ expect(scratch.innerHTML).to.equal('<div>a</div>');
+ });
+
+ it('should not emit when value does not update', () => {
+ const { Provider, Consumer } = createContext();
+ const CONTEXT = { a: 'a' };
+
+ class NoUpdate extends Component {
+ shouldComponentUpdate() {
+ return false;
+ }
+
+ render() {
+ return this.props.children;
+ }
+ }
+
+ class Inner extends Component {
+ render(props) {
+ return <div>{props.a}</div>;
+ }
+ }
+
+ sinon.spy(Inner.prototype, 'render');
+
+ render(
+ <div>
+ <Provider value={CONTEXT}>
+ <NoUpdate>
+ <Consumer>{data => <Inner {...data} />}</Consumer>
+ </NoUpdate>
+ </Provider>
+ </div>,
+ scratch
+ );
+
+ expect(Inner.prototype.render).to.have.been.calledOnce;
+
+ render(
+ <div>
+ <Provider value={CONTEXT}>
+ <NoUpdate>
+ <Consumer>{data => <Inner {...data} />}</Consumer>
+ </NoUpdate>
+ </Provider>
+ </div>,
+ scratch
+ );
+
+ expect(Inner.prototype.render).to.have.been.calledOnce;
+ });
+
+ it('should preserve provider context through nested components', () => {
+ const { Provider, Consumer } = createContext();
+ const CONTEXT = { a: 'a' };
+
+ let receivedContext;
+
+ class Consumed extends Component {
+ render(props) {
+ return <strong>{props.a}</strong>;
+ }
+ }
+
+ sinon.spy(Consumed.prototype, 'render');
+
+ class Outer extends Component {
+ render() {
+ return (
+ <div>
+ <Inner />
+ </div>
+ );
+ }
+ }
+
+ class Inner extends Component {
+ render() {
+ return (
+ <Fragment>
+ <InnerMost />
+ </Fragment>
+ );
+ }
+ }
+
+ class InnerMost extends Component {
+ render() {
+ return (
+ <div>
+ <Consumer>
+ {data => {
+ receivedContext = data;
+ return <Consumed {...data} />;
+ }}
+ </Consumer>
+ </div>
+ );
+ }
+ }
+
+ render(
+ <Provider value={CONTEXT}>
+ <Outer />
+ </Provider>,
+ scratch
+ );
+
+ // initial render does not invoke anything but render():
+ expect(Consumed.prototype.render).to.have.been.calledWithMatch({
+ ...CONTEXT
+ });
+ expect(receivedContext).to.equal(CONTEXT);
+ expect(scratch.innerHTML).to.equal(
+ '<div><div><strong>a</strong></div></div>'
+ );
+ });
+
+ it('should propagates through shouldComponentUpdate false', done => {
+ const { Provider, Consumer } = createContext();
+ const CONTEXT = { a: 'a' };
+ const UPDATED_CONTEXT = { a: 'b' };
+
+ class Consumed extends Component {
+ render(props) {
+ return <strong>{props.a}</strong>;
+ }
+ }
+
+ sinon.spy(Consumed.prototype, 'render');
+
+ class Outer extends Component {
+ render() {
+ return (
+ <div>
+ <Inner />
+ </div>
+ );
+ }
+ }
+
+ class Inner extends Component {
+ shouldComponentUpdate() {
+ return false;
+ }
+
+ render() {
+ return (
+ <Fragment>
+ <InnerMost />
+ </Fragment>
+ );
+ }
+ }
+
+ class InnerMost extends Component {
+ render() {
+ return (
+ <div>
+ <Consumer>{data => <Consumed {...data} />}</Consumer>
+ </div>
+ );
+ }
+ }
+
+ class App extends Component {
+ render() {
+ return (
+ <Provider value={this.props.value}>
+ <Outer />
+ </Provider>
+ );
+ }
+ }
+
+ render(<App value={CONTEXT} />, scratch);
+ expect(scratch.innerHTML).to.equal(
+ '<div><div><strong>a</strong></div></div>'
+ );
+ expect(Consumed.prototype.render).to.have.been.calledOnce;
+
+ render(<App value={UPDATED_CONTEXT} />, scratch);
+
+ rerender();
+
+ // initial render does not invoke anything but render():
+ expect(Consumed.prototype.render).to.have.been.calledTwice;
+ // expect(Consumed.prototype.render).to.have.been.calledWithMatch({ ...UPDATED_CONTEXT }, {}, { ['__cC' + (ctxId - 1)]: {} });
+ expect(scratch.innerHTML).to.equal(
+ '<div><div><strong>b</strong></div></div>'
+ );
+ setTimeout(() => {
+ expect(Consumed.prototype.render).to.have.been.calledTwice;
+ done();
+ });
+ });
+
+ it('should keep the right context at the right "depth"', () => {
+ const { Provider, Consumer } = createContext();
+ const CONTEXT = { theme: 'a', global: 1 };
+ const NESTED_CONTEXT = { theme: 'b', global: 1 };
+
+ let receivedData;
+ let receivedNestedData;
+
+ class Inner extends Component {
+ render(props) {
+ return (
+ <div>
+ {props.theme} - {props.global}
+ </div>
+ );
+ }
+ }
+ class Nested extends Component {
+ render(props) {
+ return (
+ <div>
+ {props.theme} - {props.global}
+ </div>
+ );
+ }
+ }
+
+ sinon.spy(Inner.prototype, 'render');
+ sinon.spy(Nested.prototype, 'render');
+
+ render(
+ <Provider value={CONTEXT}>
+ <Provider value={NESTED_CONTEXT}>
+ <Consumer>
+ {data => {
+ receivedNestedData = data;
+ return <Nested {...data} />;
+ }}
+ </Consumer>
+ </Provider>
+ <Consumer>
+ {data => {
+ receivedData = data;
+ return <Inner {...data} />;
+ }}
+ </Consumer>
+ </Provider>,
+ scratch
+ );
+
+ // initial render does not invoke anything but render():
+ expect(Nested.prototype.render).to.have.been.calledWithMatch({
+ ...NESTED_CONTEXT
+ });
+ expect(Inner.prototype.render).to.have.been.calledWithMatch({ ...CONTEXT });
+ expect(receivedData).to.equal(CONTEXT);
+ expect(receivedNestedData).to.equal(NESTED_CONTEXT);
+
+ expect(scratch.innerHTML).to.equal('<div>b - 1</div><div>a - 1</div>');
+ });
+
+ it("should not re-render the consumer if the context doesn't change", () => {
+ const { Provider, Consumer } = createContext();
+ const CONTEXT = { i: 1 };
+
+ class NoUpdate extends Component {
+ shouldComponentUpdate() {
+ return false;
+ }
+
+ render() {
+ return this.props.children;
+ }
+ }
+
+ class Inner extends Component {
+ render(props) {
+ return <div>{props.i}</div>;
+ }
+ }
+
+ sinon.spy(Inner.prototype, 'render');
+
+ render(
+ <Provider value={CONTEXT}>
+ <NoUpdate>
+ <Consumer>{data => <Inner {...data} />}</Consumer>
+ </NoUpdate>
+ </Provider>,
+ scratch
+ );
+
+ render(
+ <Provider value={CONTEXT}>
+ <NoUpdate>
+ <Consumer>{data => <Inner {...data} />}</Consumer>
+ </NoUpdate>
+ </Provider>,
+ scratch
+ );
+
+ // Rendered twice, should called just one 'Consumer' render
+ expect(Inner.prototype.render).to.have.been.calledOnce.and.calledWithMatch(
+ CONTEXT
+ );
+ expect(scratch.innerHTML).to.equal('<div>1</div>');
+
+ act(() => {
+ render(
+ <Provider value={{ i: 2 }}>
+ <NoUpdate>
+ <Consumer>{data => <Inner {...data} />}</Consumer>
+ </NoUpdate>
+ </Provider>,
+ scratch
+ );
+ });
+
+ // Rendered three times, should call 'Consumer' render two times
+ expect(
+ Inner.prototype.render
+ ).to.have.been.calledTwice.and.calledWithMatch({ i: 2 });
+ expect(scratch.innerHTML).to.equal('<div>2</div>');
+ });
+
+ it('should allow for updates of props', () => {
+ let app;
+ const { Provider, Consumer } = createContext();
+ class App extends Component {
+ constructor(props) {
+ super(props);
+ this.state = {
+ status: 'initial'
+ };
+
+ this.renderInner = this.renderInner.bind(this);
+
+ app = this;
+ }
+
+ renderInner(value) {
+ return (
+ <p>
+ {value}: {this.state.status}
+ </p>
+ );
+ }
+
+ render() {
+ return (
+ <Provider value="value">
+ <Consumer>{this.renderInner}</Consumer>
+ </Provider>
+ );
+ }
+ }
+
+ act(() => {
+ render(<App />, scratch);
+ });
+
+ expect(scratch.innerHTML).to.equal('<p>value: initial</p>');
+
+ act(() => {
+ app.setState({ status: 'updated' });
+ rerender();
+ });
+
+ expect(scratch.innerHTML).to.equal('<p>value: updated</p>');
+ });
+
+ it('should re-render the consumer if the children change', () => {
+ const { Provider, Consumer } = createContext();
+ const CONTEXT = { i: 1 };
+
+ class Inner extends Component {
+ render(props) {
+ return <div>{props.i}</div>;
+ }
+ }
+
+ sinon.spy(Inner.prototype, 'render');
+
+ act(() => {
+ render(
+ <Provider value={CONTEXT}>
+ <Consumer>{data => <Inner {...data} />}</Consumer>
+ </Provider>,
+ scratch
+ );
+
+ // Not calling re-render since it's gonna get called with the same Consumer function
+ render(
+ <Provider value={CONTEXT}>
+ <Consumer>{data => <Inner {...data} />}</Consumer>
+ </Provider>,
+ scratch
+ );
+ });
+
+ // Rendered twice, with two different children for consumer, should render twice
+ expect(Inner.prototype.render).to.have.been.calledTwice;
+ expect(scratch.innerHTML).to.equal('<div>1</div>');
+ });
+
+ it('should not rerender consumers that have been unmounted', () => {
+ const { Provider, Consumer } = createContext(0);
+
+ const Inner = sinon.spy(props => <div>{props.value}</div>);
+
+ let toggleConsumer;
+ let changeValue;
+ class App extends Component {
+ constructor() {
+ super();
+
+ this.state = { value: 0, show: true };
+ changeValue = value => this.setState({ value });
+ toggleConsumer = () => this.setState(({ show }) => ({ show: !show }));
+ }
+ render(props, state) {
+ return (
+ <Provider value={state.value}>
+ <div>
+ {state.show ? (
+ <Consumer>{data => <Inner value={data} />}</Consumer>
+ ) : null}
+ </div>
+ </Provider>
+ );
+ }
+ }
+
+ render(<App />, scratch);
+ expect(scratch.innerHTML).to.equal('<div><div>0</div></div>');
+ expect(Inner).to.have.been.calledOnce;
+
+ changeValue(1);
+ rerender();
+ expect(scratch.innerHTML).to.equal('<div><div>1</div></div>');
+ expect(Inner).to.have.been.calledTwice;
+
+ toggleConsumer();
+ rerender();
+ expect(scratch.innerHTML).to.equal('<div></div>');
+ expect(Inner).to.have.been.calledTwice;
+
+ changeValue(2);
+ rerender();
+ expect(scratch.innerHTML).to.equal('<div></div>');
+ expect(Inner).to.have.been.calledTwice;
+ });
+
+ describe('class.contextType', () => {
+ it('should use default value', () => {
+ const ctx = createContext('foo');
+
+ let actual;
+ class App extends Component {
+ render() {
+ actual = this.context;
+ return <div>bar</div>;
+ }
+ }
+
+ App.contextType = ctx;
+
+ render(<App />, scratch);
+ expect(actual).to.deep.equal('foo');
+ });
+
+ it('should use the value of the nearest Provider', () => {
+ const ctx = createContext('foo');
+
+ let actual;
+ class App extends Component {
+ render() {
+ actual = this.context;
+ return <div>bar</div>;
+ }
+ }
+
+ App.contextType = ctx;
+ const Provider = ctx.Provider;
+
+ render(
+ <Provider value="bar">
+ <Provider value="bob">
+ <App />
+ </Provider>
+ </Provider>,
+ scratch
+ );
+ expect(actual).to.deep.equal('bob');
+ });
+
+ it('should restore legacy context for children', () => {
+ const Foo = createContext('foo');
+ const spy = sinon.spy();
+
+ class NewContext extends Component {
+ render() {
+ return <div>{this.props.children}</div>;
+ }
+ }
+
+ class OldContext extends Component {
+ getChildContext() {
+ return { foo: 'foo' };
+ }
+
+ render() {
+ return <div>{this.props.children}</div>;
+ }
+ }
+
+ class Inner extends Component {
+ render() {
+ spy(this.context);
+ return <div>Inner</div>;
+ }
+ }
+
+ NewContext.contextType = Foo;
+
+ render(
+ <Foo.Provider value="bar">
+ <OldContext>
+ <NewContext>
+ <Inner />
+ </NewContext>
+ </OldContext>
+ </Foo.Provider>,
+ scratch
+ );
+
+ expect(spy).to.be.calledWithMatch({ foo: 'foo' });
+ });
+
+ it('should call componentWillUnmount', () => {
+ let Foo = createContext('foo');
+ let spy = sinon.spy();
+
+ let instance;
+ class App extends Component {
+ constructor(props) {
+ super(props);
+ instance = this;
+ }
+
+ componentWillUnmount() {
+ spy(this);
+ }
+
+ render() {
+ return <div />;
+ }
+ }
+
+ App.contextType = Foo;
+
+ render(
+ <Foo.Provider value="foo">
+ <App />
+ </Foo.Provider>,
+ scratch
+ );
+
+ render(null, scratch);
+
+ expect(spy).to.be.calledOnce;
+ expect(spy.getCall(0).args[0]).to.equal(instance);
+ });
+
+ it('should order updates correctly', () => {
+ const events = [];
+ let update;
+ const Store = createContext();
+
+ class Root extends Component {
+ constructor(props) {
+ super(props);
+ this.state = { id: 0 };
+ update = this.updateStore = this.updateStore.bind(this);
+ }
+
+ updateStore() {
+ this.setState(state => ({ id: state.id + 1 }));
+ }
+
+ render() {
+ return (
+ <Store.Provider value={this.state.id}>
+ <App />
+ </Store.Provider>
+ );
+ }
+ }
+
+ class App extends Component {
+ shouldComponentUpdate() {
+ return false;
+ }
+
+ render() {
+ return <Store.Consumer>{id => <Parent key={id} />}</Store.Consumer>;
+ }
+ }
+
+ function Parent(props) {
+ return <Store.Consumer>{id => <Child id={id} />}</Store.Consumer>;
+ }
+
+ class Child extends Component {
+ componentDidMount() {
+ events.push('mount ' + this.props.id);
+ }
+
+ componentDidUpdate(prevProps) {
+ events.push('update ' + prevProps.id + ' to ' + this.props.id);
+ }
+
+ componentWillUnmount() {
+ events.push('unmount ' + this.props.id);
+ }
+
+ render() {
+ events.push('render ' + this.props.id);
+ return this.props.id;
+ }
+ }
+
+ render(<Root />, scratch);
+ expect(events).to.deep.equal(['render 0', 'mount 0']);
+
+ update();
+ rerender();
+ expect(events).to.deep.equal([
+ 'render 0',
+ 'mount 0',
+ 'render 1',
+ 'unmount 0',
+ 'mount 1'
+ ]);
+ });
+ });
+
+ it('should rerender when reset to defaultValue', () => {
+ const defaultValue = { state: 'hi' };
+ const context = createContext(defaultValue);
+ let set;
+
+ class NoUpdate extends Component {
+ shouldComponentUpdate() {
+ return false;
+ }
+
+ render() {
+ return <context.Consumer>{v => <p>{v.state}</p>}</context.Consumer>;
+ }
+ }
+
+ class Provider extends Component {
+ constructor(props) {
+ super(props);
+ this.state = defaultValue;
+ set = this.setState.bind(this);
+ }
+
+ render() {
+ return (
+ <context.Provider value={this.state}>
+ <NoUpdate />
+ </context.Provider>
+ );
+ }
+ }
+
+ render(<Provider />, scratch);
+ expect(scratch.innerHTML).to.equal('<p>hi</p>');
+
+ set({ state: 'bye' });
+ rerender();
+ expect(scratch.innerHTML).to.equal('<p>bye</p>');
+
+ set(defaultValue);
+ rerender();
+ expect(scratch.innerHTML).to.equal('<p>hi</p>');
+ });
+});
diff --git a/preact/test/browser/customBuiltInElements.test.js b/preact/test/browser/customBuiltInElements.test.js
new file mode 100644
index 0000000..eb8ce17
--- /dev/null
+++ b/preact/test/browser/customBuiltInElements.test.js
@@ -0,0 +1,40 @@
+import { createElement, render, Component } from 'preact';
+import { setupScratch, teardown } from '../_util/helpers';
+
+/** @jsx createElement */
+
+const runSuite = typeof customElements == 'undefined' ? xdescribe : describe;
+
+runSuite('customised built-in elements', () => {
+ let scratch;
+
+ beforeEach(() => {
+ scratch = setupScratch();
+ });
+
+ afterEach(() => {
+ teardown(scratch);
+ });
+
+ it('should create built in elements correctly', () => {
+ class Foo extends Component {
+ render() {
+ return <div is="built-in" />;
+ }
+ }
+
+ const spy = sinon.spy();
+
+ class BuiltIn extends HTMLDivElement {
+ connectedCallback() {
+ spy();
+ }
+ }
+
+ customElements.define('built-in', BuiltIn, { extends: 'div' });
+
+ render(<Foo />, scratch);
+
+ expect(spy).to.have.been.calledOnce;
+ });
+});
diff --git a/preact/test/browser/events.test.js b/preact/test/browser/events.test.js
new file mode 100644
index 0000000..2e43cba
--- /dev/null
+++ b/preact/test/browser/events.test.js
@@ -0,0 +1,202 @@
+import { createElement, render } from 'preact';
+import {
+ setupScratch,
+ teardown,
+ supportsPassiveEvents
+} from '../_util/helpers';
+
+/** @jsx createElement */
+
+describe('event handling', () => {
+ let scratch, proto;
+
+ function fireEvent(on, type) {
+ let e = document.createEvent('Event');
+ e.initEvent(type, true, true);
+ on.dispatchEvent(e);
+ }
+
+ beforeEach(() => {
+ scratch = setupScratch();
+
+ proto = document.createElement('div').constructor.prototype;
+
+ sinon.spy(proto, 'addEventListener');
+ sinon.spy(proto, 'removeEventListener');
+ });
+
+ afterEach(() => {
+ teardown(scratch);
+
+ proto.addEventListener.restore();
+ proto.removeEventListener.restore();
+ });
+
+ it('should only register on* functions as handlers', () => {
+ let click = () => {},
+ onclick = () => {};
+
+ render(<div click={click} onClick={onclick} />, scratch);
+
+ expect(scratch.childNodes[0].attributes.length).to.equal(0);
+
+ expect(
+ proto.addEventListener
+ ).to.have.been.calledOnce.and.to.have.been.calledWithExactly(
+ 'click',
+ sinon.match.func,
+ false
+ );
+ });
+
+ it('should only register truthy values as handlers', () => {
+ function fooHandler() {}
+ const falsyHandler = false;
+
+ render(<div onClick={falsyHandler} onOtherClick={fooHandler} />, scratch);
+
+ expect(
+ proto.addEventListener
+ ).to.have.been.calledOnce.and.to.have.been.calledWithExactly(
+ 'OtherClick',
+ sinon.match.func,
+ false
+ );
+
+ expect(proto.addEventListener).not.to.have.been.calledWith('Click');
+ expect(proto.addEventListener).not.to.have.been.calledWith('click');
+ });
+
+ it('should support native event names', () => {
+ let click = sinon.spy(),
+ mousedown = sinon.spy();
+
+ render(<div onclick={() => click(1)} onmousedown={mousedown} />, scratch);
+
+ expect(proto.addEventListener)
+ .to.have.been.calledTwice.and.to.have.been.calledWith('click')
+ .and.calledWith('mousedown');
+
+ fireEvent(scratch.childNodes[0], 'click');
+ expect(click).to.have.been.calledOnce.and.calledWith(1);
+ });
+
+ it('should support camel-case event names', () => {
+ let click = sinon.spy(),
+ mousedown = sinon.spy();
+
+ render(<div onClick={() => click(1)} onMouseDown={mousedown} />, scratch);
+
+ expect(proto.addEventListener)
+ .to.have.been.calledTwice.and.to.have.been.calledWith('click')
+ .and.calledWith('mousedown');
+
+ fireEvent(scratch.childNodes[0], 'click');
+ expect(click).to.have.been.calledOnce.and.calledWith(1);
+ });
+
+ it('should update event handlers', () => {
+ let click1 = sinon.spy();
+ let click2 = sinon.spy();
+
+ render(<div onClick={click1} />, scratch);
+
+ fireEvent(scratch.childNodes[0], 'click');
+ expect(click1).to.have.been.calledOnce;
+ expect(click2).to.not.have.been.called;
+
+ click1.resetHistory();
+ click2.resetHistory();
+
+ render(<div onClick={click2} />, scratch);
+
+ fireEvent(scratch.childNodes[0], 'click');
+ expect(click1).to.not.have.been.called;
+ expect(click2).to.have.been.called;
+ });
+
+ it('should remove event handlers', () => {
+ let click = sinon.spy(),
+ mousedown = sinon.spy();
+
+ render(<div onClick={() => click(1)} onMouseDown={mousedown} />, scratch);
+ render(<div onClick={() => click(2)} />, scratch);
+
+ expect(proto.removeEventListener).to.have.been.calledWith('mousedown');
+
+ fireEvent(scratch.childNodes[0], 'mousedown');
+ expect(mousedown).not.to.have.been.called;
+
+ proto.removeEventListener.resetHistory();
+ click.resetHistory();
+ mousedown.resetHistory();
+
+ render(<div />, scratch);
+
+ expect(proto.removeEventListener).to.have.been.calledWith('click');
+
+ fireEvent(scratch.childNodes[0], 'click');
+ expect(click).not.to.have.been.called;
+ });
+
+ it('should register events not appearing on dom nodes', () => {
+ let onAnimationEnd = () => {};
+
+ render(<div onanimationend={onAnimationEnd} />, scratch);
+ expect(
+ proto.addEventListener
+ ).to.have.been.calledOnce.and.to.have.been.calledWithExactly(
+ 'animationend',
+ sinon.match.func,
+ false
+ );
+ });
+
+ // Skip test if browser doesn't support passive events
+ if (supportsPassiveEvents()) {
+ it('should use capturing for event props ending with *Capture', () => {
+ let click = sinon.spy(),
+ focus = sinon.spy();
+
+ render(
+ <div onClickCapture={click} onFocusCapture={focus}>
+ <button />
+ </div>,
+ scratch
+ );
+
+ let root = scratch.firstChild;
+ root.firstElementChild.click();
+ root.firstElementChild.focus();
+
+ expect(click, 'click').to.have.been.calledOnce;
+
+ // Focus delegation requires a 50b hack I'm not sure we want to incur
+ expect(focus, 'focus').to.have.been.calledOnce;
+
+ // IE doesn't set it
+ if (!/Edge/.test(navigator.userAgent)) {
+ expect(click).to.have.been.calledWithMatch({ eventPhase: 0 }); // capturing
+ expect(focus).to.have.been.calledWithMatch({ eventPhase: 0 }); // capturing
+ }
+ });
+
+ it('should support both capturing and non-capturing events on the same element', () => {
+ let click = sinon.spy(),
+ clickCapture = sinon.spy();
+
+ render(
+ <div onClick={click} onClickCapture={clickCapture}>
+ <button />
+ </div>,
+ scratch
+ );
+
+ let root = scratch.firstChild;
+ root.firstElementChild.click();
+
+ expect(clickCapture, 'click').to.have.been.calledOnce;
+ expect(click, 'click').to.have.been.calledOnce;
+ });
+ }
+});
diff --git a/preact/test/browser/focus.test.js b/preact/test/browser/focus.test.js
new file mode 100644
index 0000000..004a87a
--- /dev/null
+++ b/preact/test/browser/focus.test.js
@@ -0,0 +1,548 @@
+import { setupRerender } from 'preact/test-utils';
+import { createElement, render, Component, Fragment, hydrate } from 'preact';
+import { setupScratch, teardown } from '../_util/helpers';
+import { div, span, input as inputStr, h1, h2 } from '../_util/dom';
+
+/** @jsx createElement */
+/* eslint-disable react/jsx-boolean-value */
+
+describe('focus', () => {
+ /** @type {HTMLDivElement} */
+ let scratch;
+
+ /** @type {() => void} */
+ let rerender;
+
+ /** @type {() => void} */
+ let prepend, append, shift, pop;
+
+ /** @type {() => void} */
+ let getDynamicListHtml;
+
+ class DynamicList extends Component {
+ constructor(props) {
+ super(props);
+ this.state = {
+ before: props.initialBefore || [],
+ after: props.initialAfter || []
+ };
+
+ prepend = () => {
+ const before = this.state.before;
+ const newValue = before[0] ? before[0] - 1 : 1;
+ this.setState({
+ before: [newValue, ...before]
+ });
+ };
+
+ append = () => {
+ const after = this.state.after;
+ const lastValue = after[after.length - 1];
+ const newValue = lastValue ? lastValue + 1 : 2;
+ this.setState({
+ after: [...after, newValue]
+ });
+ };
+
+ shift = () => {
+ this.setState({
+ before: this.state.before.slice(1)
+ });
+ };
+
+ pop = () => {
+ this.setState({
+ after: this.state.after.slice(0, -1)
+ });
+ };
+
+ const liHtml = this.props.as == Input ? inputStr : span;
+ getDynamicListHtml = () =>
+ div([
+ ...this.state.before.map(liHtml),
+ '<input id="input-0" type="text">',
+ ...this.state.after.map(liHtml)
+ ]);
+ }
+
+ render(props, state) {
+ const ListComponent = props.as || ListItem;
+ return (
+ <div>
+ {state.before.map(value => (
+ <ListComponent key={props.unkeyed ? undefined : value}>
+ {value}
+ </ListComponent>
+ ))}
+ <InputWithId id="0" />
+ {state.after.map(value => (
+ <ListComponent key={props.unkeyed ? undefined : value}>
+ {value}
+ </ListComponent>
+ ))}
+ </div>
+ );
+ }
+ }
+
+ const List = ({ children }) => <div>{children}</div>;
+ const ListItem = ({ children }) => <span>{children}</span>;
+ const InputWithId = ({ id }) => <input id={`input-${id}`} type="text" />;
+ const Input = () => <input type="text" />;
+
+ function focusInput() {
+ if (!scratch) return;
+
+ const input = scratch.querySelector('input');
+ input.value = 'a word';
+ input.focus();
+ input.setSelectionRange(2, 5);
+
+ expect(document.activeElement).to.equalNode(input);
+
+ return input;
+ }
+
+ function focusInputById() {
+ if (!scratch) return;
+
+ /** @type {HTMLInputElement} */
+ const input = scratch.querySelector('#input-0');
+ input.value = 'a word';
+ input.focus();
+ input.setSelectionRange(2, 5);
+
+ expect(document.activeElement).to.equalNode(input);
+
+ return input;
+ }
+
+ /**
+ * Validate an input tag has maintained focus
+ * @param {HTMLInputElement} input The input to validate
+ * @param {string} [message] Message to show if the activeElement is not
+ * equal to the `input` parameter
+ */
+ function validateFocus(input, message) {
+ expect(document.activeElement).to.equalNode(input, message);
+ expect(input.selectionStart).to.equal(2);
+ expect(input.selectionEnd).to.equal(5);
+ }
+
+ /**
+ * @param {Array<number | string>} before
+ * @param {Array<number | string>} after
+ */
+ function getListHtml(before, after) {
+ return div([
+ ...before.map(i => span(i)),
+ inputStr(),
+ ...after.map(i => span(i))
+ ]);
+ }
+
+ beforeEach(() => {
+ scratch = setupScratch();
+ rerender = setupRerender();
+ });
+
+ afterEach(() => {
+ teardown(scratch);
+ });
+
+ it.skip('should maintain focus when swapping elements', () => {
+ render(
+ <List>
+ <Input />
+ <ListItem>fooo</ListItem>
+ </List>,
+ scratch
+ );
+
+ const input = focusInput();
+ expect(scratch.innerHTML).to.equal(getListHtml([], ['fooo']));
+
+ render(
+ <List>
+ <ListItem>fooo</ListItem>
+ <Input />
+ </List>,
+ scratch
+ );
+ validateFocus(input);
+ expect(scratch.innerHTML).to.equal(getListHtml(['fooo'], []));
+ });
+
+ it('should maintain focus when moving the input around', () => {
+ function App({ showFirst, showLast }) {
+ return (
+ <List>
+ {showFirst ? <ListItem>1</ListItem> : null}
+ <Input />
+ {showLast ? <ListItem>2</ListItem> : null}
+ </List>
+ );
+ }
+
+ render(<App showFirst={true} showLast={true} />, scratch);
+
+ let input = focusInput();
+ render(<App showFirst={false} showLast={true} />, scratch);
+ expect(scratch.innerHTML).to.equal(getListHtml([], [2]));
+ validateFocus(input, 'move from middle to beginning');
+
+ input = focusInput();
+ render(<App showFirst={true} showLast={true} />, scratch);
+ expect(scratch.innerHTML).to.equal(getListHtml([1], [2]));
+ validateFocus(input, 'move from beginning to middle');
+
+ input = focusInput();
+ render(<App showFirst={true} showLast={false} />, scratch);
+ expect(scratch.innerHTML).to.equal(getListHtml([1], []));
+ validateFocus(input, 'move from middle to end');
+
+ input = focusInput();
+ render(<App showFirst={true} showLast={true} />, scratch);
+ expect(scratch.innerHTML).to.equal(getListHtml([1], [2]));
+ validateFocus(input, 'move from end to middle');
+ });
+
+ it('should maintain focus when adding children around input', () => {
+ render(<DynamicList />, scratch);
+
+ let input = focusInput();
+ expect(scratch.innerHTML).to.equal(getDynamicListHtml());
+
+ prepend();
+ rerender();
+
+ expect(scratch.innerHTML).to.equal(getDynamicListHtml());
+ validateFocus(input, 'insert sibling before');
+
+ append();
+ rerender();
+
+ expect(scratch.innerHTML).to.equal(getDynamicListHtml());
+ validateFocus(input, 'insert sibling after');
+
+ append();
+ rerender();
+
+ expect(scratch.innerHTML).to.equal(getDynamicListHtml());
+ validateFocus(input, 'insert sibling after again');
+
+ prepend();
+ rerender();
+
+ expect(scratch.innerHTML).to.equal(getDynamicListHtml());
+ validateFocus(input, 'insert sibling before again');
+ });
+
+ it('should maintain focus when adding children around input (unkeyed)', () => {
+ // Related preactjs/preact#2446
+
+ render(<DynamicList unkeyed />, scratch);
+
+ let input = focusInput();
+ expect(scratch.innerHTML).to.equal(getDynamicListHtml());
+
+ prepend();
+ rerender();
+
+ expect(scratch.innerHTML).to.equal(getDynamicListHtml());
+ validateFocus(input, 'insert sibling before');
+
+ append();
+ rerender();
+
+ expect(scratch.innerHTML).to.equal(getDynamicListHtml());
+ validateFocus(input, 'insert sibling after');
+
+ append();
+ rerender();
+
+ expect(scratch.innerHTML).to.equal(getDynamicListHtml());
+ validateFocus(input, 'insert sibling after again');
+
+ prepend();
+ rerender();
+
+ expect(scratch.innerHTML).to.equal(getDynamicListHtml());
+ validateFocus(input, 'insert sibling before again');
+ });
+
+ it('should maintain focus when conditional elements around input', () => {
+ render(
+ <List>
+ <ListItem>0</ListItem>
+ <ListItem>1</ListItem>
+ <Input />
+ <ListItem>2</ListItem>
+ <ListItem>3</ListItem>
+ </List>,
+ scratch
+ );
+
+ let input = focusInput();
+ expect(scratch.innerHTML).to.equal(getListHtml([0, 1], [2, 3]));
+
+ render(
+ <List>
+ {false && <ListItem>0</ListItem>}
+ <ListItem>1</ListItem>
+ <Input />
+ <ListItem>2</ListItem>
+ <ListItem>3</ListItem>
+ </List>,
+ scratch
+ );
+
+ expect(scratch.innerHTML).to.equal(getListHtml([1], [2, 3]));
+ validateFocus(input, 'remove sibling before');
+
+ render(
+ <List>
+ {false && <ListItem>0</ListItem>}
+ <ListItem>1</ListItem>
+ <Input />
+ <ListItem>2</ListItem>
+ {false && <ListItem>3</ListItem>}
+ </List>,
+ scratch
+ );
+
+ expect(scratch.innerHTML).to.equal(getListHtml([1], [2]));
+ validateFocus(input, 'remove sibling after');
+
+ render(
+ <List>
+ {false && <ListItem>0</ListItem>}
+ <ListItem>1</ListItem>
+ <Input />
+ {false && <ListItem>2</ListItem>}
+ {false && <ListItem>3</ListItem>}
+ </List>,
+ scratch
+ );
+
+ expect(scratch.innerHTML).to.equal(getListHtml([1], []));
+ validateFocus(input, 'remove sibling after 2');
+
+ render(
+ <List>
+ {false && <ListItem>0</ListItem>}
+ {false && <ListItem>1</ListItem>}
+ <Input />
+ {false && <ListItem>2</ListItem>}
+ {false && <ListItem>3</ListItem>}
+ </List>,
+ scratch
+ );
+
+ expect(scratch.innerHTML).to.equal(getListHtml([], []));
+ validateFocus(input, 'remove sibling before 2');
+ });
+
+ it('should maintain focus when removing elements around input', () => {
+ render(
+ <DynamicList initialBefore={[0, 1]} initialAfter={[2, 3]} />,
+ scratch
+ );
+
+ let input = focusInput();
+ expect(scratch.innerHTML).to.equal(getDynamicListHtml());
+
+ shift();
+ rerender();
+
+ expect(scratch.innerHTML).to.equal(getDynamicListHtml());
+ validateFocus(input, 'remove sibling before');
+
+ pop();
+ rerender();
+
+ expect(scratch.innerHTML).to.equal(getDynamicListHtml());
+ validateFocus(input, 'remove sibling after');
+
+ pop();
+ rerender();
+
+ expect(scratch.innerHTML).to.equal(getDynamicListHtml());
+ validateFocus(input, 'remove sibling after 2');
+
+ shift();
+ rerender();
+
+ expect(scratch.innerHTML).to.equal(getDynamicListHtml());
+ validateFocus(input, 'remove sibling before 2');
+ });
+
+ it('should maintain focus when adding input next to the current input', () => {
+ render(<DynamicList as={Input} />, scratch);
+
+ expect(scratch.innerHTML).to.equal(getDynamicListHtml());
+
+ let input = focusInputById();
+ prepend();
+ rerender();
+
+ expect(scratch.innerHTML).to.equal(getDynamicListHtml());
+ validateFocus(input, 'add input before');
+
+ input = focusInputById();
+ append();
+ rerender();
+
+ expect(scratch.innerHTML).to.equal(getDynamicListHtml());
+ validateFocus(input, 'add input after');
+
+ input = focusInputById();
+ prepend();
+ rerender();
+
+ expect(scratch.innerHTML).to.equal(getDynamicListHtml());
+ validateFocus(input, 'add input first place');
+
+ input = focusInputById();
+ prepend();
+ rerender();
+
+ expect(scratch.innerHTML).to.equal(getDynamicListHtml());
+ validateFocus(input, 'add input before');
+ });
+
+ it('should maintain focus when hydrating', () => {
+ const html = div([span('1'), span('2'), span('3'), inputStr()]);
+
+ scratch.innerHTML = html;
+ const input = focusInput();
+
+ hydrate(
+ <List>
+ <ListItem>1</ListItem>
+ <ListItem>2</ListItem>
+ <ListItem>3</ListItem>
+ <Input />
+ </List>,
+ scratch
+ );
+
+ expect(scratch.innerHTML).to.equal(html);
+ validateFocus(input);
+ });
+
+ it('should keep focus in Fragments', () => {
+ /** @type {HTMLInputElement} */
+ let input;
+
+ /** @type {() => void} */
+ let updateState;
+
+ class App extends Component {
+ constructor() {
+ super();
+ this.state = { active: false };
+ updateState = () => this.setState(prev => ({ active: !prev.active }));
+ }
+
+ render() {
+ return (
+ <div>
+ <h1>Heading</h1>
+ {!this.state.active ? (
+ <Fragment>
+ foobar
+ <Fragment>
+ Hello World
+ <h2>yo</h2>
+ </Fragment>
+ <input type="text" ref={i => (input = i)} />
+ </Fragment>
+ ) : (
+ <Fragment>
+ <Fragment>
+ Hello World
+ <h2>yo</h2>
+ </Fragment>
+ foobar
+ <input type="text" ref={i => (input = i)} />
+ </Fragment>
+ )}
+ </div>
+ );
+ }
+ }
+
+ render(<App />, scratch);
+
+ input.focus();
+ updateState();
+
+ expect(document.activeElement).to.equalNode(input, 'Before rerender');
+ rerender();
+
+ expect(scratch.innerHTML).to.equal(
+ div([h1('Heading'), 'Hello World', h2('yo'), 'foobar', inputStr()])
+ );
+ expect(document.activeElement).to.equalNode(input, 'After rerender');
+ });
+
+ it('should keep text selection', () => {
+ /** @type {HTMLInputElement} */
+ let input;
+
+ /** @type {() => void} */
+ let updateState;
+
+ class App extends Component {
+ constructor() {
+ super();
+ this.state = { active: false };
+ updateState = () => this.setState(prev => ({ active: !prev.active }));
+ }
+
+ render() {
+ return (
+ <div>
+ <h1>Heading</h1>
+ {!this.state.active ? (
+ <Fragment>
+ foobar
+ <Fragment>
+ Hello World
+ <h2>yo</h2>
+ </Fragment>
+ <input type="text" ref={i => (input = i)} value="foobar" />
+ </Fragment>
+ ) : (
+ <Fragment>
+ <Fragment>
+ Hello World
+ <h2>yo</h2>
+ </Fragment>
+ foobar
+ <input type="text" ref={i => (input = i)} value="foobar" />
+ </Fragment>
+ )}
+ </div>
+ );
+ }
+ }
+
+ render(<App />, scratch);
+
+ input.focus();
+ input.setSelectionRange(2, 5);
+ updateState();
+
+ expect(document.activeElement).to.equalNode(input, 'Before rerender');
+ rerender();
+
+ expect(scratch.innerHTML).to.equal(
+ div([h1('Heading'), 'Hello World', h2('yo'), 'foobar', inputStr()])
+ );
+ expect(input.selectionStart).to.equal(2);
+ expect(input.selectionEnd).to.equal(5);
+ expect(document.activeElement).to.equalNode(input, 'After rerender');
+ });
+});
diff --git a/preact/test/browser/fragments.test.js b/preact/test/browser/fragments.test.js
new file mode 100644
index 0000000..882aeed
--- /dev/null
+++ b/preact/test/browser/fragments.test.js
@@ -0,0 +1,2805 @@
+import { setupRerender } from 'preact/test-utils';
+import { createElement, render, Component, Fragment } from 'preact';
+import { setupScratch, teardown } from '../_util/helpers';
+import { span, div, ul, ol, li, section } from '../_util/dom';
+import { logCall, clearLog, getLog } from '../_util/logCall';
+
+/** @jsx createElement */
+/* eslint-disable react/jsx-boolean-value */
+
+describe('Fragment', () => {
+ /** @type {HTMLDivElement} */
+ let scratch;
+
+ /** @type {() => void} */
+ let rerender;
+
+ let ops = [];
+
+ function expectDomLogToBe(expectedOperations, message) {
+ expect(getLog()).to.deep.equal(expectedOperations, message);
+ }
+
+ class Stateful extends Component {
+ componentDidUpdate() {
+ ops.push('Update Stateful');
+ }
+ render() {
+ return <div>Hello</div>;
+ }
+ }
+
+ let resetInsertBefore;
+ let resetAppendChild;
+ let resetRemoveChild;
+
+ before(() => {
+ resetInsertBefore = logCall(Element.prototype, 'insertBefore');
+ resetAppendChild = logCall(Element.prototype, 'appendChild');
+ resetRemoveChild = logCall(Element.prototype, 'removeChild');
+ // logCall(CharacterData.prototype, 'remove');
+ // TODO: Consider logging setting set data
+ // ```
+ // var orgData = Object.getOwnPropertyDescriptor(CharacterData.prototype, 'data')
+ // Object.defineProperty(CharacterData.prototype, 'data', {
+ // ...orgData,
+ // get() { return orgData.get.call(this) },
+ // set(value) { console.log('setData', value); orgData.set.call(this, value); }
+ // });
+ // ```
+ });
+
+ after(() => {
+ resetInsertBefore();
+ resetAppendChild();
+ resetRemoveChild();
+ });
+
+ beforeEach(() => {
+ scratch = setupScratch();
+ rerender = setupRerender();
+ ops = [];
+
+ clearLog();
+ });
+
+ afterEach(() => {
+ teardown(scratch);
+ });
+
+ it('should not render empty Fragment', () => {
+ render(<Fragment />, scratch);
+ expect(scratch.innerHTML).to.equal('');
+ });
+
+ it('should render a single child', () => {
+ clearLog();
+ render(
+ <Fragment>
+ <span>foo</span>
+ </Fragment>,
+ scratch
+ );
+
+ expect(scratch.innerHTML).to.equal('<span>foo</span>');
+ expectDomLogToBe([
+ '<span>.appendChild(#text)',
+ '<div>.appendChild(<span>foo)'
+ ]);
+ });
+
+ it('should render multiple children via noop renderer', () => {
+ render(
+ <Fragment>
+ hello <span>world</span>
+ </Fragment>,
+ scratch
+ );
+
+ expect(scratch.innerHTML).to.equal('hello <span>world</span>');
+ });
+
+ it('should not crash with null as last child', () => {
+ let fn = () => {
+ render(
+ <Fragment>
+ <span>world</span>
+ {null}
+ </Fragment>,
+ scratch
+ );
+ };
+ expect(fn).not.to.throw();
+ expect(scratch.innerHTML).to.equal('<span>world</span>');
+
+ render(
+ <Fragment>
+ <span>world</span>
+ <p>Hello</p>
+ </Fragment>,
+ scratch
+ );
+ expect(scratch.innerHTML).to.equal('<span>world</span><p>Hello</p>');
+
+ expect(fn).not.to.throw();
+ expect(scratch.innerHTML).to.equal('<span>world</span>');
+
+ render(
+ <Fragment>
+ <span>world</span>
+ {null}
+ <span>world</span>
+ </Fragment>,
+ scratch
+ );
+ expect(scratch.innerHTML).to.equal('<span>world</span><span>world</span>');
+
+ render(
+ <Fragment>
+ <span>world</span>
+ Hello
+ <span>world</span>
+ </Fragment>,
+ scratch
+ );
+ expect(scratch.innerHTML).to.equal(
+ '<span>world</span>Hello<span>world</span>'
+ );
+ });
+
+ it('should handle reordering components that return Fragments #1325', () => {
+ class X extends Component {
+ render() {
+ return <Fragment>{this.props.children}</Fragment>;
+ }
+ }
+
+ class App extends Component {
+ render(props) {
+ if (this.props.i === 0) {
+ return (
+ <div>
+ <X key={1}>1</X>
+ <X key={2}>2</X>
+ </div>
+ );
+ }
+ return (
+ <div>
+ <X key={2}>2</X>
+ <X key={1}>1</X>
+ </div>
+ );
+ }
+ }
+
+ render(<App i={0} />, scratch);
+ expect(scratch.textContent).to.equal('12');
+ render(<App i={1} />, scratch);
+ expect(scratch.textContent).to.equal('21');
+ });
+
+ it('should handle changing node type within a Component that returns a Fragment #1326', () => {
+ class X extends Component {
+ render() {
+ return this.props.children;
+ }
+ }
+
+ /** @type {(newState: any) => void} */
+ let setState;
+ class App extends Component {
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = { i: 0 };
+ setState = this.setState.bind(this);
+ }
+
+ render() {
+ if (this.state.i === 0) {
+ return (
+ <div>
+ <X>
+ <span>1</span>
+ </X>
+ <X>
+ <span>2</span>
+ <span>2</span>
+ </X>
+ </div>
+ );
+ }
+
+ return (
+ <div>
+ <X>
+ <div>1</div>
+ </X>
+ <X>
+ <span>2</span>
+ <span>2</span>
+ </X>
+ </div>
+ );
+ }
+ }
+
+ render(<App />, scratch);
+ expect(scratch.innerHTML).to.equal(div([span(1), span(2), span(2)]));
+
+ setState({ i: 1 });
+
+ clearLog();
+ rerender();
+
+ expect(scratch.innerHTML).to.equal(div([div(1), span(2), span(2)]));
+ expectDomLogToBe([
+ '<div>.appendChild(#text)',
+ '<div>122.insertBefore(<div>1, <span>1)',
+ '<span>1.remove()'
+ ]);
+ });
+
+ it('should preserve state of children with 1 level nesting', () => {
+ function Foo({ condition }) {
+ return condition ? (
+ <Stateful key="a" />
+ ) : (
+ <Fragment>
+ <Stateful key="a" />
+ <div key="b">World</div>
+ </Fragment>
+ );
+ }
+
+ render(<Foo condition={true} />, scratch);
+ render(<Foo condition={false} />, scratch);
+
+ expect(ops).to.deep.equal(['Update Stateful']);
+ expect(scratch.innerHTML).to.deep.equal('<div>Hello</div><div>World</div>');
+
+ render(<Foo condition={true} />, scratch);
+
+ expect(ops).to.deep.equal(['Update Stateful', 'Update Stateful']);
+ expect(scratch.innerHTML).to.deep.equal('<div>Hello</div>');
+ });
+
+ it('should preserve state between top-level fragments', () => {
+ function Foo({ condition }) {
+ return condition ? (
+ <Fragment>
+ <Stateful />
+ </Fragment>
+ ) : (
+ <Fragment>
+ <Stateful />
+ </Fragment>
+ );
+ }
+
+ render(<Foo condition={true} />, scratch);
+
+ clearLog();
+ render(<Foo condition={false} />, scratch);
+
+ expect(ops).to.deep.equal(['Update Stateful']);
+ expect(scratch.innerHTML).to.equal('<div>Hello</div>');
+ expectDomLogToBe([]);
+
+ clearLog();
+ render(<Foo condition={true} />, scratch);
+
+ expect(ops).to.deep.equal(['Update Stateful', 'Update Stateful']);
+ expect(scratch.innerHTML).to.equal('<div>Hello</div>');
+ expectDomLogToBe([]);
+ });
+
+ it('should preserve state of children nested at same level', () => {
+ function Foo({ condition }) {
+ return condition ? (
+ <Fragment>
+ <Fragment>
+ <Fragment>
+ <Stateful key="a" />
+ </Fragment>
+ </Fragment>
+ </Fragment>
+ ) : (
+ <Fragment>
+ <Fragment>
+ <Fragment>
+ <div />
+ <Stateful key="a" />
+ </Fragment>
+ </Fragment>
+ </Fragment>
+ );
+ }
+
+ render(<Foo condition={true} />, scratch);
+
+ clearLog();
+ render(<Foo condition={false} />, scratch);
+
+ expect(ops).to.deep.equal(['Update Stateful']);
+ expect(scratch.innerHTML).to.equal('<div></div><div>Hello</div>');
+ expectDomLogToBe(['<div>Hello.insertBefore(<div>, <div>Hello)']);
+
+ clearLog();
+ render(<Foo condition={true} />, scratch);
+
+ expect(ops).to.deep.equal(['Update Stateful', 'Update Stateful']);
+ expect(scratch.innerHTML).to.equal('<div>Hello</div>');
+ expectDomLogToBe(['<div>.remove()']);
+ });
+
+ it('should not preserve state in non-top-level fragment nesting', () => {
+ function Foo({ condition }) {
+ return condition ? (
+ <Fragment>
+ <Fragment>
+ <Stateful key="a" />
+ </Fragment>
+ </Fragment>
+ ) : (
+ <Fragment>
+ <Stateful key="a" />
+ </Fragment>
+ );
+ }
+
+ render(<Foo condition={true} />, scratch);
+
+ clearLog();
+ render(<Foo condition={false} />, scratch);
+
+ expect(ops).to.deep.equal([]);
+ expect(scratch.innerHTML).to.equal('<div>Hello</div>');
+ expectDomLogToBe([
+ '<div>.appendChild(#text)',
+ '<div>Hello.insertBefore(<div>Hello, <div>Hello)',
+ '<div>Hello.remove()'
+ ]);
+
+ clearLog();
+ render(<Foo condition={true} />, scratch);
+
+ expect(ops).to.deep.equal([]);
+ expect(scratch.innerHTML).to.equal('<div>Hello</div>');
+ expectDomLogToBe([
+ '<div>.appendChild(#text)',
+ // Re-append the Stateful DOM since it has been re-parented
+ '<div>Hello.insertBefore(<div>Hello, <div>Hello)',
+ '<div>Hello.remove()'
+ ]);
+ });
+
+ it('should not preserve state of children if nested 2 levels without siblings', () => {
+ function Foo({ condition }) {
+ return condition ? (
+ <Stateful key="a" />
+ ) : (
+ <Fragment>
+ <Fragment>
+ <Stateful key="a" />
+ </Fragment>
+ </Fragment>
+ );
+ }
+
+ render(<Foo condition={true} />, scratch);
+
+ clearLog();
+ render(<Foo condition={false} />, scratch);
+
+ expect(ops).to.deep.equal([]);
+ expect(scratch.innerHTML).to.equal('<div>Hello</div>');
+ expectDomLogToBe([
+ '<div>.appendChild(#text)',
+ '<div>Hello.insertBefore(<div>Hello, <div>Hello)',
+ '<div>Hello.remove()'
+ ]);
+
+ clearLog();
+ render(<Foo condition={true} />, scratch);
+
+ expect(ops).to.deep.equal([]);
+ expect(scratch.innerHTML).to.equal('<div>Hello</div>');
+ expectDomLogToBe([
+ '<div>.appendChild(#text)',
+ '<div>Hello.insertBefore(<div>Hello, <div>Hello)',
+ '<div>Hello.remove()'
+ ]);
+ });
+
+ it('should just render children for fragments', () => {
+ class Comp extends Component {
+ render() {
+ return (
+ <Fragment>
+ <div>Child1</div>
+ <div>Child2</div>
+ </Fragment>
+ );
+ }
+ }
+
+ render(<Comp />, scratch);
+ expect(scratch.innerHTML).to.equal('<div>Child1</div><div>Child2</div>');
+ });
+
+ it('should not preserve state of children if nested 2 levels with siblings', () => {
+ function Foo({ condition }) {
+ return condition ? (
+ <Stateful key="a" />
+ ) : (
+ <Fragment>
+ <Fragment>
+ <Stateful key="a" />
+ </Fragment>
+ <div />
+ </Fragment>
+ );
+ }
+
+ render(<Foo condition={true} />, scratch);
+ render(<Foo condition={false} />, scratch);
+
+ expect(ops).to.deep.equal([]);
+ expect(scratch.innerHTML).to.equal('<div>Hello</div><div></div>');
+
+ render(<Foo condition={true} />, scratch);
+
+ expect(ops).to.deep.equal([]);
+ expect(scratch.innerHTML).to.equal('<div>Hello</div>');
+ });
+
+ it('should preserve state between array nested in fragment and fragment', () => {
+ // In this test case, the children of the Fragment in Foo end up being the same when flattened.
+ //
+ // When condition == true, the children of the Fragment are a Stateful VNode.
+ // When condition == false, the children of the Fragment are an Array containing a single
+ // Stateful VNode.
+ //
+ // However, when each of these are flattened (in flattenChildren), they both become
+ // an Array containing a single Stateful VNode. So when diff'ed they are compared together
+ // and the state of Stateful is preserved
+
+ function Foo({ condition }) {
+ return condition ? (
+ <Fragment>
+ <Stateful key="a" />
+ </Fragment>
+ ) : (
+ <Fragment>{[<Stateful key="a" />]}</Fragment>
+ );
+ }
+
+ render(<Foo condition={true} />, scratch);
+ render(<Foo condition={false} />, scratch);
+
+ expect(ops).to.deep.equal(['Update Stateful']);
+ expect(scratch.innerHTML).to.equal('<div>Hello</div>');
+
+ render(<Foo condition={true} />, scratch);
+
+ expect(ops).to.deep.equal(['Update Stateful', 'Update Stateful']);
+ expect(scratch.innerHTML).to.equal('<div>Hello</div>');
+ });
+
+ it('should preserve state between top level fragment and array', () => {
+ function Foo({ condition }) {
+ return condition ? (
+ [<Stateful key="a" />]
+ ) : (
+ <Fragment>
+ <Stateful key="a" />
+ </Fragment>
+ );
+ }
+
+ render(<Foo condition={true} />, scratch);
+ render(<Foo condition={false} />, scratch);
+
+ expect(ops).to.deep.equal(['Update Stateful']);
+ expect(scratch.innerHTML).to.equal('<div>Hello</div>');
+
+ render(<Foo condition={true} />, scratch);
+
+ expect(ops).to.deep.equal(['Update Stateful', 'Update Stateful']);
+ expect(scratch.innerHTML).to.equal('<div>Hello</div>');
+ });
+
+ it('should not preserve state between array nested in fragment and double nested fragment', () => {
+ // In this test case, the children of the Fragment in Foo end up being the different when flattened.
+ //
+ // When condition == true, the children of the Fragment are an Array of Stateful VNode.
+ // When condition == false, the children of the Fragment are another Fragment whose children are
+ // a single Stateful VNode.
+ //
+ // When each of these are flattened (in flattenChildren), the first Fragment stays the same
+ // (Fragment -> [Stateful]). The second Fragment also doesn't change (flattening doesn't erase
+ // Fragments) so it remains Fragment -> Fragment -> Stateful. Therefore when diff'ed these Fragments
+ // separate the two Stateful VNodes into different trees and state is not preserved between them.
+
+ function Foo({ condition }) {
+ return condition ? (
+ <Fragment>{[<Stateful key="a" />]}</Fragment>
+ ) : (
+ <Fragment>
+ <Fragment>
+ <Stateful key="a" />
+ </Fragment>
+ </Fragment>
+ );
+ }
+
+ render(<Foo condition={true} />, scratch);
+ render(<Foo condition={false} />, scratch);
+
+ expect(ops).to.deep.equal([]);
+ expect(scratch.innerHTML).to.equal('<div>Hello</div>');
+
+ render(<Foo condition={true} />, scratch);
+
+ expect(ops).to.deep.equal([]);
+ expect(scratch.innerHTML).to.equal('<div>Hello</div>');
+ });
+
+ it('should not preserve state between array nested in fragment and double nested array', () => {
+ function Foo({ condition }) {
+ return condition ? (
+ <Fragment>{[<Stateful key="a" />]}</Fragment>
+ ) : (
+ [[<Stateful key="a" />]]
+ );
+ }
+
+ render(<Foo condition={true} />, scratch);
+ render(<Foo condition={false} />, scratch);
+
+ expect(ops).to.deep.equal([]);
+ expect(scratch.innerHTML).to.equal('<div>Hello</div>');
+
+ render(<Foo condition={true} />, scratch);
+
+ expect(ops).to.deep.equal([]);
+ expect(scratch.innerHTML).to.equal('<div>Hello</div>');
+ });
+
+ it('should preserve state between double nested fragment and double nested array', () => {
+ function Foo({ condition }) {
+ return condition ? (
+ <Fragment>
+ <Fragment>
+ <Stateful key="a" />
+ </Fragment>
+ </Fragment>
+ ) : (
+ [[<Stateful key="a" />]]
+ );
+ }
+
+ render(<Foo condition={true} />, scratch);
+ render(<Foo condition={false} />, scratch);
+
+ expect(ops).to.deep.equal(['Update Stateful']);
+ expect(scratch.innerHTML).to.equal('<div>Hello</div>');
+
+ render(<Foo condition={true} />, scratch);
+
+ expect(ops).to.deep.equal(['Update Stateful', 'Update Stateful']);
+ expect(scratch.innerHTML).to.equal('<div>Hello</div>');
+ });
+
+ it('should not preserve state of children when the keys are different', () => {
+ function Foo({ condition }) {
+ return condition ? (
+ <Fragment key="a">
+ <Stateful />
+ </Fragment>
+ ) : (
+ <Fragment key="b">
+ <Stateful />
+ <span>World</span>
+ </Fragment>
+ );
+ }
+
+ render(<Foo condition={true} />, scratch);
+ render(<Foo condition={false} />, scratch);
+
+ expect(ops).to.deep.equal([]);
+ expect(scratch.innerHTML).to.equal('<div>Hello</div><span>World</span>');
+
+ render(<Foo condition={true} />, scratch);
+
+ expect(ops).to.deep.equal([]);
+ expect(scratch.innerHTML).to.equal('<div>Hello</div>');
+ });
+
+ it('should not preserve state between unkeyed and keyed fragment', () => {
+ function Foo({ condition }) {
+ return condition ? (
+ <Fragment key="a">
+ <Stateful />
+ </Fragment>
+ ) : (
+ <Fragment>
+ <Stateful />
+ </Fragment>
+ );
+ }
+
+ // React & Preact: has the same behavior for components
+ // https://codesandbox.io/s/57prmy5mx
+ render(<Foo condition={true} />, scratch);
+ render(<Foo condition={false} />, scratch);
+
+ expect(ops).to.deep.equal([]);
+ expect(scratch.innerHTML).to.equal('<div>Hello</div>');
+
+ render(<Foo condition={true} />, scratch);
+
+ expect(ops).to.deep.equal([]);
+ expect(scratch.innerHTML).to.equal('<div>Hello</div>');
+ });
+
+ it('should preserve state with reordering in multiple levels', () => {
+ function Foo({ condition }) {
+ return condition ? (
+ <div>
+ <Fragment key="c">
+ <div>foo</div>
+ <div key="b">
+ <Stateful key="a" />
+ </div>
+ </Fragment>
+ <div>boop</div>
+ </div>
+ ) : (
+ <div>
+ <div>beep</div>
+ <Fragment key="c">
+ <div key="b">
+ <Stateful key="a" />
+ </div>
+ <div>bar</div>
+ </Fragment>
+ </div>
+ );
+ }
+
+ const htmlForTrue = div([div('foo'), div(div('Hello')), div('boop')]);
+
+ const htmlForFalse = div([div('beep'), div(div('Hello')), div('bar')]);
+
+ clearLog();
+ render(<Foo condition={true} />, scratch);
+
+ expect(scratch.innerHTML).to.equal(htmlForTrue);
+
+ clearLog();
+ render(<Foo condition={false} />, scratch);
+
+ expect(ops).to.deep.equal(['Update Stateful']);
+ expect(scratch.innerHTML).to.equal(htmlForFalse);
+ expectDomLogToBe(
+ [
+ '<div>fooHellobeep.insertBefore(<div>beep, <div>foo)',
+ '<div>beepbarHello.appendChild(<div>bar)'
+ ],
+ 'rendering true to false'
+ );
+
+ clearLog();
+ render(<Foo condition={true} />, scratch);
+
+ expect(ops).to.deep.equal(['Update Stateful', 'Update Stateful']);
+ expect(scratch.innerHTML).to.equal(htmlForTrue);
+ expectDomLogToBe(
+ [
+ '<div>beepHellofoo.insertBefore(<div>foo, <div>beep)',
+ '<div>fooboopHello.appendChild(<div>boop)'
+ ],
+ 'rendering false to true'
+ );
+ });
+
+ it('should not preserve state when switching between a keyed fragment and an array', () => {
+ function Foo({ condition }) {
+ return condition ? (
+ <div>
+ {
+ <Fragment key="foo">
+ <span>1</span>
+ <Stateful />
+ </Fragment>
+ }
+ <span>2</span>
+ </div>
+ ) : (
+ <div>
+ {[<span>1</span>, <Stateful />]}
+ <span>2</span>
+ </div>
+ );
+ }
+
+ const html = div([span('1'), div('Hello'), span('2')]);
+
+ clearLog();
+ render(<Foo condition={true} />, scratch);
+
+ clearLog();
+ render(<Foo condition={false} />, scratch);
+
+ expect(ops).to.deep.equal([]); // Component should not have updated (empty op log)
+ expect(scratch.innerHTML).to.equal(html);
+ expectDomLogToBe([
+ '<span>.appendChild(#text)',
+ '<div>1Hello2.insertBefore(<span>1, <span>1)',
+ '<div>.appendChild(#text)',
+ '<div>11Hello2.insertBefore(<div>Hello, <span>1)',
+ '<div>1Hello1Hello2.insertBefore(<span>2, <span>1)',
+ '<span>1.remove()',
+ '<div>Hello.remove()'
+ ]);
+
+ clearLog();
+ render(<Foo condition={true} />, scratch);
+
+ expect(ops).to.deep.equal([]); // Component should not have updated (empty op log)
+ expect(scratch.innerHTML).to.equal(html);
+ expectDomLogToBe([
+ '<span>.appendChild(#text)',
+ '<div>1Hello2.insertBefore(<span>1, <span>1)',
+ '<div>.appendChild(#text)',
+ '<div>11Hello2.insertBefore(<div>Hello, <span>1)',
+ '<div>1Hello1Hello2.insertBefore(<span>2, <span>1)',
+ '<span>1.remove()',
+ '<div>Hello.remove()'
+ ]);
+ });
+
+ it('should preserve state when it does not change positions', () => {
+ function Foo({ condition }) {
+ return condition
+ ? [
+ <span />,
+ <Fragment>
+ <Stateful />
+ </Fragment>
+ ]
+ : [
+ <span />,
+ <Fragment>
+ <Stateful />
+ </Fragment>
+ ];
+ }
+
+ render(<Foo condition={true} />, scratch);
+ render(<Foo condition={false} />, scratch);
+
+ expect(ops).to.deep.equal(['Update Stateful']);
+ expect(scratch.innerHTML).to.equal('<span></span><div>Hello</div>');
+
+ render(<Foo condition={true} />, scratch);
+
+ expect(ops).to.deep.equal(['Update Stateful', 'Update Stateful']);
+ expect(scratch.innerHTML).to.equal('<span></span><div>Hello</div>');
+ });
+
+ it('should render nested Fragments', () => {
+ clearLog();
+ render(
+ <Fragment>
+ spam
+ <Fragment>foo</Fragment>
+ <Fragment />
+ bar
+ </Fragment>,
+ scratch
+ );
+
+ expect(scratch.innerHTML).to.equal('spamfoobar');
+ expectDomLogToBe([
+ '<div>.appendChild(#text)',
+ '<div>spam.appendChild(#text)',
+ '<div>spamfoo.appendChild(#text)'
+ ]);
+
+ clearLog();
+ render(
+ <Fragment>
+ <Fragment>foo</Fragment>
+ <Fragment>bar</Fragment>
+ </Fragment>,
+ scratch
+ );
+
+ expect(scratch.innerHTML).to.equal('foobar');
+ expectDomLogToBe([
+ '<div>spamfoobar.insertBefore(#text, #text)',
+ '#text.remove()',
+ '#text.remove()'
+ ]);
+ });
+
+ it('should render nested Fragments with siblings', () => {
+ clearLog();
+ render(
+ <div>
+ <div>0</div>
+ <div>1</div>
+ <Fragment>
+ <Fragment>
+ <div>2</div>
+ <div>3</div>
+ </Fragment>
+ </Fragment>
+ <div>4</div>
+ <div>5</div>
+ </div>,
+ scratch
+ );
+
+ expect(scratch.innerHTML).to.equal(
+ div([div(0), div(1), div(2), div(3), div(4), div(5)])
+ );
+ });
+
+ it('should respect keyed Fragments', () => {
+ /** @type {() => void} */
+ let update;
+
+ class Comp extends Component {
+ constructor() {
+ super();
+ this.state = { key: 'foo' };
+ update = () => this.setState({ key: 'bar' });
+ }
+
+ render() {
+ return <Fragment key={this.state.key}>foo</Fragment>;
+ }
+ }
+ render(<Comp />, scratch);
+ expect(scratch.innerHTML).to.equal('foo');
+
+ update();
+ rerender();
+
+ expect(scratch.innerHTML).to.equal('foo');
+ });
+
+ it('should support conditionally rendered children', () => {
+ /** @type {() => void} */
+ let update;
+
+ class Comp extends Component {
+ constructor() {
+ super();
+ this.state = { value: true };
+ update = () => this.setState({ value: !this.state.value });
+ }
+
+ render() {
+ return (
+ <Fragment>
+ <span>0</span>
+ {this.state.value && 'foo'}
+ <span>1</span>
+ </Fragment>
+ );
+ }
+ }
+
+ const html = contents => span('0') + contents + span('1');
+
+ render(<Comp />, scratch);
+ expect(scratch.innerHTML).to.equal(html('foo'));
+
+ update();
+ rerender();
+
+ expect(scratch.innerHTML).to.equal(html(''));
+
+ update();
+ rerender();
+ expect(scratch.innerHTML).to.equal(html('foo'));
+ });
+
+ it('can modify the children of a Fragment', () => {
+ /** @type {() => void} */
+ let push;
+
+ class List extends Component {
+ constructor() {
+ super();
+ this.state = { values: [0, 1, 2] };
+ push = () =>
+ this.setState({
+ values: [...this.state.values, this.state.values.length]
+ });
+ }
+
+ render() {
+ return (
+ <Fragment>
+ {this.state.values.map(value => (
+ <div>{value}</div>
+ ))}
+ </Fragment>
+ );
+ }
+ }
+
+ render(<List />, scratch);
+ expect(scratch.textContent).to.equal('012');
+
+ push();
+ rerender();
+
+ expect(scratch.textContent).to.equal('0123');
+
+ push();
+ rerender();
+
+ expect(scratch.textContent).to.equal('01234');
+ });
+
+ it('should render sibling array children', () => {
+ const Group = ({ title, values }) => (
+ <Fragment>
+ <li>{title}</li>
+ {values.map(value => (
+ <li>{value}</li>
+ ))}
+ </Fragment>
+ );
+
+ const Todo = () => (
+ <ul>
+ <Group title={'A header'} values={['a', 'b']} />
+ <Group title={'A divider'} values={['c', 'd']} />
+ <li>A footer</li>
+ </ul>
+ );
+
+ render(<Todo />, scratch);
+
+ expect(scratch.innerHTML).to.equal(
+ ul([
+ li('A header'),
+ li('a'),
+ li('b'),
+ li('A divider'),
+ li('c'),
+ li('d'),
+ li('A footer')
+ ])
+ );
+ });
+
+ it('should reorder Fragment children', () => {
+ let updateState;
+
+ class App extends Component {
+ constructor() {
+ super();
+ this.state = { active: false };
+ updateState = () => this.setState(prev => ({ active: !prev.active }));
+ }
+
+ render() {
+ return (
+ <div>
+ <h1>Heading</h1>
+ {!this.state.active ? (
+ <Fragment>
+ foobar
+ <Fragment>
+ Hello World
+ <h2>yo</h2>
+ </Fragment>
+ <input type="text" />
+ </Fragment>
+ ) : (
+ <Fragment>
+ <Fragment>
+ Hello World
+ <h2>yo</h2>
+ </Fragment>
+ foobar
+ <input type="text" />
+ </Fragment>
+ )}
+ </div>
+ );
+ }
+ }
+
+ render(<App />, scratch);
+
+ expect(scratch.innerHTML).to.equal(
+ '<div><h1>Heading</h1>foobarHello World<h2>yo</h2><input type="text"></div>'
+ );
+
+ updateState();
+
+ // See "should preserve state between top level fragment and array"
+ // Perhaps rename test to "should reorder **keyed** Fragment children"
+ rerender();
+ expect(scratch.innerHTML).to.equal(
+ '<div><h1>Heading</h1>Hello World<h2>yo</h2>foobar<input type="text"></div>'
+ );
+ });
+
+ it('should render sibling fragments with multiple children in the correct order', () => {
+ render(
+ <ol>
+ <li>0</li>
+ <Fragment>
+ <li>1</li>
+ <li>2</li>
+ </Fragment>
+ <li>3</li>
+ <li>4</li>
+ <Fragment>
+ <li>5</li>
+ <li>6</li>
+ </Fragment>
+ <li>7</li>
+ </ol>,
+ scratch
+ );
+
+ expect(scratch.textContent).to.equal('01234567');
+ });
+
+ it('should support HOCs that return children', () => {
+ const text =
+ "Don't forget to tell these special people in your life just how special they are to you.";
+
+ class BobRossProvider extends Component {
+ getChildContext() {
+ return { text };
+ }
+
+ render(props) {
+ return props.children;
+ }
+ }
+
+ function BobRossConsumer(props, context) {
+ return props.children(context.text);
+ }
+
+ const Say = props => <div>{props.text}</div>;
+
+ const Speak = () => (
+ <Fragment>
+ <span>the top</span>
+ <BobRossProvider>
+ <span>a span</span>
+ <BobRossConsumer>
+ {text => [<Say text={text} />, <Say text={text} />]}
+ </BobRossConsumer>
+ <span>another span</span>
+ </BobRossProvider>
+ <span>a final span</span>
+ </Fragment>
+ );
+
+ render(<Speak />, scratch);
+
+ expect(scratch.innerHTML).to.equal(
+ [
+ span('the top'),
+ span('a span'),
+ div(text),
+ div(text),
+ span('another span'),
+ span('a final span')
+ ].join('')
+ );
+ });
+
+ it('should support conditionally rendered Fragment', () => {
+ const Foo = ({ condition }) => (
+ <ol>
+ <li>0</li>
+ {condition ? (
+ <Fragment>
+ <li>1</li>
+ <li>2</li>
+ </Fragment>
+ ) : (
+ [<li>1</li>, <li>2</li>]
+ )}
+ <li>3</li>
+ </ol>
+ );
+
+ const html = ol([li('0'), li('1'), li('2'), li('3')]);
+
+ clearLog();
+ render(<Foo condition={true} />, scratch);
+ expect(scratch.innerHTML).to.equal(html, 'initial render of true');
+ expectDomLogToBe(
+ [
+ '<li>.appendChild(#text)',
+ '<ol>.appendChild(<li>0)',
+ '<li>.appendChild(#text)',
+ '<ol>0.appendChild(<li>1)',
+ '<li>.appendChild(#text)',
+ '<ol>01.appendChild(<li>2)',
+ '<li>.appendChild(#text)',
+ '<ol>012.appendChild(<li>3)',
+ '<div>.appendChild(<ol>0123)'
+ ],
+ 'initial render of true'
+ );
+
+ clearLog();
+ render(<Foo condition={false} />, scratch);
+ expect(scratch.innerHTML).to.equal(html, 'rendering from true to false');
+ expectDomLogToBe([], 'rendering from true to false');
+
+ clearLog();
+ render(<Foo condition={true} />, scratch);
+ expect(scratch.innerHTML).to.equal(html, 'rendering from false to true');
+ expectDomLogToBe([], 'rendering from false to true');
+ });
+
+ it('should support conditionally rendered Fragment or null', () => {
+ const Foo = ({ condition }) => (
+ <ol>
+ <li>0</li>
+ {condition ? (
+ <Fragment>
+ <li>1</li>
+ <li>2</li>
+ </Fragment>
+ ) : null}
+ <li>3</li>
+ <li>4</li>
+ </ol>
+ );
+
+ const htmlForTrue = ol([li('0'), li('1'), li('2'), li('3'), li('4')]);
+
+ const htmlForFalse = ol([li('0'), li('3'), li('4')]);
+
+ clearLog();
+ render(<Foo condition={true} />, scratch);
+ expect(scratch.innerHTML).to.equal(htmlForTrue, 'initial render of true');
+ expectDomLogToBe(
+ [
+ '<li>.appendChild(#text)',
+ '<ol>.appendChild(<li>0)',
+ '<li>.appendChild(#text)',
+ '<ol>0.appendChild(<li>1)',
+ '<li>.appendChild(#text)',
+ '<ol>01.appendChild(<li>2)',
+ '<li>.appendChild(#text)',
+ '<ol>012.appendChild(<li>3)',
+ '<li>.appendChild(#text)',
+ '<ol>0123.appendChild(<li>4)',
+ '<div>.appendChild(<ol>01234)'
+ ],
+ 'initial render of true'
+ );
+
+ clearLog();
+ render(<Foo condition={false} />, scratch);
+ expect(scratch.innerHTML).to.equal(
+ htmlForFalse,
+ 'rendering from true to false'
+ );
+ expectDomLogToBe(
+ ['<li>1.remove()', '<li>2.remove()'],
+ 'rendering from true to false'
+ );
+
+ clearLog();
+ render(<Foo condition={true} />, scratch);
+ expect(scratch.innerHTML).to.equal(
+ htmlForTrue,
+ 'rendering from false to true'
+ );
+ expectDomLogToBe(
+ [
+ '<li>.appendChild(#text)',
+ '<ol>034.insertBefore(<li>1, <li>3)',
+ '<li>.appendChild(#text)',
+ '<ol>0134.insertBefore(<li>2, <li>3)'
+ ],
+ 'rendering from false to true'
+ );
+ });
+
+ it('should support moving Fragments between beginning and end', () => {
+ const Foo = ({ condition }) => (
+ <ol>
+ {condition
+ ? [
+ <li>0</li>,
+ <li>1</li>,
+ <li>2</li>,
+ <li>3</li>,
+ <Fragment>
+ <li>4</li>
+ <li>5</li>
+ </Fragment>
+ ]
+ : [
+ <Fragment>
+ <li>4</li>
+ <li>5</li>
+ </Fragment>,
+ <li>0</li>,
+ <li>1</li>,
+ <li>2</li>,
+ <li>3</li>
+ ]}
+ </ol>
+ );
+
+ const htmlForTrue = ol([
+ li('0'),
+ li('1'),
+ li('2'),
+ li('3'),
+ li('4'),
+ li('5')
+ ]);
+
+ const htmlForFalse = ol([
+ li('4'),
+ li('5'),
+ li('0'),
+ li('1'),
+ li('2'),
+ li('3')
+ ]);
+
+ clearLog();
+ render(<Foo condition={true} />, scratch);
+ expect(scratch.innerHTML).to.equal(htmlForTrue, 'initial render of true');
+
+ clearLog();
+ render(<Foo condition={false} />, scratch);
+ expect(scratch.innerHTML).to.equal(
+ htmlForFalse,
+ 'rendering from true to false'
+ );
+ expectDomLogToBe([
+ '<ol>012345.insertBefore(<li>4, <li>0)',
+ '<ol>401235.insertBefore(<li>5, <li>0)',
+ // TODO: Hmmm why does this extra append happen?
+ '<ol>453012.appendChild(<li>3)'
+ ]);
+
+ clearLog();
+ render(<Foo condition={true} />, scratch);
+ expect(scratch.innerHTML).to.equal(
+ htmlForTrue,
+ 'rendering from false to true'
+ );
+ expectDomLogToBe([
+ '<ol>450123.appendChild(<li>4)',
+ '<ol>501234.appendChild(<li>5)'
+ ]);
+ });
+
+ it('should support conditional beginning and end Fragments', () => {
+ const Foo = ({ condition }) => (
+ <ol>
+ {condition ? (
+ <Fragment>
+ <li>0</li>
+ <li>1</li>
+ </Fragment>
+ ) : null}
+ <li>2</li>
+ <li>2</li>
+ {condition ? null : (
+ <Fragment>
+ <li>3</li>
+ <li>4</li>
+ </Fragment>
+ )}
+ </ol>
+ );
+
+ const htmlForTrue = ol([li(0), li(1), li(2), li(2)]);
+
+ const htmlForFalse = ol([li(2), li(2), li(3), li(4)]);
+
+ clearLog();
+ render(<Foo condition={true} />, scratch);
+ expect(scratch.innerHTML).to.equal(htmlForTrue, 'initial render of true');
+
+ clearLog();
+ render(<Foo condition={false} />, scratch);
+ expect(scratch.innerHTML).to.equal(
+ htmlForFalse,
+ 'rendering from true to false'
+ );
+ expectDomLogToBe([
+ // Mount 3 & 4
+ '<li>.appendChild(#text)',
+ '<ol>0122.appendChild(<li>3)',
+ '<li>.appendChild(#text)',
+ '<ol>01223.appendChild(<li>4)',
+ // Remove 1 & 2 (replaced with null)
+ '<li>0.remove()',
+ '<li>1.remove()'
+ ]);
+
+ clearLog();
+ render(<Foo condition={true} />, scratch);
+ expect(scratch.innerHTML).to.equal(
+ htmlForTrue,
+ 'rendering from false to true'
+ );
+ expectDomLogToBe([
+ // Insert 0 and 1
+ '<li>.appendChild(#text)',
+ '<ol>2234.insertBefore(<li>0, <li>2)',
+ '<li>.appendChild(#text)',
+ '<ol>02234.insertBefore(<li>1, <li>2)',
+ // Remove 3 & 4 (replaced by null)
+ '<li>3.remove()',
+ '<li>4.remove()'
+ ]);
+ });
+
+ it('should support nested conditional beginning and end Fragments', () => {
+ const Foo = ({ condition }) => (
+ <ol>
+ {condition ? (
+ <Fragment>
+ <Fragment>
+ <Fragment>
+ <li>0</li>
+ <li>1</li>
+ </Fragment>
+ </Fragment>
+ </Fragment>
+ ) : null}
+ <li>2</li>
+ <li>3</li>
+ {condition ? null : (
+ <Fragment>
+ <Fragment>
+ <Fragment>
+ <li>4</li>
+ <li>5</li>
+ </Fragment>
+ </Fragment>
+ </Fragment>
+ )}
+ </ol>
+ );
+
+ const htmlForTrue = ol([li(0), li(1), li(2), li(3)]);
+
+ const htmlForFalse = ol([li(2), li(3), li(4), li(5)]);
+
+ clearLog();
+ render(<Foo condition={true} />, scratch);
+ expect(scratch.innerHTML).to.equal(htmlForTrue, 'initial render of true');
+
+ clearLog();
+ render(<Foo condition={false} />, scratch);
+ expect(scratch.innerHTML).to.equal(
+ htmlForFalse,
+ 'rendering from true to false'
+ );
+ expectDomLogToBe([
+ // Mount 4 & 5
+ '<li>.appendChild(#text)',
+ '<ol>0123.appendChild(<li>4)',
+ '<li>.appendChild(#text)',
+ '<ol>01234.appendChild(<li>5)',
+ // Remove 1 & 2 (replaced with null)
+ '<li>0.remove()',
+ '<li>1.remove()'
+ ]);
+
+ clearLog();
+ render(<Foo condition={true} />, scratch);
+ expect(scratch.innerHTML).to.equal(
+ htmlForTrue,
+ 'rendering from false to true'
+ );
+ expectDomLogToBe([
+ // Insert 0 and 1 back into the DOM
+ '<li>.appendChild(#text)',
+ '<ol>2345.insertBefore(<li>0, <li>2)',
+ '<li>.appendChild(#text)',
+ '<ol>02345.insertBefore(<li>1, <li>2)',
+ // Remove 4 & 5 (replaced by null)
+ '<li>4.remove()',
+ '<li>5.remove()'
+ ]);
+ });
+
+ it('should preserve state with reordering in multiple levels with mixed # of Fragment siblings', () => {
+ // Also fails if the # of divs outside the Fragment equals or exceeds
+ // the # inside the Fragment for both conditions
+ function Foo({ condition }) {
+ return condition ? (
+ <div>
+ <Fragment key="c">
+ <div>foo</div>
+ <div key="b">
+ <Stateful key="a" />
+ </div>
+ </Fragment>
+ <div>boop</div>
+ <div>boop</div>
+ </div>
+ ) : (
+ <div>
+ <div>beep</div>
+ <Fragment key="c">
+ <div key="b">
+ <Stateful key="a" />
+ </div>
+ <div>bar</div>
+ </Fragment>
+ </div>
+ );
+ }
+
+ const htmlForTrue = div([
+ div('foo'),
+ div(div('Hello')),
+ div('boop'),
+ div('boop')
+ ]);
+
+ const htmlForFalse = div([div('beep'), div(div('Hello')), div('bar')]);
+
+ clearLog();
+ render(<Foo condition={true} />, scratch);
+
+ clearLog();
+ render(<Foo condition={false} />, scratch);
+
+ expect(ops).to.deep.equal(['Update Stateful']);
+ expect(scratch.innerHTML).to.equal(
+ htmlForFalse,
+ 'rendering from true to false'
+ );
+ expectDomLogToBe(
+ [
+ '<div>fooHellobeepboop.insertBefore(<div>Hello, <div>boop)',
+ '<div>barbeepHelloboop.insertBefore(<div>bar, <div>boop)',
+ '<div>boop.remove()'
+ ],
+ 'rendering from true to false'
+ );
+
+ clearLog();
+ render(<Foo condition={true} />, scratch);
+
+ expect(ops).to.deep.equal(['Update Stateful', 'Update Stateful']);
+ expect(scratch.innerHTML).to.equal(
+ htmlForTrue,
+ 'rendering from false to true'
+ );
+ expectDomLogToBe(
+ [
+ '<div>beepHellofoo.insertBefore(<div>foo, <div>beep)',
+ '<div>fooboopHello.appendChild(<div>boop)',
+ '<div>.appendChild(#text)',
+ '<div>fooHelloboop.appendChild(<div>boop)'
+ ],
+ 'rendering from false to true'
+ );
+ });
+
+ it('should preserve state with reordering in multiple levels with lots of Fragment siblings', () => {
+ // Also fails if the # of divs outside the Fragment equals or exceeds
+ // the # inside the Fragment for both conditions
+ function Foo({ condition }) {
+ return condition ? (
+ <div>
+ <Fragment key="c">
+ <div>foo</div>
+ <div key="b">
+ <Stateful key="a" />
+ </div>
+ </Fragment>
+ <div>boop</div>
+ <div>boop</div>
+ <div>boop</div>
+ </div>
+ ) : (
+ <div>
+ <div>beep</div>
+ <div>beep</div>
+ <div>beep</div>
+ <Fragment key="c">
+ <div key="b">
+ <Stateful key="a" />
+ </div>
+ <div>bar</div>
+ </Fragment>
+ </div>
+ );
+ }
+
+ const htmlForTrue = div([
+ div('foo'),
+ div(div('Hello')),
+ div('boop'),
+ div('boop'),
+ div('boop')
+ ]);
+
+ const htmlForFalse = div([
+ div('beep'),
+ div('beep'),
+ div('beep'),
+ div(div('Hello')),
+ div('bar')
+ ]);
+
+ clearLog();
+ render(<Foo condition={true} />, scratch);
+
+ clearLog();
+ render(<Foo condition={false} />, scratch);
+
+ expect(ops).to.deep.equal(['Update Stateful']);
+ expect(scratch.innerHTML).to.equal(
+ htmlForFalse,
+ 'rendering from true to false'
+ );
+ expectDomLogToBe(
+ [
+ '<div>fooHellobeepbeepbeep.appendChild(<div>Hello)',
+ '<div>barbeepbeepbeepHello.appendChild(<div>bar)'
+ ],
+ 'rendering from true to false'
+ );
+
+ clearLog();
+ render(<Foo condition={true} />, scratch);
+
+ expect(ops).to.deep.equal(['Update Stateful', 'Update Stateful']);
+ expect(scratch.innerHTML).to.equal(
+ htmlForTrue,
+ 'rendering from false to true'
+ );
+ expectDomLogToBe(
+ [
+ '<div>beepbeepbeepHellofoo.insertBefore(<div>foo, <div>beep)',
+ '<div>foobeepbeepbeepHello.insertBefore(<div>Hello, <div>beep)',
+ '<div>fooHelloboopboopboop.appendChild(<div>boop)'
+ ],
+ 'rendering from false to true'
+ );
+ });
+
+ it('should correctly append children with siblings', () => {
+ /**
+ * @type {(props: { values: Array<string | number>}) => JSX.Element}
+ */
+ const Foo = ({ values }) => (
+ <ol>
+ <li>a</li>
+ <Fragment>
+ {values.map(value => (
+ <li>{value}</li>
+ ))}
+ </Fragment>
+ <li>b</li>
+ </ol>
+ );
+
+ const getHtml = values =>
+ ol([li('a'), ...values.map(value => li(value)), li('b')]);
+
+ let values = [0, 1, 2];
+ clearLog();
+ render(<Foo values={values} />, scratch);
+ expect(scratch.innerHTML).to.equal(
+ getHtml(values),
+ `original list: [${values.join(',')}]`
+ );
+
+ values.push(3);
+
+ clearLog();
+ render(<Foo values={values} />, scratch);
+ expect(scratch.innerHTML).to.equal(
+ getHtml(values),
+ `push 3: [${values.join(',')}]`
+ );
+ expectDomLogToBe([
+ '<li>.appendChild(#text)',
+ '<ol>a012b.insertBefore(<li>3, <li>b)'
+ ]);
+
+ values.push(4);
+
+ clearLog();
+ render(<Foo values={values} />, scratch);
+ expect(scratch.innerHTML).to.equal(
+ getHtml(values),
+ `push 4: [${values.join(',')}]`
+ );
+ expectDomLogToBe([
+ '<li>.appendChild(#text)',
+ '<ol>a0123b.insertBefore(<li>4, <li>b)'
+ ]);
+ });
+
+ it('should render components that conditionally return Fragments', () => {
+ const Foo = ({ condition }) =>
+ condition ? (
+ <Fragment>
+ <div>1</div>
+ <div>2</div>
+ </Fragment>
+ ) : (
+ <div>
+ <div>3</div>
+ <div>4</div>
+ </div>
+ );
+
+ const htmlForTrue = [div(1), div(2)].join('');
+
+ const htmlForFalse = div([div(3), div(4)]);
+
+ clearLog();
+ render(<Foo condition={true} />, scratch);
+
+ expect(scratch.innerHTML).to.equal(htmlForTrue);
+
+ clearLog();
+ render(<Foo condition={false} />, scratch);
+
+ expect(scratch.innerHTML).to.equal(htmlForFalse);
+ expectDomLogToBe(
+ [
+ '<div>.appendChild(#text)',
+ '<div>1.insertBefore(<div>3, #text)',
+ '<div>.appendChild(#text)',
+ '<div>31.insertBefore(<div>4, #text)',
+ '#text.remove()',
+ '<div>2.remove()'
+ ],
+ 'rendering from true to false'
+ );
+
+ clearLog();
+ render(<Foo condition={true} />, scratch);
+
+ expect(scratch.innerHTML).to.equal(htmlForTrue);
+ expectDomLogToBe(
+ [
+ '<div>34.insertBefore(#text, <div>3)',
+ '<div>4.remove()',
+ '<div>3.remove()',
+ '<div>.appendChild(#text)',
+ '<div>1.appendChild(<div>2)'
+ ],
+ 'rendering from false to true'
+ );
+ });
+
+ it('should clear empty Fragments', () => {
+ function Foo(props) {
+ if (props.condition) {
+ return <Fragment>foo</Fragment>;
+ }
+ return <Fragment />;
+ }
+
+ render(<Foo condition={true} />, scratch);
+ expect(scratch.textContent).to.equal('foo');
+
+ render(<Foo condition={false} />, scratch);
+ expect(scratch.textContent).to.equal('');
+ });
+
+ it('should support conditionally rendered nested Fragments or null with siblings', () => {
+ const Foo = ({ condition }) => (
+ <ol>
+ <li>0</li>
+ <Fragment>
+ <li>1</li>
+ {condition ? (
+ <Fragment>
+ <li>2</li>
+ <li>3</li>
+ </Fragment>
+ ) : null}
+ <li>4</li>
+ </Fragment>
+ <li>5</li>
+ </ol>
+ );
+
+ const htmlForTrue = ol([
+ li('0'),
+ li('1'),
+ li('2'),
+ li('3'),
+ li('4'),
+ li('5')
+ ]);
+
+ const htmlForFalse = ol([li('0'), li('1'), li('4'), li('5')]);
+
+ clearLog();
+ render(<Foo condition={true} />, scratch);
+ expect(scratch.innerHTML).to.equal(htmlForTrue, 'initial render of true');
+ expectDomLogToBe(
+ [
+ '<li>.appendChild(#text)',
+ '<ol>.appendChild(<li>0)',
+ '<li>.appendChild(#text)',
+ '<ol>0.appendChild(<li>1)',
+ '<li>.appendChild(#text)',
+ '<ol>01.appendChild(<li>2)',
+ '<li>.appendChild(#text)',
+ '<ol>012.appendChild(<li>3)',
+ '<li>.appendChild(#text)',
+ '<ol>0123.appendChild(<li>4)',
+ '<li>.appendChild(#text)',
+ '<ol>01234.appendChild(<li>5)',
+ '<div>.appendChild(<ol>012345)'
+ ],
+ 'initial render of true'
+ );
+
+ clearLog();
+ render(<Foo condition={false} />, scratch);
+ expect(scratch.innerHTML).to.equal(
+ htmlForFalse,
+ 'rendering from true to false'
+ );
+ expectDomLogToBe(
+ ['<li>2.remove()', '<li>3.remove()'],
+ 'rendering from true to false'
+ );
+
+ clearLog();
+ render(<Foo condition={true} />, scratch);
+ expect(scratch.innerHTML).to.equal(
+ htmlForTrue,
+ 'rendering from false to true'
+ );
+ expectDomLogToBe(
+ [
+ '<li>.appendChild(#text)',
+ '<ol>0145.insertBefore(<li>2, <li>4)',
+ '<li>.appendChild(#text)',
+ '<ol>01245.insertBefore(<li>3, <li>4)'
+ ],
+ 'rendering from false to true'
+ );
+ });
+
+ it('should render first child Fragment that wrap null components', () => {
+ const Empty = () => null;
+ const Foo = () => (
+ <ol>
+ <Fragment>
+ <Empty />
+ </Fragment>
+ <li>1</li>
+ </ol>
+ );
+
+ render(<Foo />, scratch);
+ expect(scratch.innerHTML).to.equal(ol(li(1)));
+ });
+
+ it('should properly render Components that return Fragments and use shouldComponentUpdate #1415', () => {
+ class SubList extends Component {
+ shouldComponentUpdate(nextProps) {
+ return nextProps.prop1 !== this.props.prop1;
+ }
+ render() {
+ return (
+ <Fragment>
+ <div>2</div>
+ <div>3</div>
+ </Fragment>
+ );
+ }
+ }
+
+ /** @type {(update: any) => void} */
+ let setState;
+ class App extends Component {
+ constructor() {
+ super();
+ setState = update => this.setState(update);
+
+ this.state = { error: false };
+ }
+
+ render() {
+ return (
+ <div>
+ {this.state.error ? (
+ <div>Error!</div>
+ ) : (
+ <div>
+ <div>1</div>
+ <SubList prop1={this.state.error} />
+ </div>
+ )}
+ </div>
+ );
+ }
+ }
+
+ const successHtml = div(div([div(1), div(2), div(3)]));
+
+ const errorHtml = div(div('Error!'));
+
+ render(<App />, scratch);
+ expect(scratch.innerHTML).to.equal(successHtml);
+
+ setState({}); // Trigger sCU
+ rerender();
+ expect(scratch.innerHTML).to.equal(successHtml);
+
+ setState({ error: true });
+ rerender();
+ expect(scratch.innerHTML).to.equal(errorHtml);
+
+ setState({ error: false });
+ rerender();
+ expect(scratch.innerHTML).to.equal(successHtml);
+
+ setState({}); // Trigger sCU again
+ rerender();
+ expect(scratch.innerHTML).to.equal(successHtml);
+ });
+
+ it('should properly render Fragments whose last child is a component returning null', () => {
+ let Noop = () => null;
+ let update;
+ class App extends Component {
+ constructor(props) {
+ super(props);
+ update = () => this.setState({ items: ['A', 'B', 'C'] });
+ this.state = {
+ items: null
+ };
+ }
+
+ render() {
+ return (
+ <div>
+ {this.state.items && (
+ <Fragment>
+ {this.state.items.map(v => (
+ <div>{v}</div>
+ ))}
+ <Noop />
+ </Fragment>
+ )}
+ </div>
+ );
+ }
+ }
+
+ render(<App />, scratch);
+ expect(scratch.textContent).to.equal('');
+
+ clearLog();
+ update();
+ rerender();
+
+ expect(scratch.textContent).to.equal('ABC');
+ expectDomLogToBe([
+ '<div>.appendChild(#text)',
+ '<div>.appendChild(<div>A)',
+ '<div>.appendChild(#text)',
+ '<div>A.appendChild(<div>B)',
+ '<div>.appendChild(#text)',
+ '<div>AB.appendChild(<div>C)'
+ ]);
+ });
+
+ it('should replace node in-between children', () => {
+ let update;
+ class SetState extends Component {
+ constructor(props) {
+ super(props);
+ update = () => this.setState({ active: true });
+ }
+
+ render() {
+ return this.state.active ? <section>B2</section> : <div>B1</div>;
+ }
+ }
+
+ render(
+ <div>
+ <div>A</div>
+ <SetState />
+ <div>C</div>
+ </div>,
+ scratch
+ );
+
+ expect(scratch.innerHTML).to.eql(
+ `<div><div>A</div><div>B1</div><div>C</div></div>`
+ );
+
+ clearLog();
+ update();
+ rerender();
+
+ expect(scratch.innerHTML).to.eql(
+ `<div><div>A</div><section>B2</section><div>C</div></div>`
+ );
+ expectDomLogToBe([
+ '<section>.appendChild(#text)',
+ '<div>AB1C.insertBefore(<section>B2, <div>B1)',
+ '<div>B1.remove()'
+ ]);
+ });
+
+ it('should replace Fragment in-between children', () => {
+ let update;
+ class SetState extends Component {
+ constructor(props) {
+ super(props);
+ update = () => this.setState({ active: true });
+ }
+
+ render() {
+ return this.state.active ? (
+ <Fragment>
+ <section>B3</section>
+ <section>B4</section>
+ </Fragment>
+ ) : (
+ <Fragment>
+ <div>B1</div>
+ <div>B2</div>
+ </Fragment>
+ );
+ }
+ }
+
+ render(
+ <div>
+ <div>A</div>
+ <SetState />
+ <div>C</div>
+ </div>,
+ scratch
+ );
+
+ expect(scratch.innerHTML).to.eql(
+ div([div('A'), div('B1'), div('B2'), div('C')])
+ );
+
+ clearLog();
+ update();
+ rerender();
+
+ expect(scratch.innerHTML).to.eql(
+ div([div('A'), section('B3'), section('B4'), div('C')])
+ );
+ expectDomLogToBe([
+ '<section>.appendChild(#text)',
+ '<div>AB1B2C.insertBefore(<section>B3, <div>B1)',
+ '<section>.appendChild(#text)',
+ '<div>AB3B1B2C.insertBefore(<section>B4, <div>B1)',
+ '<div>B2.remove()',
+ '<div>B1.remove()'
+ ]);
+ });
+
+ it('should insert in-between children', () => {
+ let update;
+ class SetState extends Component {
+ constructor(props) {
+ super(props);
+ update = () => this.setState({ active: true });
+ }
+
+ render() {
+ return this.state.active ? <div>B</div> : null;
+ }
+ }
+
+ render(
+ <div>
+ <div>A</div>
+ <SetState />
+ <div>C</div>
+ </div>,
+ scratch
+ );
+
+ expect(scratch.innerHTML).to.eql(`<div><div>A</div><div>C</div></div>`);
+
+ clearLog();
+ update();
+ rerender();
+
+ expect(scratch.innerHTML).to.eql(
+ `<div><div>A</div><div>B</div><div>C</div></div>`
+ );
+ expectDomLogToBe([
+ '<div>.appendChild(#text)',
+ '<div>AC.insertBefore(<div>B, <div>C)'
+ ]);
+ });
+
+ it('should insert in-between Fragments', () => {
+ let update;
+ class SetState extends Component {
+ constructor(props) {
+ super(props);
+ update = () => this.setState({ active: true });
+ }
+
+ render() {
+ return this.state.active ? [<div>B1</div>, <div>B2</div>] : null;
+ }
+ }
+
+ render(
+ <div>
+ <div>A</div>
+ <SetState />
+ <div>C</div>
+ </div>,
+ scratch
+ );
+
+ expect(scratch.innerHTML).to.eql(`<div><div>A</div><div>C</div></div>`);
+
+ clearLog();
+ update();
+ rerender();
+
+ expect(scratch.innerHTML).to.eql(
+ `<div><div>A</div><div>B1</div><div>B2</div><div>C</div></div>`
+ );
+ expectDomLogToBe([
+ '<div>.appendChild(#text)',
+ '<div>AC.insertBefore(<div>B1, <div>C)',
+ '<div>.appendChild(#text)',
+ '<div>AB1C.insertBefore(<div>B2, <div>C)'
+ ]);
+ });
+
+ it('should insert in-between null children', () => {
+ let update;
+ class SetState extends Component {
+ constructor(props) {
+ super(props);
+ update = () => this.setState({ active: true });
+ }
+
+ render() {
+ return this.state.active ? <div>B</div> : null;
+ }
+ }
+
+ render(
+ <div>
+ <div>A</div>
+ {null}
+ <SetState />
+ {null}
+ <div>C</div>
+ </div>,
+ scratch
+ );
+
+ expect(scratch.innerHTML).to.eql(`<div><div>A</div><div>C</div></div>`);
+
+ clearLog();
+ update();
+ rerender();
+
+ expect(scratch.innerHTML).to.eql(
+ `<div><div>A</div><div>B</div><div>C</div></div>`
+ );
+ expectDomLogToBe([
+ '<div>.appendChild(#text)',
+ '<div>AC.insertBefore(<div>B, <div>C)'
+ ]);
+ });
+
+ it('should insert Fragment in-between null children', () => {
+ let update;
+ class SetState extends Component {
+ constructor(props) {
+ super(props);
+ update = () => this.setState({ active: true });
+ }
+
+ render() {
+ return this.state.active ? (
+ <Fragment>
+ <div>B1</div>
+ <div>B2</div>
+ </Fragment>
+ ) : null;
+ }
+ }
+
+ render(
+ <div>
+ <div>A</div>
+ {null}
+ <SetState />
+ {null}
+ <div>C</div>
+ </div>,
+ scratch
+ );
+
+ expect(scratch.innerHTML).to.eql(`<div><div>A</div><div>C</div></div>`);
+
+ clearLog();
+ update();
+ rerender();
+
+ expect(scratch.innerHTML).to.eql(
+ `<div><div>A</div><div>B1</div><div>B2</div><div>C</div></div>`
+ );
+ expectDomLogToBe([
+ '<div>.appendChild(#text)',
+ '<div>AC.insertBefore(<div>B1, <div>C)',
+ '<div>.appendChild(#text)',
+ '<div>AB1C.insertBefore(<div>B2, <div>C)'
+ ]);
+ });
+
+ it('should insert in-between nested null children', () => {
+ let update;
+ class SetState extends Component {
+ constructor(props) {
+ super(props);
+ update = () => this.setState({ active: true });
+ }
+
+ render() {
+ return this.state.active ? <div>B</div> : null;
+ }
+ }
+
+ function Outer() {
+ return <SetState />;
+ }
+
+ render(
+ <div>
+ <div>A</div>
+ {null}
+ <Outer />
+ {null}
+ <div>C</div>
+ </div>,
+ scratch
+ );
+
+ expect(scratch.innerHTML).to.eql(`<div><div>A</div><div>C</div></div>`);
+
+ clearLog();
+ update();
+ rerender();
+
+ expect(scratch.innerHTML).to.eql(
+ `<div><div>A</div><div>B</div><div>C</div></div>`
+ );
+ expectDomLogToBe([
+ '<div>.appendChild(#text)',
+ '<div>AC.insertBefore(<div>B, <div>C)'
+ ]);
+ });
+
+ it('should insert Fragment in-between nested null children', () => {
+ let update;
+ class SetState extends Component {
+ constructor(props) {
+ super(props);
+ update = () => this.setState({ active: true });
+ }
+
+ render() {
+ return this.state.active ? (
+ <Fragment>
+ <div>B1</div>
+ <div>B2</div>
+ </Fragment>
+ ) : null;
+ }
+ }
+
+ function Outer() {
+ return <SetState />;
+ }
+
+ render(
+ <div>
+ <div>A</div>
+ {null}
+ <Outer />
+ {null}
+ <div>C</div>
+ </div>,
+ scratch
+ );
+
+ expect(scratch.innerHTML).to.eql(`<div><div>A</div><div>C</div></div>`);
+
+ clearLog();
+ update();
+ rerender();
+
+ expect(scratch.innerHTML).to.eql(
+ `<div><div>A</div><div>B1</div><div>B2</div><div>C</div></div>`
+ );
+ expectDomLogToBe([
+ '<div>.appendChild(#text)',
+ '<div>AC.insertBefore(<div>B1, <div>C)',
+ '<div>.appendChild(#text)',
+ '<div>AB1C.insertBefore(<div>B2, <div>C)'
+ ]);
+ });
+
+ it('should update at correct place', () => {
+ let updateA;
+ class A extends Component {
+ constructor(props) {
+ super(props);
+ this.state = { active: true };
+ updateA = () => this.setState(prev => ({ active: !prev.active }));
+ }
+
+ render() {
+ return this.state.active ? <div>A</div> : <span>A2</span>;
+ }
+ }
+
+ function B() {
+ return <div>B</div>;
+ }
+
+ function X(props) {
+ return props.children;
+ }
+
+ function App(props) {
+ let b = props.condition ? <B /> : null;
+ return (
+ <div>
+ <X>
+ <A />
+ </X>
+ <X>
+ {b}
+ <div>C</div>
+ </X>
+ </div>
+ );
+ }
+
+ render(<App condition={true} />, scratch);
+
+ expect(scratch.innerHTML).to.eql(
+ `<div><div>A</div><div>B</div><div>C</div></div>`
+ );
+
+ clearLog();
+ render(<App condition={false} />, scratch);
+
+ expect(scratch.innerHTML).to.eql(`<div><div>A</div><div>C</div></div>`);
+ expectDomLogToBe(['<div>B.remove()']);
+
+ clearLog();
+ updateA();
+ rerender();
+
+ expect(scratch.innerHTML).to.eql(`<div><span>A2</span><div>C</div></div>`);
+ expectDomLogToBe([
+ '<span>.appendChild(#text)',
+ '<div>AC.insertBefore(<span>A2, <div>A)',
+ '<div>A.remove()'
+ ]);
+ });
+
+ it('should update Fragment at correct place', () => {
+ let updateA;
+ class A extends Component {
+ constructor(props) {
+ super(props);
+ this.state = { active: true };
+ updateA = () => this.setState(prev => ({ active: !prev.active }));
+ }
+
+ render() {
+ return this.state.active
+ ? [<div>A1</div>, <div>A2</div>]
+ : [<span>A3</span>, <span>A4</span>];
+ }
+ }
+
+ function B() {
+ return <div>B</div>;
+ }
+
+ function X(props) {
+ return props.children;
+ }
+
+ function App(props) {
+ let b = props.condition ? <B /> : null;
+ return (
+ <div>
+ <X>
+ <A />
+ </X>
+ <X>
+ {b}
+ <div>C</div>
+ </X>
+ </div>
+ );
+ }
+
+ render(<App condition={true} />, scratch);
+
+ expect(scratch.innerHTML).to.eql(
+ `<div><div>A1</div><div>A2</div><div>B</div><div>C</div></div>`
+ );
+
+ clearLog();
+ render(<App condition={false} />, scratch);
+
+ expect(scratch.innerHTML).to.eql(
+ `<div><div>A1</div><div>A2</div><div>C</div></div>`
+ );
+ expectDomLogToBe(['<div>B.remove()']);
+
+ clearLog();
+ updateA();
+ rerender();
+
+ expect(scratch.innerHTML).to.eql(
+ `<div><span>A3</span><span>A4</span><div>C</div></div>`
+ );
+ expectDomLogToBe([
+ '<span>.appendChild(#text)',
+ '<div>A1A2C.insertBefore(<span>A3, <div>A1)',
+ '<span>.appendChild(#text)',
+ '<div>A3A1A2C.insertBefore(<span>A4, <div>A1)',
+ '<div>A2.remove()',
+ '<div>A1.remove()'
+ ]);
+ });
+
+ it('should insert children correctly if sibling component DOM changes', () => {
+ /** @type {() => void} */
+ let updateA;
+ class A extends Component {
+ constructor(props) {
+ super(props);
+ this.state = { active: true };
+ updateA = () => this.setState(prev => ({ active: !prev.active }));
+ }
+
+ render() {
+ return this.state.active ? <div>A</div> : <span>A2</span>;
+ }
+ }
+
+ /** @type {() => void} */
+ let updateB;
+ class B extends Component {
+ constructor(props) {
+ super(props);
+ this.state = { active: false };
+ updateB = () => this.setState(prev => ({ active: !prev.active }));
+ }
+ render() {
+ return this.state.active ? <div>B</div> : null;
+ }
+ }
+
+ function X(props) {
+ return props.children;
+ }
+
+ function App() {
+ return (
+ <div>
+ <X>
+ <A />
+ </X>
+ <X>
+ <B />
+ <div>C</div>
+ </X>
+ </div>
+ );
+ }
+
+ render(<App />, scratch);
+
+ expect(scratch.innerHTML).to.eql(div([div('A'), div('C')]), 'initial');
+
+ clearLog();
+ updateB();
+ rerender();
+
+ expect(scratch.innerHTML).to.eql(
+ div([div('A'), div('B'), div('C')]),
+ 'updateB'
+ );
+ expectDomLogToBe([
+ '<div>.appendChild(#text)',
+ '<div>AC.insertBefore(<div>B, <div>C)'
+ ]);
+
+ clearLog();
+ updateA();
+ rerender();
+
+ expect(scratch.innerHTML).to.eql(
+ div([span('A2'), div('B'), div('C')]),
+ 'updateA'
+ );
+ expectDomLogToBe([
+ '<span>.appendChild(#text)',
+ '<div>ABC.insertBefore(<span>A2, <div>A)',
+ '<div>A.remove()'
+ ]);
+ });
+
+ it('should correctly append children if last child changes DOM', () => {
+ /** @type {() => void} */
+ let updateA;
+ class A extends Component {
+ constructor(props) {
+ super(props);
+ this.state = { active: true };
+ updateA = () => this.setState(prev => ({ active: !prev.active }));
+ }
+
+ render() {
+ return this.state.active
+ ? [<div>A1</div>, <div>A2</div>]
+ : [<span>A3</span>, <span>A4</span>];
+ }
+ }
+
+ /** @type {() => void} */
+ let updateB;
+ class B extends Component {
+ constructor(props) {
+ super(props);
+ this.state = { active: false };
+ updateB = () => this.setState(prev => ({ active: !prev.active }));
+ }
+ render() {
+ return (
+ <Fragment>
+ <A />
+ {this.state.active ? <div>B</div> : null}
+ </Fragment>
+ );
+ }
+ }
+
+ render(<B />, scratch);
+
+ expect(scratch.innerHTML).to.eql(
+ [div('A1'), div('A2')].join(''),
+ 'initial'
+ );
+
+ clearLog();
+ updateA();
+ rerender();
+
+ expect(scratch.innerHTML).to.eql(
+ [span('A3'), span('A4')].join(''),
+ 'updateA'
+ );
+ expectDomLogToBe([
+ '<span>.appendChild(#text)',
+ '<div>A1A2.insertBefore(<span>A3, <div>A1)',
+ '<span>.appendChild(#text)',
+ '<div>A3A1A2.insertBefore(<span>A4, <div>A1)',
+ '<div>A2.remove()',
+ '<div>A1.remove()'
+ ]);
+
+ clearLog();
+ updateB();
+ rerender();
+
+ expect(scratch.innerHTML).to.eql(
+ [span('A3'), span('A4'), div('B')].join(''),
+ 'updateB'
+ );
+ expectDomLogToBe([
+ '<div>.appendChild(#text)',
+ '<div>A3A4.appendChild(<div>B)'
+ ]);
+ });
+
+ it('should properly place conditional elements around strictly equal vnodes', () => {
+ let set;
+
+ const Children = () => (
+ <Fragment>
+ <div>Navigation</div>
+ <div>Content</div>
+ </Fragment>
+ );
+
+ class Parent extends Component {
+ constructor(props) {
+ super(props);
+ this.state = { panelPosition: 'bottom' };
+ set = this.tooglePanelPosition = this.tooglePanelPosition.bind(this);
+ }
+
+ tooglePanelPosition() {
+ this.setState({
+ panelPosition: this.state.panelPosition === 'top' ? 'bottom' : 'top'
+ });
+ }
+
+ render() {
+ return (
+ <div>
+ {this.state.panelPosition === 'top' && <div>top panel</div>}
+ {this.props.children}
+ {this.state.panelPosition === 'bottom' && <div>bottom panel</div>}
+ </div>
+ );
+ }
+ }
+
+ const App = () => (
+ <Parent>
+ <Children />
+ </Parent>
+ );
+
+ const content = `<div>Navigation</div><div>Content</div>`;
+ const top = `<div><div>top panel</div>${content}</div>`;
+ const bottom = `<div>${content}<div>bottom panel</div></div>`;
+
+ render(<App />, scratch);
+ expect(scratch.innerHTML).to.equal(bottom);
+
+ clearLog();
+ set();
+ rerender();
+ expect(scratch.innerHTML).to.equal(top);
+ expectDomLogToBe([
+ '<div>.appendChild(#text)',
+ '<div>NavigationContentbottom panel.insertBefore(<div>top panel, <div>Navigation)',
+ '<div>bottom panel.remove()'
+ ]);
+
+ clearLog();
+ set();
+ rerender();
+ expect(scratch.innerHTML).to.equal(bottom);
+ expectDomLogToBe([
+ '<div>.appendChild(#text)',
+ '<div>top panelNavigationContent.appendChild(<div>bottom panel)',
+ '<div>top panel.remove()'
+ ]);
+
+ clearLog();
+ set();
+ rerender();
+ expect(scratch.innerHTML).to.equal(top);
+ expectDomLogToBe([
+ '<div>.appendChild(#text)',
+ '<div>NavigationContentbottom panel.insertBefore(<div>top panel, <div>Navigation)',
+ '<div>bottom panel.remove()'
+ ]);
+ });
+
+ it('should efficiently unmount Fragment children', () => {
+ // <div>1 => <span>1 and Fragment sibling unmounts. Does <span>1 get correct _nextDom pointer?
+ function App({ condition }) {
+ return condition ? (
+ <div>
+ <Fragment>
+ <div>1</div>
+ <div>2</div>
+ </Fragment>
+ <Fragment>
+ <div>A</div>
+ </Fragment>
+ </div>
+ ) : (
+ <div>
+ <Fragment>
+ <div>1</div>
+ </Fragment>
+ <Fragment>
+ <div>A</div>
+ </Fragment>
+ </div>
+ );
+ }
+
+ render(<App condition={true} />, scratch);
+ expect(scratch.innerHTML).to.equal(div([div(1), div(2), div('A')]));
+
+ clearLog();
+ render(<App condition={false} />, scratch);
+
+ expect(scratch.innerHTML).to.equal(div([div(1), div('A')]));
+ expectDomLogToBe(['<div>2.remove()']);
+ });
+
+ it('should efficiently unmount nested Fragment children', () => {
+ // Fragment wrapping <div>2 and <div>3 unmounts. Does <div>1 get correct
+ // _nextDom pointer to efficiently update DOM? _nextDom should be <div>A
+ function App({ condition }) {
+ return condition ? (
+ <div>
+ <Fragment>
+ <div>1</div>
+ <Fragment>
+ <div>2</div>
+ <div>3</div>
+ </Fragment>
+ </Fragment>
+ <Fragment>
+ <div>A</div>
+ <div>B</div>
+ </Fragment>
+ </div>
+ ) : (
+ <div>
+ <Fragment>
+ <div>1</div>
+ </Fragment>
+ <Fragment>
+ <div>A</div>
+ <div>B</div>
+ </Fragment>
+ </div>
+ );
+ }
+
+ clearLog();
+ render(<App condition={true} />, scratch);
+ expect(scratch.innerHTML).to.equal(
+ div([div(1), div(2), div(3), div('A'), div('B')])
+ );
+
+ clearLog();
+ render(<App condition={false} />, scratch);
+
+ expect(scratch.innerHTML).to.equal(div([div(1), div('A'), div('B')]));
+ expectDomLogToBe(['<div>2.remove()', '<div>3.remove()']);
+ });
+
+ it('should efficiently place new children and unmount nested Fragment children', () => {
+ // <div>4 is added and Fragment sibling unmounts. Does <div>4 get correct _nextDom pointer?
+ function App({ condition }) {
+ return condition ? (
+ <div>
+ <Fragment>
+ <div>1</div>
+ <Fragment>
+ <div>2</div>
+ <div>3</div>
+ </Fragment>
+ </Fragment>
+ <Fragment>
+ <div>A</div>
+ <div>B</div>
+ </Fragment>
+ </div>
+ ) : (
+ <div>
+ <Fragment>
+ <div>1</div>
+ <div>4</div>
+ </Fragment>
+ <Fragment>
+ <div>A</div>
+ <div>B</div>
+ </Fragment>
+ </div>
+ );
+ }
+
+ render(<App condition={true} />, scratch);
+ expect(scratch.innerHTML).to.equal(
+ div([div(1), div(2), div(3), div('A'), div('B')])
+ );
+
+ clearLog();
+ render(<App condition={false} />, scratch);
+
+ expect(scratch.innerHTML).to.equal(
+ div([div(1), div(4), div('A'), div('B')])
+ );
+ expectDomLogToBe([
+ '<div>.appendChild(#text)',
+ '<div>123AB.insertBefore(<div>4, <div>2)',
+ '<div>2.remove()',
+ '<div>3.remove()'
+ ]);
+ });
+
+ it('should efficiently unmount nested Fragment children when changing node type', () => {
+ // <div>1 => <span>1 and Fragment sibling unmounts. Does <span>1 get correct _nextDom pointer?
+ function App({ condition }) {
+ return condition ? (
+ <div>
+ <Fragment>
+ <div>1</div>
+ <Fragment>
+ <div>2</div>
+ <div>3</div>
+ </Fragment>
+ </Fragment>
+ <Fragment>
+ <div>A</div>
+ <div>B</div>
+ </Fragment>
+ </div>
+ ) : (
+ <div>
+ <Fragment>
+ <span>1</span>
+ </Fragment>
+ <Fragment>
+ <div>A</div>
+ <div>B</div>
+ </Fragment>
+ </div>
+ );
+ }
+
+ render(<App condition={true} />, scratch);
+ expect(scratch.innerHTML).to.equal(
+ div([div(1), div(2), div(3), div('A'), div('B')])
+ );
+
+ clearLog();
+ render(<App condition={false} />, scratch);
+
+ expect(scratch.innerHTML).to.equal(div([span(1), div('A'), div('B')]));
+ expectDomLogToBe([
+ '<span>.appendChild(#text)',
+ '<div>123AB.insertBefore(<span>1, <div>1)',
+ '<div>2.remove()',
+ '<div>3.remove()',
+ '<div>1.remove()'
+ ]);
+ });
+});
diff --git a/preact/test/browser/getDomSibling.test.js b/preact/test/browser/getDomSibling.test.js
new file mode 100644
index 0000000..8dc9b75
--- /dev/null
+++ b/preact/test/browser/getDomSibling.test.js
@@ -0,0 +1,362 @@
+import { createElement, render, Fragment } from '../../src/';
+import { getDomSibling } from '../../src/component';
+import { setupScratch, teardown } from '../_util/helpers';
+
+/** @jsx createElement */
+
+describe('getDomSibling', () => {
+ /** @type {import('../../src/internal').PreactElement} */
+ let scratch;
+
+ const getRoot = dom => dom._children;
+
+ beforeEach(() => {
+ scratch = setupScratch();
+ });
+
+ afterEach(() => {
+ teardown(scratch);
+ });
+
+ it('should find direct sibling', () => {
+ render(
+ <div>
+ <div>A</div>
+ <div>B</div>
+ </div>,
+ scratch
+ );
+ let vnode = getRoot(scratch)._children[0]._children[0];
+ expect(getDomSibling(vnode)).to.equalNode(scratch.firstChild.childNodes[1]);
+ });
+
+ it('should find direct text node sibling', () => {
+ render(
+ <div>
+ <div>A</div>B
+ </div>,
+ scratch
+ );
+ let vnode = getRoot(scratch)._children[0]._children[0];
+ expect(getDomSibling(vnode)).to.equalNode(scratch.firstChild.childNodes[1]);
+ });
+
+ it('should find nested text node sibling', () => {
+ render(
+ <div>
+ <Fragment>
+ <div>A</div>
+ </Fragment>
+ <Fragment>B</Fragment>
+ </div>,
+ scratch
+ );
+ let vnode = getRoot(scratch)._children[0]._children[0];
+ expect(getDomSibling(vnode)).to.equalNode(scratch.firstChild.childNodes[1]);
+ });
+
+ it('should find text node sibling with placeholder', () => {
+ render(<div>A{null}B</div>, scratch);
+ let vnode = getRoot(scratch)._children[0]._children[0];
+ expect(getDomSibling(vnode)).to.equalNode(scratch.firstChild.childNodes[1]);
+ });
+
+ it('should find sibling with placeholder', () => {
+ render(
+ <div key="parent">
+ <div key="A">A</div>
+ {null}
+ <div key="B">B</div>
+ </div>,
+ scratch
+ );
+ let vnode = getRoot(scratch)._children[0]._children[0];
+ expect(getDomSibling(vnode)).to.equalNode(scratch.firstChild.childNodes[1]);
+ });
+
+ it('should find sibling with nested placeholder', () => {
+ render(
+ <div key="0">
+ <Fragment key="0.0">
+ <div key="A">A</div>
+ </Fragment>
+ <Fragment key="0.1">{null}</Fragment>
+ <Fragment key="0.2">
+ <div key="B">B</div>
+ </Fragment>
+ </div>,
+ scratch
+ );
+ let vnode = getRoot(scratch)._children[0]._children[0]._children[0];
+ expect(getDomSibling(vnode)).to.equalNode(scratch.firstChild.childNodes[1]);
+ });
+
+ it('should find sibling in parent', () => {
+ render(
+ <div>
+ <Fragment>
+ <div>A</div>
+ </Fragment>
+ <div>B</div>
+ </div>,
+ scratch
+ );
+ let vnode = getRoot(scratch)._children[0]._children[0]._children[0];
+ expect(getDomSibling(vnode)).to.equalNode(scratch.firstChild.childNodes[1]);
+ });
+
+ it('should find unrelated sibling from a DOM VNode', () => {
+ render(
+ <div key="0">
+ <Fragment key="0.0">
+ <Fragment key="0.0.0">
+ <Fragment key="0.0.0.0">
+ <div key="A">A</div>
+ </Fragment>
+ </Fragment>
+ </Fragment>
+ <Fragment key="0.1">
+ <Fragment key="0.1.0" />
+ <Fragment key="0.1.1" />
+ <Fragment key="0.1.2" />
+ </Fragment>
+ <Fragment key="0.2">
+ <Fragment key="0.2.0" />
+ <Fragment key="0.2.1" />
+ <Fragment key="0.2.2">
+ <div key="B">B</div>
+ </Fragment>
+ </Fragment>
+ </div>,
+ scratch
+ );
+
+ let divAVNode = getRoot(scratch)._children[0]._children[0]._children[0]
+ ._children[0]._children[0];
+ expect(divAVNode.type).to.equal('div');
+ expect(getDomSibling(divAVNode)).to.equalNode(
+ scratch.firstChild.childNodes[1]
+ );
+ });
+
+ it('should find unrelated sibling from a Fragment VNode', () => {
+ render(
+ <div key="0">
+ <Fragment key="0.0">
+ <Fragment key="0.0.0">
+ <Fragment key="0.0.0.0">
+ <div key="A">A</div>
+ </Fragment>
+ </Fragment>
+ </Fragment>
+ <Fragment key="0.1">
+ <Fragment key="0.1.0">
+ <div key="B">B</div>
+ </Fragment>
+ </Fragment>
+ </div>,
+ scratch
+ );
+
+ let fragment = getRoot(scratch)._children[0]._children[0]._children[0]
+ ._children[0];
+ expect(fragment.type).to.equal(Fragment);
+ expect(getDomSibling(fragment)).to.equalNode(
+ scratch.firstChild.childNodes[1]
+ );
+ });
+
+ it('should find unrelated sibling from a Component VNode', () => {
+ const Foo = props => props.children;
+ render(
+ <div key="0">
+ <Fragment key="0.0">
+ <Fragment key="0.0.0">
+ <Foo key="0.0.0.0">
+ <div key="A">A</div>
+ </Foo>
+ </Fragment>
+ </Fragment>
+ <Fragment key="0.1">
+ <Fragment key="0.1.0">
+ <div key="B">B</div>
+ </Fragment>
+ </Fragment>
+ </div>,
+ scratch
+ );
+
+ let foo = getRoot(scratch)._children[0]._children[0]._children[0]
+ ._children[0];
+ expect(foo.type).to.equal(Foo);
+ expect(getDomSibling(foo)).to.equalNode(scratch.firstChild.childNodes[1]);
+ });
+
+ it('should find sibling through components', () => {
+ const Foo = props => props.children;
+ render(
+ <div key="0">
+ <Foo key="0.0">
+ <div key="A">A</div>
+ </Foo>
+ <Foo key="0.1" />
+ <Foo key="0.2">
+ <Foo key="0.2.0">
+ <div key="B">B</div>
+ </Foo>
+ </Foo>
+ </div>,
+ scratch
+ );
+
+ let divAVNode = getRoot(scratch)._children[0]._children[0]._children[0];
+ expect(divAVNode.type).to.equal('div');
+ expect(getDomSibling(divAVNode)).to.equalNode(
+ scratch.firstChild.childNodes[1]
+ );
+ });
+
+ it('should find sibling rendered in Components that wrap JSX children', () => {
+ const Foo = props => <p key="p">{props.children}</p>;
+ render(
+ <div key="0">
+ <div key="A">A</div>
+ <Foo key="Foo">
+ <span key="span">a span</span>
+ </Foo>
+ </div>,
+ scratch
+ );
+
+ let divAVNode = getRoot(scratch)._children[0]._children[0];
+ expect(divAVNode.type).to.equal('div');
+
+ let sibling = getDomSibling(divAVNode);
+ expect(sibling).to.equalNode(scratch.firstChild.childNodes[1]);
+ });
+
+ it('should find sibling rendered in Components without JSX children', () => {
+ const Foo = props => <p key="p">A paragraph</p>;
+ render(
+ <div key="0">
+ <div key="A">A</div>
+ <Foo key="Foo" />
+ </div>,
+ scratch
+ );
+
+ let divAVNode = getRoot(scratch)._children[0]._children[0];
+ expect(divAVNode.type).to.equal('div');
+
+ let sibling = getDomSibling(divAVNode);
+ expect(sibling).to.equalNode(scratch.firstChild.childNodes[1]);
+ });
+
+ it('should climb through Components without JSX children', () => {
+ const divAVNode = <div key="A">A</div>;
+ const Foo = () => divAVNode;
+
+ render(
+ <div key="0">
+ <Foo key="Foo" />
+ <div key="B">B</div>
+ </div>,
+ scratch
+ );
+
+ let sibling = getDomSibling(divAVNode);
+ expect(sibling).to.equalNode(scratch.firstChild.childNodes[1]);
+ });
+
+ it('should return null if last sibling', () => {
+ render(
+ <div key="0">
+ <Fragment key="0.0">
+ <div key="A">A</div>
+ </Fragment>
+ <Fragment key="0.1">
+ <div key="B">B</div>
+ </Fragment>
+ <Fragment key="0.2">
+ <div key="C">C</div>
+ </Fragment>
+ </div>,
+ scratch
+ );
+
+ const divCVNode = getRoot(scratch)._children[0]._children[2]._children[0];
+ expect(getDomSibling(divCVNode)).to.equal(null);
+ });
+
+ it('should return null if no sibling', () => {
+ render(
+ <div key="0">
+ <Fragment key="0.0">
+ <Fragment key="0.0.0">
+ <Fragment key="0.0.0.0">
+ <div key="A">A</div>
+ </Fragment>
+ </Fragment>
+ </Fragment>
+ <Fragment key="0.1">
+ <Fragment key="0.1.0">{null}</Fragment>
+ </Fragment>
+ </div>,
+ scratch
+ );
+
+ let divAVNode = getRoot(scratch)._children[0]._children[0]._children[0]
+ ._children[0]._children[0];
+ expect(getDomSibling(divAVNode)).to.equal(null);
+ });
+
+ it('should return null if no sibling with lots of empty trees', () => {
+ render(
+ <div key="0">
+ <Fragment key="0.0">
+ <Fragment key="0.0.0">
+ <Fragment key="0.0.0.0">
+ <div key="A">A</div>
+ </Fragment>
+ </Fragment>
+ </Fragment>
+ <Fragment key="0.1">
+ <Fragment key="0.1.0" />
+ <Fragment key="0.1.1" />
+ <Fragment key="0.1.2" />
+ </Fragment>
+ <Fragment key="0.2">
+ <Fragment key="0.2.0" />
+ <Fragment key="0.2.1" />
+ <Fragment key="0.2.2">{null}</Fragment>
+ </Fragment>
+ </div>,
+ scratch
+ );
+
+ let divAVNode = getRoot(scratch)._children[0]._children[0]._children[0]
+ ._children[0]._children[0];
+ expect(getDomSibling(divAVNode)).to.equal(null);
+ });
+
+ it('should return null if current parent has no siblings (even if parent has siblings at same level)', () => {
+ let divAVNode = <div key="A">A</div>;
+
+ render(
+ <div key="0">
+ <div key="0.0">
+ <div key="0.0.0" />
+ {divAVNode}
+ <Fragment key="0.1.2" />
+ </div>
+ <div key="0.1">
+ <Fragment key="0.1.0" />
+ <div key="B">B</div>
+ </div>
+ </div>,
+ scratch
+ );
+
+ expect(getDomSibling(divAVNode)).to.equal(null);
+ });
+});
diff --git a/preact/test/browser/hydrate.test.js b/preact/test/browser/hydrate.test.js
new file mode 100644
index 0000000..5f6e537
--- /dev/null
+++ b/preact/test/browser/hydrate.test.js
@@ -0,0 +1,454 @@
+import { createElement, hydrate, Fragment } from 'preact';
+import {
+ setupScratch,
+ teardown,
+ sortAttributes,
+ serializeHtml,
+ spyOnElementAttributes,
+ createEvent
+} from '../_util/helpers';
+import { ul, li, div } from '../_util/dom';
+import { logCall, clearLog, getLog } from '../_util/logCall';
+
+/** @jsx createElement */
+
+describe('hydrate()', () => {
+ /** @type {HTMLElement} */
+ let scratch;
+ let attributesSpy;
+
+ const List = ({ children }) => <ul>{children}</ul>;
+ const ListItem = ({ children, onClick = null }) => (
+ <li onClick={onClick}>{children}</li>
+ );
+
+ let resetAppendChild;
+ let resetInsertBefore;
+ let resetRemoveChild;
+ let resetRemove;
+ let resetSetAttribute;
+ let resetRemoveAttribute;
+
+ before(() => {
+ resetAppendChild = logCall(Element.prototype, 'appendChild');
+ resetInsertBefore = logCall(Element.prototype, 'insertBefore');
+ resetRemoveChild = logCall(Element.prototype, 'removeChild');
+ resetRemove = logCall(Element.prototype, 'remove');
+ resetSetAttribute = logCall(Element.prototype, 'setAttribute');
+ resetRemoveAttribute = logCall(Element.prototype, 'removeAttribute');
+ });
+
+ after(() => {
+ resetAppendChild();
+ resetInsertBefore();
+ resetRemoveChild();
+ resetRemove();
+ resetSetAttribute();
+ resetRemoveAttribute();
+ });
+
+ beforeEach(() => {
+ scratch = setupScratch();
+ attributesSpy = spyOnElementAttributes();
+ });
+
+ afterEach(() => {
+ teardown(scratch);
+ clearLog();
+ });
+
+ it('should reuse existing DOM', () => {
+ const onClickSpy = sinon.spy();
+ const html = ul([li('1'), li('2'), li('3')]);
+
+ scratch.innerHTML = html;
+ clearLog();
+
+ hydrate(
+ <ul>
+ <li>1</li>
+ <li>2</li>
+ <li onClick={onClickSpy}>3</li>
+ </ul>,
+ scratch
+ );
+
+ expect(scratch.innerHTML).to.equal(html);
+ expect(getLog()).to.deep.equal([]);
+ expect(onClickSpy).not.to.have.been.called;
+
+ scratch.querySelector('li:last-child').dispatchEvent(createEvent('click'));
+
+ expect(onClickSpy).to.have.been.called.calledOnce;
+ });
+
+ it('should reuse existing DOM when given components', () => {
+ const onClickSpy = sinon.spy();
+ const html = ul([li('1'), li('2'), li('3')]);
+
+ scratch.innerHTML = html;
+ clearLog();
+
+ hydrate(
+ <List>
+ <ListItem>1</ListItem>
+ <ListItem>2</ListItem>
+ <ListItem onClick={onClickSpy}>3</ListItem>
+ </List>,
+ scratch
+ );
+
+ expect(scratch.innerHTML).to.equal(html);
+ expect(getLog()).to.deep.equal([]);
+ expect(onClickSpy).not.to.have.been.called;
+
+ scratch.querySelector('li:last-child').dispatchEvent(createEvent('click'));
+
+ expect(onClickSpy).to.have.been.called.calledOnce;
+ });
+
+ it('should properly set event handlers to existing DOM when given components', () => {
+ const proto = Element.prototype;
+ sinon.spy(proto, 'addEventListener');
+
+ const clickHandlers = [sinon.spy(), sinon.spy(), sinon.spy()];
+
+ const html = ul([li('1'), li('2'), li('3')]);
+
+ scratch.innerHTML = html;
+ clearLog();
+
+ hydrate(
+ <List>
+ <ListItem onClick={clickHandlers[0]}>1</ListItem>
+ <ListItem onClick={clickHandlers[1]}>2</ListItem>
+ <ListItem onClick={clickHandlers[2]}>3</ListItem>
+ </List>,
+ scratch
+ );
+
+ expect(scratch.innerHTML).to.equal(html);
+ expect(getLog()).to.deep.equal([]);
+ expect(proto.addEventListener).to.have.been.calledThrice;
+ expect(clickHandlers[2]).not.to.have.been.called;
+
+ scratch.querySelector('li:last-child').dispatchEvent(createEvent('click'));
+ expect(clickHandlers[2]).to.have.been.calledOnce;
+ });
+
+ it('should add missing nodes to existing DOM when hydrating', () => {
+ const html = ul([li('1')]);
+
+ scratch.innerHTML = html;
+ clearLog();
+
+ hydrate(
+ <List>
+ <ListItem>1</ListItem>
+ <ListItem>2</ListItem>
+ <ListItem>3</ListItem>
+ </List>,
+ scratch
+ );
+
+ expect(scratch.innerHTML).to.equal(ul([li('1'), li('2'), li('3')]));
+ expect(getLog()).to.deep.equal([
+ '<li>.appendChild(#text)',
+ '<ul>1.appendChild(<li>2)',
+ '<li>.appendChild(#text)',
+ '<ul>12.appendChild(<li>3)'
+ ]);
+ });
+
+ it('should remove extra nodes from existing DOM when hydrating', () => {
+ const html = ul([li('1'), li('2'), li('3'), li('4')]);
+
+ scratch.innerHTML = html;
+ clearLog();
+
+ hydrate(
+ <List>
+ <ListItem>1</ListItem>
+ <ListItem>2</ListItem>
+ <ListItem>3</ListItem>
+ </List>,
+ scratch
+ );
+
+ expect(scratch.innerHTML).to.equal(ul([li('1'), li('2'), li('3')]));
+ expect(getLog()).to.deep.equal(['<li>4.remove()']);
+ });
+
+ it('should not update attributes on existing DOM', () => {
+ scratch.innerHTML =
+ '<div><span before-hydrate="test" same-value="foo" different-value="a">Test</span></div>';
+ let vnode = (
+ <div>
+ <span same-value="foo" different-value="b" new-value="c">
+ Test
+ </span>
+ </div>
+ );
+
+ clearLog();
+ hydrate(vnode, scratch);
+
+ // IE11 doesn't support spying on Element.prototype
+ if (!/Trident/.test(navigator.userAgent)) {
+ expect(attributesSpy.get).to.not.have.been.called;
+ }
+
+ expect(serializeHtml(scratch)).to.equal(
+ sortAttributes(
+ '<div><span before-hydrate="test" different-value="a" same-value="foo">Test</span></div>'
+ )
+ );
+ expect(getLog()).to.deep.equal([]);
+ });
+
+ it('should update class attribute via className prop', () => {
+ scratch.innerHTML = '<div class="foo">bar</div>';
+ hydrate(<div className="foo">bar</div>, scratch);
+ expect(scratch.innerHTML).to.equal('<div class="foo">bar</div>');
+ });
+
+ it('should correctly hydrate with Fragments', () => {
+ const html = ul([li('1'), li('2'), li('3'), li('4')]);
+
+ scratch.innerHTML = html;
+ clearLog();
+
+ const clickHandlers = [sinon.spy(), sinon.spy(), sinon.spy(), sinon.spy()];
+
+ hydrate(
+ <List>
+ <ListItem onClick={clickHandlers[0]}>1</ListItem>
+ <Fragment>
+ <ListItem onClick={clickHandlers[1]}>2</ListItem>
+ <ListItem onClick={clickHandlers[2]}>3</ListItem>
+ </Fragment>
+ <ListItem onClick={clickHandlers[3]}>4</ListItem>
+ </List>,
+ scratch
+ );
+
+ expect(scratch.innerHTML).to.equal(html);
+ expect(getLog()).to.deep.equal([]);
+ expect(clickHandlers[2]).not.to.have.been.called;
+
+ scratch
+ .querySelector('li:nth-child(3)')
+ .dispatchEvent(createEvent('click'));
+
+ expect(clickHandlers[2]).to.have.been.called.calledOnce;
+ });
+
+ it('should correctly hydrate root Fragments', () => {
+ const html = [
+ ul([li('1'), li('2'), li('3'), li('4')]),
+ div('sibling')
+ ].join('');
+
+ scratch.innerHTML = html;
+ clearLog();
+
+ const clickHandlers = [
+ sinon.spy(),
+ sinon.spy(),
+ sinon.spy(),
+ sinon.spy(),
+ sinon.spy()
+ ];
+
+ hydrate(
+ <Fragment>
+ <List>
+ <Fragment>
+ <ListItem onClick={clickHandlers[0]}>1</ListItem>
+ <ListItem onClick={clickHandlers[1]}>2</ListItem>
+ </Fragment>
+ <ListItem onClick={clickHandlers[2]}>3</ListItem>
+ <ListItem onClick={clickHandlers[3]}>4</ListItem>
+ </List>
+ <div onClick={clickHandlers[4]}>sibling</div>
+ </Fragment>,
+ scratch
+ );
+
+ expect(scratch.innerHTML).to.equal(html);
+ expect(getLog()).to.deep.equal([]);
+ expect(clickHandlers[2]).not.to.have.been.called;
+
+ scratch
+ .querySelector('li:nth-child(3)')
+ .dispatchEvent(createEvent('click'));
+
+ expect(clickHandlers[2]).to.have.been.calledOnce;
+ expect(clickHandlers[4]).not.to.have.been.called;
+
+ scratch.querySelector('div').dispatchEvent(createEvent('click'));
+
+ expect(clickHandlers[2]).to.have.been.calledOnce;
+ expect(clickHandlers[4]).to.have.been.calledOnce;
+ });
+
+ // Failing because the following condition in diffElementNodes doesn't evaluate to true
+ // when hydrating a dom node which is not correct
+ // dom===d && newVNode.text!==oldVNode.text
+ // We don't set `d` when hydrating. If we did, then newVNode.text would never equal
+ // oldVNode.text since oldVNode is always EMPTY_OBJ when hydrating
+ it.skip('should override incorrect pre-existing DOM with VNodes passed into render', () => {
+ const initialHtml = [
+ div('sibling'),
+ ul([li('1'), li('4'), li('3'), li('2')])
+ ].join('');
+
+ scratch.innerHTML = initialHtml;
+ clearLog();
+
+ hydrate(
+ <Fragment>
+ <List>
+ <Fragment>
+ <ListItem>1</ListItem>
+ <ListItem>2</ListItem>
+ </Fragment>
+ <ListItem>3</ListItem>
+ <ListItem>4</ListItem>
+ </List>
+ <div>sibling</div>
+ </Fragment>,
+ scratch
+ );
+
+ const finalHtml = [
+ ul([li('1'), li('2'), li('3'), li('4')]),
+ div('sibling')
+ ].join('');
+
+ expect(scratch.innerHTML).to.equal(finalHtml);
+ // TODO: Fill in with proper log once this test is passing
+ expect(getLog()).to.deep.equal([]);
+ });
+
+ it('should not merge attributes with node created by the DOM', () => {
+ const html = htmlString => {
+ const div = document.createElement('div');
+ div.innerHTML = htmlString;
+ return div.firstChild;
+ };
+
+ // prettier-ignore
+ const DOMElement = html`<div><a foo="bar"></a></div>`;
+ scratch.appendChild(DOMElement);
+
+ const preactElement = (
+ <div>
+ <a />
+ </div>
+ );
+
+ hydrate(preactElement, scratch);
+ // IE11 doesn't support spies on built-in prototypes
+ if (!/Trident/.test(navigator.userAgent)) {
+ expect(attributesSpy.get).to.not.have.been.called;
+ }
+ expect(scratch).to.have.property(
+ 'innerHTML',
+ '<div><a foo="bar"></a></div>'
+ );
+ });
+
+ it('should attach event handlers', () => {
+ let spy = sinon.spy();
+ scratch.innerHTML = '<span>Test</span>';
+ let vnode = <span onClick={spy}>Test</span>;
+
+ hydrate(vnode, scratch);
+
+ scratch.firstChild.click();
+ expect(spy).to.be.calledOnce;
+ });
+
+ // #2237
+ it('should not redundantly add text nodes', () => {
+ scratch.innerHTML = '<div id="test"><p>hello bar</p></div>';
+ const element = document.getElementById('test');
+ const Component = props => <p>hello {props.foo}</p>;
+
+ hydrate(<Component foo="bar" />, element);
+ expect(element.innerHTML).to.equal('<p>hello bar</p>');
+ });
+
+ it('should not remove values', () => {
+ scratch.innerHTML =
+ '<select><option value="0">Zero</option><option selected value="2">Two</option></select>';
+ const App = () => {
+ const options = [
+ {
+ value: '0',
+ label: 'Zero'
+ },
+ {
+ value: '2',
+ label: 'Two'
+ }
+ ];
+
+ return (
+ <select value="2">
+ {options.map(({ disabled, label, value }) => (
+ <option key={label} disabled={disabled} value={value}>
+ {label}
+ </option>
+ ))}
+ </select>
+ );
+ };
+
+ hydrate(<App />, scratch);
+ expect(sortAttributes(scratch.innerHTML)).to.equal(
+ sortAttributes(
+ '<select><option value="0">Zero</option><option selected="" value="2">Two</option></select>'
+ )
+ );
+ });
+
+ it('should deopt for trees introduced in hydrate (append)', () => {
+ scratch.innerHTML = '<div id="test"><p class="hi">hello bar</p></div>';
+ const Component = props => <p class="hi">hello {props.foo}</p>;
+ const element = document.getElementById('test');
+ hydrate(
+ <Fragment>
+ <Component foo="bar" />
+ <Component foo="baz" />
+ </Fragment>,
+ element
+ );
+ expect(element.innerHTML).to.equal(
+ '<p class="hi">hello bar</p><p class="hi">hello baz</p>'
+ );
+ });
+
+ it('should deopt for trees introduced in hydrate (insert before)', () => {
+ scratch.innerHTML = '<div id="test"><p class="hi">hello bar</p></div>';
+ const Component = props => <p class="hi">hello {props.foo}</p>;
+ const element = document.getElementById('test');
+ hydrate(
+ <Fragment>
+ <Component foo="baz" />
+ <Component foo="bar" />
+ </Fragment>,
+ element
+ );
+ expect(element.innerHTML).to.equal(
+ '<p class="hi">hello baz</p><p class="hi">hello bar</p>'
+ );
+ });
+
+ it('should skip comment nodes', () => {
+ scratch.innerHTML = '<p>hello <!-- c -->foo</p>';
+ hydrate(<p>hello {'foo'}</p>, scratch);
+ expect(scratch.innerHTML).to.equal('<p>hello foo</p>');
+ });
+});
diff --git a/preact/test/browser/isValidElement.test.js b/preact/test/browser/isValidElement.test.js
new file mode 100644
index 0000000..fe2e6a0
--- /dev/null
+++ b/preact/test/browser/isValidElement.test.js
@@ -0,0 +1,4 @@
+import { createElement, isValidElement, Component } from 'preact';
+import { isValidElementTests } from '../shared/isValidElementTests';
+
+isValidElementTests(expect, isValidElement, createElement, Component);
diff --git a/preact/test/browser/keys.test.js b/preact/test/browser/keys.test.js
new file mode 100644
index 0000000..7f6c0e8
--- /dev/null
+++ b/preact/test/browser/keys.test.js
@@ -0,0 +1,627 @@
+import { createElement, Component, render } from 'preact';
+import { setupScratch, teardown } from '../_util/helpers';
+import { logCall, clearLog, getLog } from '../_util/logCall';
+import { div } from '../_util/dom';
+
+/** @jsx createElement */
+
+describe('keys', () => {
+ /** @type {HTMLDivElement} */
+ let scratch;
+
+ /** @type {string[]} */
+ let ops;
+
+ function createStateful(name) {
+ return class Stateful extends Component {
+ componentDidUpdate() {
+ ops.push(`Update ${name}`);
+ }
+ componentDidMount() {
+ ops.push(`Mount ${name}`);
+ }
+ componentWillUnmount() {
+ ops.push(`Unmount ${name}`);
+ }
+ render() {
+ return <div>{name}</div>;
+ }
+ };
+ }
+
+ /** @type {(props: {values: any[]}) => any} */
+ const List = props => (
+ <ol>
+ {props.values.map(value => (
+ <li key={value}>{value}</li>
+ ))}
+ </ol>
+ );
+
+ /**
+ * Move an element in an array from one index to another
+ * @param {any[]} values The array of values
+ * @param {number} from The index to move from
+ * @param {number} to The index to move to
+ */
+ function move(values, from, to) {
+ const value = values[from];
+ values.splice(from, 1);
+ values.splice(to, 0, value);
+ }
+
+ let 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();
+ ops = [];
+ });
+
+ afterEach(() => {
+ teardown(scratch);
+ clearLog();
+ });
+
+ // https://fb.me/react-special-props
+ it('should not pass key in props', () => {
+ const Foo = sinon.spy(() => null);
+ render(<Foo key="foo" />, scratch);
+ expect(Foo.args[0][0]).to.deep.equal({});
+ });
+
+ // See preactjs/preact-compat#21
+ it('should remove orphaned keyed nodes', () => {
+ render(
+ <div>
+ <div>1</div>
+ <li key="a">a</li>
+ <li key="b">b</li>
+ </div>,
+ scratch
+ );
+
+ render(
+ <div>
+ <div>2</div>
+ <li key="b">b</li>
+ <li key="c">c</li>
+ </div>,
+ scratch
+ );
+
+ expect(scratch.innerHTML).to.equal(
+ '<div><div>2</div><li>b</li><li>c</li></div>'
+ );
+ });
+
+ it('should remove keyed nodes (#232)', () => {
+ class App extends Component {
+ componentDidMount() {
+ setTimeout(() => this.setState({ opened: true, loading: true }), 10);
+ setTimeout(() => this.setState({ opened: true, loading: false }), 20);
+ }
+
+ render({ opened, loading }) {
+ return (
+ <BusyIndicator id="app" busy={loading}>
+ <div>This div needs to be here for this to break</div>
+ {opened && !loading && <div>{[]}</div>}
+ </BusyIndicator>
+ );
+ }
+ }
+
+ class BusyIndicator extends Component {
+ render({ children, busy }) {
+ return (
+ <div class={busy ? 'busy' : ''}>
+ {children && children.length ? (
+ children
+ ) : (
+ <div class="busy-placeholder" />
+ )}
+ <div class="indicator">
+ <div>indicator</div>
+ <div>indicator</div>
+ <div>indicator</div>
+ </div>
+ </div>
+ );
+ }
+ }
+
+ render(<App />, scratch);
+ render(<App opened loading />, scratch);
+ render(<App opened />, scratch);
+
+ const html = String(scratch.firstChild.innerHTML).replace(/ class=""/g, '');
+ expect(html).to.equal(
+ '<div>This div needs to be here for this to break</div><div></div><div class="indicator"><div>indicator</div><div>indicator</div><div>indicator</div></div>'
+ );
+ });
+
+ it('should append new keyed elements', () => {
+ const values = ['a', 'b'];
+
+ render(<List values={values} />, scratch);
+ expect(scratch.textContent).to.equal('ab');
+
+ values.push('c');
+ clearLog();
+
+ render(<List values={values} />, scratch);
+ expect(scratch.textContent).to.equal('abc');
+ expect(getLog()).to.deep.equal([
+ '<li>.appendChild(#text)',
+ '<ol>ab.appendChild(<li>c)'
+ ]);
+ });
+
+ it('should remove keyed elements from the end', () => {
+ const values = ['a', 'b', 'c', 'd'];
+
+ render(<List values={values} />, scratch);
+ expect(scratch.textContent).to.equal('abcd');
+
+ values.pop();
+ clearLog();
+
+ render(<List values={values} />, scratch);
+ expect(scratch.textContent).to.equal('abc');
+ expect(getLog()).to.deep.equal(['<li>d.remove()']);
+ });
+
+ it('should prepend keyed elements to the beginning', () => {
+ const values = ['b', 'c'];
+
+ render(<List values={values} />, scratch);
+ expect(scratch.textContent).to.equal('bc');
+
+ values.unshift('a');
+ clearLog();
+
+ render(<List values={values} />, scratch);
+ expect(scratch.textContent).to.equal('abc');
+ expect(getLog()).to.deep.equal([
+ '<li>.appendChild(#text)',
+ '<ol>bc.insertBefore(<li>a, <li>b)'
+ ]);
+ });
+
+ it('should remove keyed elements from the beginning', () => {
+ const values = ['z', 'a', 'b', 'c'];
+
+ render(<List values={values} />, scratch);
+ expect(scratch.textContent).to.equal('zabc');
+
+ values.shift();
+ clearLog();
+
+ render(<List values={values} />, scratch);
+ expect(scratch.textContent).to.equal('abc');
+ expect(getLog()).to.deep.equal(['<li>z.remove()']);
+ });
+
+ it('should insert new keyed children in the middle', () => {
+ const values = ['a', 'c'];
+
+ render(<List values={values} />, scratch);
+ expect(scratch.textContent).to.equal('ac');
+
+ values.splice(1, 0, 'b');
+ clearLog();
+
+ render(<List values={values} />, scratch);
+ expect(scratch.textContent).to.equal('abc');
+ expect(getLog()).to.deep.equal([
+ '<li>.appendChild(#text)',
+ '<ol>ac.insertBefore(<li>b, <li>c)'
+ ]);
+ });
+
+ it('should remove keyed children from the middle', () => {
+ const values = ['a', 'b', 'x', 'y', 'z', 'c', 'd'];
+
+ render(<List values={values} />, scratch);
+ expect(scratch.textContent).to.equal('abxyzcd');
+
+ values.splice(2, 3);
+ clearLog();
+
+ render(<List values={values} />, scratch);
+ expect(scratch.textContent).to.equal('abcd');
+ expect(getLog()).to.deep.equal([
+ '<li>z.remove()',
+ '<li>y.remove()',
+ '<li>x.remove()'
+ ]);
+ });
+
+ it('should swap keyed children efficiently', () => {
+ render(<List values={['a', 'b']} />, scratch);
+ expect(scratch.textContent).to.equal('ab');
+
+ clearLog();
+
+ render(<List values={['b', 'a']} />, scratch);
+ expect(scratch.textContent).to.equal('ba');
+
+ expect(getLog()).to.deep.equal(['<ol>ab.appendChild(<li>a)']);
+ });
+
+ it('should swap existing keyed children in the middle of a list efficiently', () => {
+ const values = ['a', 'b', 'c', 'd'];
+
+ render(<List values={values} />, scratch);
+ expect(scratch.textContent).to.equal('abcd');
+
+ // swap
+ move(values, 1, 2);
+ clearLog();
+
+ render(<List values={values} />, scratch);
+ expect(scratch.textContent).to.equal('acbd', 'initial swap');
+ expect(getLog()).to.deep.equal(
+ ['<ol>abcd.insertBefore(<li>b, <li>d)'],
+ 'initial swap'
+ );
+
+ // swap back
+ move(values, 2, 1);
+ clearLog();
+
+ render(<List values={values} />, scratch);
+ expect(scratch.textContent).to.equal('abcd', 'swap back');
+ expect(getLog()).to.deep.equal(
+ ['<ol>acbd.insertBefore(<li>c, <li>d)'],
+ 'swap back'
+ );
+ });
+
+ it('should move keyed children to the end of the list', () => {
+ const values = ['a', 'b', 'c', 'd'];
+
+ render(<List values={values} />, scratch);
+ expect(scratch.textContent).to.equal('abcd');
+
+ // move to end
+ move(values, 0, values.length - 1);
+ clearLog();
+
+ render(<List values={values} />, scratch);
+ expect(scratch.textContent).to.equal('bcda', 'move to end');
+ expect(getLog()).to.deep.equal(
+ ['<ol>abcd.appendChild(<li>a)'],
+ 'move to end'
+ );
+
+ // move to beginning
+ move(values, values.length - 1, 0);
+ clearLog();
+
+ render(<List values={values} />, scratch);
+ expect(scratch.textContent).to.equal('abcd', 'move to beginning');
+ expect(getLog()).to.deep.equal(
+ ['<ol>bcda.insertBefore(<li>a, <li>b)'],
+ 'move to beginning'
+ );
+ });
+
+ it('should reverse keyed children effectively', () => {
+ const values = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j'];
+
+ render(<List values={values} />, scratch);
+ expect(scratch.textContent).to.equal(values.join(''));
+
+ // reverse list
+ values.reverse();
+ clearLog();
+
+ render(<List values={values} />, scratch);
+ expect(scratch.textContent).to.equal(values.join(''));
+ expect(getLog()).to.deep.equal([
+ '<ol>abcdefghij.insertBefore(<li>j, <li>a)',
+ '<ol>jabcdefghi.insertBefore(<li>i, <li>a)',
+ '<ol>jiabcdefgh.insertBefore(<li>h, <li>a)',
+ '<ol>jihabcdefg.insertBefore(<li>g, <li>a)',
+ '<ol>jihgabcdef.appendChild(<li>e)',
+ '<ol>jihgabcdfe.appendChild(<li>d)',
+ '<ol>jihgabcfed.appendChild(<li>c)',
+ '<ol>jihgabfedc.appendChild(<li>b)',
+ '<ol>jihgafedcb.appendChild(<li>a)'
+ ]);
+ });
+
+ it("should not preserve state when a component's keys are different", () => {
+ const Stateful = createStateful('Stateful');
+
+ function Foo({ condition }) {
+ return condition ? <Stateful key="a" /> : <Stateful key="b" />;
+ }
+
+ ops = [];
+ render(<Foo condition />, scratch);
+ expect(scratch.innerHTML).to.equal('<div>Stateful</div>');
+ expect(ops).to.deep.equal(['Mount Stateful'], 'initial mount');
+
+ ops = [];
+ render(<Foo condition={false} />, scratch);
+ expect(scratch.innerHTML).to.equal('<div>Stateful</div>');
+ expect(ops).to.deep.equal(
+ ['Unmount Stateful', 'Mount Stateful'],
+ 'switching keys 1'
+ );
+
+ ops = [];
+ render(<Foo condition />, scratch);
+ expect(scratch.innerHTML).to.equal('<div>Stateful</div>');
+ expect(ops).to.deep.equal(
+ ['Unmount Stateful', 'Mount Stateful'],
+ 'switching keys 2'
+ );
+ });
+
+ it('should not preserve state between an unkeyed and keyed component', () => {
+ // React and Preact v8 behavior: https://codesandbox.io/s/57prmy5mx
+
+ const Stateful = createStateful('Stateful');
+
+ function Foo({ keyed }) {
+ return keyed ? <Stateful key="a" /> : <Stateful />;
+ }
+
+ ops = [];
+ render(<Foo keyed />, scratch);
+ expect(scratch.innerHTML).to.equal('<div>Stateful</div>');
+ expect(ops).to.deep.equal(['Mount Stateful'], 'initial mount with key');
+
+ ops = [];
+ render(<Foo keyed={false} />, scratch);
+ expect(scratch.innerHTML).to.equal('<div>Stateful</div>');
+ expect(ops).to.deep.equal(
+ ['Unmount Stateful', 'Mount Stateful'],
+ 'switching from keyed to unkeyed'
+ );
+
+ ops = [];
+ render(<Foo keyed />, scratch);
+ expect(scratch.innerHTML).to.equal('<div>Stateful</div>');
+ expect(ops).to.deep.equal(
+ ['Unmount Stateful', 'Mount Stateful'],
+ 'switching from unkeyed to keyed'
+ );
+ });
+
+ it('should not preserve state when keys change with multiple children', () => {
+ // React & Preact v8 behavior: https://codesandbox.io/s/8l3p6lz9kj
+
+ const Stateful1 = createStateful('Stateful1');
+ const Stateful2 = createStateful('Stateful2');
+
+ let Stateful1Ref;
+ let Stateful2Ref;
+ let Stateful1MovedRef;
+ let Stateful2MovedRef;
+
+ function Foo({ moved }) {
+ return moved ? (
+ <div>
+ <div>1</div>
+ <Stateful1 key="c" ref={c => (Stateful1MovedRef = c)} />
+ <div>2</div>
+ <Stateful2 key="d" ref={c => (Stateful2MovedRef = c)} />
+ </div>
+ ) : (
+ <div>
+ <div>1</div>
+ <Stateful1 key="a" ref={c => (Stateful1Ref = c)} />
+ <div>2</div>
+ <Stateful2 key="b" ref={c => (Stateful2Ref = c)} />
+ </div>
+ );
+ }
+
+ const expectedHtml = div([
+ div(1),
+ div('Stateful1'),
+ div(2),
+ div('Stateful2')
+ ]);
+
+ ops = [];
+ render(<Foo moved={false} />, scratch);
+
+ expect(scratch.innerHTML).to.equal(expectedHtml);
+ expect(ops).to.deep.equal(['Mount Stateful1', 'Mount Stateful2']);
+ expect(Stateful1Ref).to.exist;
+ expect(Stateful2Ref).to.exist;
+
+ ops = [];
+ render(<Foo moved />, scratch);
+
+ expect(scratch.innerHTML).to.equal(expectedHtml);
+ expect(ops).to.deep.equal([
+ 'Unmount Stateful2',
+ 'Unmount Stateful1',
+ 'Mount Stateful1',
+ 'Mount Stateful2'
+ ]);
+ expect(Stateful1MovedRef).to.not.equal(Stateful1Ref);
+ expect(Stateful2MovedRef).to.not.equal(Stateful2Ref);
+
+ ops = [];
+ render(<Foo moved={false} />, scratch);
+
+ expect(scratch.innerHTML).to.equal(expectedHtml);
+ expect(ops).to.deep.equal([
+ 'Unmount Stateful2',
+ 'Unmount Stateful1',
+ 'Mount Stateful1',
+ 'Mount Stateful2'
+ ]);
+ expect(Stateful1Ref).to.not.equal(Stateful1MovedRef);
+ expect(Stateful2Ref).to.not.equal(Stateful2MovedRef);
+ });
+
+ it('should preserve state when moving keyed children components', () => {
+ // React & Preact v8 behavior: https://codesandbox.io/s/8l3p6lz9kj
+
+ const Stateful1 = createStateful('Stateful1');
+ const Stateful2 = createStateful('Stateful2');
+
+ let Stateful1Ref;
+ let Stateful2Ref;
+ let Stateful1MovedRef;
+ let Stateful2MovedRef;
+
+ function Foo({ moved }) {
+ return moved ? (
+ <div>
+ <div>1</div>
+ <Stateful2
+ key="b"
+ ref={c => (c ? (Stateful2MovedRef = c) : undefined)}
+ />
+ <div>2</div>
+ <Stateful1
+ key="a"
+ ref={c => (c ? (Stateful1MovedRef = c) : undefined)}
+ />
+ </div>
+ ) : (
+ <div>
+ <div>1</div>
+ <Stateful1 key="a" ref={c => (c ? (Stateful1Ref = c) : undefined)} />
+ <div>2</div>
+ <Stateful2 key="b" ref={c => (c ? (Stateful2Ref = c) : undefined)} />
+ </div>
+ );
+ }
+
+ const htmlForFalse = div([
+ div(1),
+ div('Stateful1'),
+ div(2),
+ div('Stateful2')
+ ]);
+
+ const htmlForTrue = div([
+ div(1),
+ div('Stateful2'),
+ div(2),
+ div('Stateful1')
+ ]);
+
+ ops = [];
+ render(<Foo moved={false} />, scratch);
+
+ expect(scratch.innerHTML).to.equal(htmlForFalse);
+ expect(ops).to.deep.equal(['Mount Stateful1', 'Mount Stateful2']);
+ expect(Stateful1Ref).to.exist;
+ expect(Stateful2Ref).to.exist;
+
+ ops = [];
+ render(<Foo moved />, scratch);
+
+ expect(scratch.innerHTML).to.equal(htmlForTrue);
+ expect(ops).to.deep.equal(['Update Stateful2', 'Update Stateful1']);
+ expect(Stateful1MovedRef).to.equal(Stateful1Ref);
+ expect(Stateful2MovedRef).to.equal(Stateful2Ref);
+
+ ops = [];
+ render(<Foo moved={false} />, scratch);
+
+ expect(scratch.innerHTML).to.equal(htmlForFalse);
+ expect(ops).to.deep.equal(['Update Stateful1', 'Update Stateful2']);
+ expect(Stateful1Ref).to.equal(Stateful1MovedRef);
+ expect(Stateful2Ref).to.equal(Stateful2MovedRef);
+ });
+
+ it('should not preserve state when switching between keyed and unkeyed components as children', () => {
+ // React & Preact v8 behavior: https://codesandbox.io/s/8l3p6lz9kj
+
+ const Stateful1 = createStateful('Stateful1');
+ const Stateful2 = createStateful('Stateful2');
+
+ let Stateful1Ref;
+ let Stateful2Ref;
+ let Stateful1MovedRef;
+ let Stateful2MovedRef;
+
+ function Foo({ unkeyed }) {
+ return unkeyed ? (
+ <div>
+ <div>1</div>
+ <Stateful1 ref={c => (Stateful2MovedRef = c)} />
+ <div>2</div>
+ <Stateful2 ref={c => (Stateful1MovedRef = c)} />
+ </div>
+ ) : (
+ <div>
+ <div>1</div>
+ <Stateful1 key="a" ref={c => (Stateful1Ref = c)} />
+ <div>2</div>
+ <Stateful2 key="b" ref={c => (Stateful2Ref = c)} />
+ </div>
+ );
+ }
+
+ const expectedHtml = div([
+ div(1),
+ div('Stateful1'),
+ div(2),
+ div('Stateful2')
+ ]);
+
+ ops = [];
+ render(<Foo unkeyed={false} />, scratch);
+
+ expect(scratch.innerHTML).to.equal(expectedHtml);
+ expect(ops).to.deep.equal(['Mount Stateful1', 'Mount Stateful2']);
+ expect(Stateful1Ref).to.exist;
+ expect(Stateful2Ref).to.exist;
+
+ ops = [];
+ render(<Foo unkeyed />, scratch);
+
+ expect(scratch.innerHTML).to.equal(expectedHtml);
+ expect(ops).to.deep.equal([
+ 'Unmount Stateful2',
+ 'Unmount Stateful1',
+ 'Mount Stateful1',
+ 'Mount Stateful2'
+ ]);
+ expect(Stateful1MovedRef).to.not.equal(Stateful1Ref);
+ expect(Stateful2MovedRef).to.not.equal(Stateful2Ref);
+
+ ops = [];
+ render(<Foo unkeyed={false} />, scratch);
+
+ expect(scratch.innerHTML).to.equal(expectedHtml);
+ expect(ops).to.deep.equal([
+ 'Unmount Stateful2',
+ 'Unmount Stateful1',
+ 'Mount Stateful1',
+ 'Mount Stateful2'
+ ]);
+ expect(Stateful1Ref).to.not.equal(Stateful1MovedRef);
+ expect(Stateful2Ref).to.not.equal(Stateful2MovedRef);
+ });
+});
diff --git a/preact/test/browser/lifecycles/componentDidCatch.test.js b/preact/test/browser/lifecycles/componentDidCatch.test.js
new file mode 100644
index 0000000..6587dd6
--- /dev/null
+++ b/preact/test/browser/lifecycles/componentDidCatch.test.js
@@ -0,0 +1,672 @@
+import { setupRerender } from 'preact/test-utils';
+import { createElement, render, Component, Fragment } from 'preact';
+import { setupScratch, teardown } from '../../_util/helpers';
+
+/** @jsx createElement */
+
+describe('Lifecycle methods', () => {
+ /* eslint-disable react/display-name */
+
+ /** @type {HTMLDivElement} */
+ let scratch;
+
+ /** @type {() => void} */
+ let rerender;
+
+ beforeEach(() => {
+ scratch = setupScratch();
+ rerender = setupRerender();
+ });
+
+ afterEach(() => {
+ teardown(scratch);
+ });
+
+ describe('#componentDidCatch', () => {
+ /** @type {Error} */
+ let expectedError;
+
+ /** @type {typeof import('../../../').Component} */
+ let ThrowErr;
+ class Receiver extends Component {
+ componentDidCatch(error) {
+ this.setState({ error });
+ }
+
+ render() {
+ return this.state.error
+ ? String(this.state.error)
+ : this.props.children;
+ }
+ }
+
+ let thrower;
+
+ sinon.spy(Receiver.prototype, 'componentDidCatch');
+ sinon.spy(Receiver.prototype, 'render');
+
+ function throwExpectedError() {
+ throw (expectedError = new Error('Error!'));
+ }
+
+ beforeEach(() => {
+ ThrowErr = class ThrowErr extends Component {
+ constructor(props) {
+ super(props);
+ thrower = this;
+ }
+
+ componentDidCatch() {
+ expect.fail("Throwing component should not catch it's own error.");
+ }
+ render() {
+ return <div>ThrowErr: componentDidCatch</div>;
+ }
+ };
+ sinon.spy(ThrowErr.prototype, 'componentDidCatch');
+
+ expectedError = undefined;
+
+ Receiver.prototype.componentDidCatch.resetHistory();
+ Receiver.prototype.render.resetHistory();
+ });
+
+ afterEach(() => {
+ expect(
+ ThrowErr.prototype.componentDidCatch,
+ "Throwing component should not catch it's own error."
+ ).to.not.be.called;
+ thrower = undefined;
+ });
+
+ it('should be called when child fails in constructor', () => {
+ class ThrowErr extends Component {
+ constructor(props, context) {
+ super(props, context);
+ throwExpectedError();
+ }
+ componentDidCatch() {
+ expect.fail("Throwing component should not catch it's own error");
+ }
+ render() {
+ return <div />;
+ }
+ }
+
+ render(
+ <Receiver>
+ <ThrowErr />
+ </Receiver>,
+ scratch
+ );
+ rerender();
+
+ expect(Receiver.prototype.componentDidCatch).to.have.been.calledWith(
+ expectedError
+ );
+ });
+
+ // https://github.com/preactjs/preact/issues/1570
+ it('should handle double child throws', () => {
+ const Child = ({ i }) => {
+ throw new Error(`error! ${i}`);
+ };
+
+ const fn = () =>
+ render(
+ <Receiver>
+ {[1, 2].map(i => (
+ <Child key={i} i={i} />
+ ))}
+ </Receiver>,
+ scratch
+ );
+ expect(fn).to.not.throw();
+
+ rerender();
+ expect(scratch.innerHTML).to.equal('Error: error! 2');
+ });
+
+ it('should be called when child fails in componentWillMount', () => {
+ ThrowErr.prototype.componentWillMount = throwExpectedError;
+
+ render(
+ <Receiver>
+ <ThrowErr />
+ </Receiver>,
+ scratch
+ );
+ expect(Receiver.prototype.componentDidCatch).to.have.been.calledWith(
+ expectedError
+ );
+ });
+
+ it('should be called when child fails in render', () => {
+ ThrowErr.prototype.render = throwExpectedError;
+
+ render(
+ <Receiver>
+ <ThrowErr />
+ </Receiver>,
+ scratch
+ );
+ expect(Receiver.prototype.componentDidCatch).to.have.been.calledWith(
+ expectedError
+ );
+ });
+
+ it('should be called when child fails in componentDidMount', () => {
+ ThrowErr.prototype.componentDidMount = throwExpectedError;
+
+ render(
+ <Receiver>
+ <ThrowErr />
+ </Receiver>,
+ scratch
+ );
+ expect(Receiver.prototype.componentDidCatch).to.have.been.calledWith(
+ expectedError
+ );
+ });
+
+ it('should be called when child fails in getDerivedStateFromProps', () => {
+ ThrowErr.getDerivedStateFromProps = throwExpectedError;
+
+ sinon.spy(ThrowErr.prototype, 'render');
+ render(
+ <Receiver>
+ <ThrowErr />
+ </Receiver>,
+ scratch
+ );
+
+ expect(Receiver.prototype.componentDidCatch).to.have.been.calledWith(
+ expectedError
+ );
+ expect(ThrowErr.prototype.render).not.to.have.been.called;
+ });
+
+ it('should be called when child fails in getSnapshotBeforeUpdate', () => {
+ ThrowErr.prototype.getSnapshotBeforeUpdate = throwExpectedError;
+
+ render(
+ <Receiver>
+ <ThrowErr />
+ </Receiver>,
+ scratch
+ );
+ thrower.forceUpdate();
+ rerender();
+
+ expect(Receiver.prototype.componentDidCatch).to.have.been.calledWith(
+ expectedError
+ );
+ });
+
+ it('should be called when child fails in componentDidUpdate', () => {
+ ThrowErr.prototype.componentDidUpdate = throwExpectedError;
+
+ render(
+ <Receiver>
+ <ThrowErr />
+ </Receiver>,
+ scratch
+ );
+
+ thrower.forceUpdate();
+ rerender();
+ expect(Receiver.prototype.componentDidCatch).to.have.been.calledWith(
+ expectedError
+ );
+ });
+
+ it('should be called when child fails in componentWillUpdate', () => {
+ ThrowErr.prototype.componentWillUpdate = throwExpectedError;
+
+ render(
+ <Receiver>
+ <ThrowErr />
+ </Receiver>,
+ scratch
+ );
+
+ thrower.forceUpdate();
+ rerender();
+ expect(Receiver.prototype.componentDidCatch).to.have.been.calledWith(
+ expectedError
+ );
+ });
+
+ it('should be called when child fails in componentWillReceiveProps', () => {
+ ThrowErr.prototype.componentWillReceiveProps = throwExpectedError;
+
+ let receiver;
+ class Receiver extends Component {
+ constructor() {
+ super();
+ this.state = { foo: 'bar' };
+ receiver = this;
+ }
+ componentDidCatch(error) {
+ this.setState({ error });
+ }
+ render() {
+ return this.state.error ? (
+ String(this.state.error)
+ ) : (
+ <ThrowErr foo={this.state.foo} />
+ );
+ }
+ }
+
+ sinon.spy(Receiver.prototype, 'componentDidCatch');
+ render(<Receiver />, scratch);
+
+ receiver.setState({ foo: 'baz' });
+ rerender();
+
+ expect(Receiver.prototype.componentDidCatch).to.have.been.calledWith(
+ expectedError
+ );
+ });
+
+ it('should be called when child fails in shouldComponentUpdate', () => {
+ ThrowErr.prototype.shouldComponentUpdate = throwExpectedError;
+
+ let receiver;
+ class Receiver extends Component {
+ constructor() {
+ super();
+ this.state = { foo: 'bar' };
+ receiver = this;
+ }
+ componentDidCatch(error) {
+ this.setState({ error });
+ }
+ render() {
+ return this.state.error ? (
+ String(this.state.error)
+ ) : (
+ <ThrowErr foo={this.state.foo} />
+ );
+ }
+ }
+
+ sinon.spy(Receiver.prototype, 'componentDidCatch');
+ render(<Receiver />, scratch);
+
+ receiver.setState({ foo: 'baz' });
+ rerender();
+
+ expect(Receiver.prototype.componentDidCatch).to.have.been.calledWith(
+ expectedError
+ );
+ });
+
+ it('should be called when child fails in componentWillUnmount', () => {
+ ThrowErr.prototype.componentWillUnmount = throwExpectedError;
+
+ render(
+ <Receiver>
+ <ThrowErr />
+ </Receiver>,
+ scratch
+ );
+ render(
+ <Receiver>
+ <div />
+ </Receiver>,
+ scratch
+ );
+ expect(Receiver.prototype.componentDidCatch).to.have.been.calledWith(
+ expectedError
+ );
+ });
+
+ it('should be called when applying a Component ref', () => {
+ const Foo = () => <div />;
+
+ const ref = value => {
+ if (value) {
+ throwExpectedError();
+ }
+ };
+
+ // In React, an error boundary handles it's own refs:
+ // https://codesandbox.io/s/react-throwing-refs-lk958
+ class Receiver extends Component {
+ componentDidCatch(error) {
+ this.setState({ error });
+ }
+ render() {
+ return this.state.error ? (
+ String(this.state.error)
+ ) : (
+ <Foo ref={ref} />
+ );
+ }
+ }
+
+ sinon.spy(Receiver.prototype, 'componentDidCatch');
+ render(<Receiver />, scratch);
+ expect(Receiver.prototype.componentDidCatch).to.have.been.calledWith(
+ expectedError
+ );
+ });
+
+ it('should be called when applying a DOM ref', () => {
+ const ref = value => {
+ if (value) {
+ throwExpectedError();
+ }
+ };
+
+ // In React, an error boundary handles it's own refs:
+ // https://codesandbox.io/s/react-throwing-refs-lk958
+ class Receiver extends Component {
+ componentDidCatch(error) {
+ this.setState({ error });
+ }
+ render() {
+ return this.state.error ? (
+ String(this.state.error)
+ ) : (
+ <div ref={ref} />
+ );
+ }
+ }
+
+ sinon.spy(Receiver.prototype, 'componentDidCatch');
+ render(<Receiver />, scratch);
+ expect(Receiver.prototype.componentDidCatch).to.have.been.calledWith(
+ expectedError
+ );
+ });
+
+ it('should be called when unmounting a ref', () => {
+ const ref = value => {
+ if (value == null) {
+ throwExpectedError();
+ }
+ };
+
+ ThrowErr.prototype.render = () => <div ref={ref} />;
+
+ render(
+ <Receiver>
+ <ThrowErr />
+ </Receiver>,
+ scratch
+ );
+ render(
+ <Receiver>
+ <div />
+ </Receiver>,
+ scratch
+ );
+ expect(Receiver.prototype.componentDidCatch).to.have.been.calledOnceWith(
+ expectedError
+ );
+ });
+
+ it('should be called when functional child fails', () => {
+ function ThrowErr() {
+ throwExpectedError();
+ }
+
+ render(
+ <Receiver>
+ <ThrowErr />
+ </Receiver>,
+ scratch
+ );
+ expect(Receiver.prototype.componentDidCatch).to.have.been.calledWith(
+ expectedError
+ );
+ });
+
+ it('should be called when child inside a Fragment fails', () => {
+ function ThrowErr() {
+ throwExpectedError();
+ }
+
+ render(
+ <Receiver>
+ <Fragment>
+ <ThrowErr />
+ </Fragment>
+ </Receiver>,
+ scratch
+ );
+ expect(Receiver.prototype.componentDidCatch).to.have.been.calledWith(
+ expectedError
+ );
+ });
+
+ it('should re-render with new content', () => {
+ class ThrowErr extends Component {
+ componentWillMount() {
+ throw new Error('Error contents');
+ }
+ render() {
+ return 'No error!?!?';
+ }
+ }
+
+ render(
+ <Receiver>
+ <ThrowErr />
+ </Receiver>,
+ scratch
+ );
+ rerender();
+ expect(scratch).to.have.property('textContent', 'Error: Error contents');
+ });
+
+ it('should be able to adapt and rethrow errors', () => {
+ let adaptedError;
+ class Adapter extends Component {
+ componentDidCatch(error) {
+ throw (adaptedError = new Error(
+ 'Adapted ' +
+ String(error && 'message' in error ? error.message : error)
+ ));
+ }
+ render() {
+ return <div>{this.props.children}</div>;
+ }
+ }
+
+ function ThrowErr() {
+ throwExpectedError();
+ }
+
+ sinon.spy(Adapter.prototype, 'componentDidCatch');
+ render(
+ <Receiver>
+ <Adapter>
+ <ThrowErr />
+ </Adapter>
+ </Receiver>,
+ scratch
+ );
+
+ expect(Adapter.prototype.componentDidCatch).to.have.been.calledWith(
+ expectedError
+ );
+ expect(Receiver.prototype.componentDidCatch).to.have.been.calledWith(
+ adaptedError
+ );
+
+ rerender();
+ expect(scratch).to.have.property('textContent', 'Error: Adapted Error!');
+ });
+
+ it('should bubble on repeated errors', () => {
+ class Adapter extends Component {
+ componentDidCatch(error) {
+ // Try to handle the error
+ this.setState({ error });
+ }
+ render() {
+ // But fail at doing so
+ if (this.state.error) {
+ throw this.state.error;
+ }
+ return <div>{this.props.children}</div>;
+ }
+ }
+
+ function ThrowErr() {
+ throwExpectedError();
+ }
+
+ sinon.spy(Adapter.prototype, 'componentDidCatch');
+
+ render(
+ <Receiver>
+ <Adapter>
+ <ThrowErr />
+ </Adapter>
+ </Receiver>,
+ scratch
+ );
+ rerender();
+
+ expect(Adapter.prototype.componentDidCatch).to.have.been.calledWith(
+ expectedError
+ );
+ expect(Receiver.prototype.componentDidCatch).to.have.been.calledWith(
+ expectedError
+ );
+ expect(scratch).to.have.property('textContent', 'Error: Error!');
+ });
+
+ it('should bubble on ignored errors', () => {
+ class Adapter extends Component {
+ componentDidCatch() {
+ // Ignore the error
+ }
+ render() {
+ return <div>{this.props.children}</div>;
+ }
+ }
+
+ function ThrowErr() {
+ throw new Error('Error!');
+ }
+
+ sinon.spy(Adapter.prototype, 'componentDidCatch');
+
+ render(
+ <Receiver>
+ <Adapter>
+ <ThrowErr />
+ </Adapter>
+ </Receiver>,
+ scratch
+ );
+ rerender();
+
+ expect(Adapter.prototype.componentDidCatch, 'Adapter').to.have.been
+ .called;
+ expect(Receiver.prototype.componentDidCatch, 'Receiver').to.have.been
+ .called;
+ expect(scratch).to.have.property('textContent', 'Error: Error!');
+ });
+
+ it('should not bubble on caught errors', () => {
+ class TopReceiver extends Component {
+ componentDidCatch(error) {
+ this.setState({ error });
+ }
+ render() {
+ return (
+ <div>
+ {this.state.error
+ ? String(this.state.error)
+ : this.props.children}
+ </div>
+ );
+ }
+ }
+
+ function ThrowErr() {
+ throwExpectedError();
+ }
+
+ sinon.spy(TopReceiver.prototype, 'componentDidCatch');
+
+ render(
+ <TopReceiver>
+ <Receiver>
+ <ThrowErr />
+ </Receiver>
+ </TopReceiver>,
+ scratch
+ );
+ rerender();
+
+ expect(TopReceiver.prototype.componentDidCatch).not.to.have.been.called;
+ expect(Receiver.prototype.componentDidCatch).to.have.been.calledWith(
+ expectedError
+ );
+ expect(scratch).to.have.property('textContent', 'Error: Error!');
+ });
+
+ it('should be called through non-component parent elements', () => {
+ ThrowErr.prototype.render = throwExpectedError;
+ render(
+ <Receiver>
+ <div>
+ <ThrowErr />
+ </div>
+ </Receiver>,
+ scratch
+ );
+ expect(Receiver.prototype.componentDidCatch).to.have.been.calledWith(
+ expectedError
+ );
+ });
+
+ it('should bubble up when ref throws on component that is not an error boundary', () => {
+ const ref = value => {
+ if (value) {
+ throwExpectedError();
+ }
+ };
+
+ function ThrowErr() {
+ return <div ref={ref} />;
+ }
+
+ render(
+ <Receiver>
+ <ThrowErr />
+ </Receiver>,
+ scratch
+ );
+ expect(Receiver.prototype.componentDidCatch).to.have.been.calledWith(
+ expectedError
+ );
+ });
+
+ it.skip('should successfully unmount constantly throwing ref', () => {
+ const buggyRef = throwExpectedError;
+
+ function ThrowErr() {
+ return <div ref={buggyRef}>ThrowErr</div>;
+ }
+
+ render(
+ <Receiver>
+ <ThrowErr />
+ </Receiver>,
+ scratch
+ );
+ rerender();
+
+ expect(scratch.innerHTML).to.equal('<div>Error: Error!</div>');
+ });
+ });
+});
diff --git a/preact/test/browser/lifecycles/componentDidMount.test.js b/preact/test/browser/lifecycles/componentDidMount.test.js
new file mode 100644
index 0000000..086702b
--- /dev/null
+++ b/preact/test/browser/lifecycles/componentDidMount.test.js
@@ -0,0 +1,36 @@
+import { createElement, render, Component } from 'preact';
+import { setupScratch, teardown } from '../../_util/helpers';
+
+/** @jsx createElement */
+
+describe('Lifecycle methods', () => {
+ /** @type {HTMLDivElement} */
+ let scratch;
+
+ beforeEach(() => {
+ scratch = setupScratch();
+ });
+
+ afterEach(() => {
+ teardown(scratch);
+ });
+
+ describe('#componentDidMount', () => {
+ it('is invoked after refs are set', () => {
+ const spy = sinon.spy();
+
+ class App extends Component {
+ componentDidMount() {
+ expect(spy).to.have.been.calledOnceWith(scratch.firstChild);
+ }
+
+ render() {
+ return <div ref={spy} />;
+ }
+ }
+
+ render(<App />, scratch);
+ expect(spy).to.have.been.calledOnceWith(scratch.firstChild);
+ });
+ });
+});
diff --git a/preact/test/browser/lifecycles/componentDidUpdate.test.js b/preact/test/browser/lifecycles/componentDidUpdate.test.js
new file mode 100644
index 0000000..647adb8
--- /dev/null
+++ b/preact/test/browser/lifecycles/componentDidUpdate.test.js
@@ -0,0 +1,385 @@
+import { setupRerender } from 'preact/test-utils';
+import { createElement, render, Component } from 'preact';
+import { setupScratch, teardown } from '../../_util/helpers';
+
+/** @jsx createElement */
+
+describe('Lifecycle methods', () => {
+ /** @type {HTMLDivElement} */
+ let scratch;
+
+ /** @type {() => void} */
+ let rerender;
+
+ beforeEach(() => {
+ scratch = setupScratch();
+ rerender = setupRerender();
+ });
+
+ afterEach(() => {
+ teardown(scratch);
+ });
+
+ describe('#componentDidUpdate', () => {
+ it('should be passed previous props and state', () => {
+ /** @type {() => void} */
+ let updateState;
+
+ let prevPropsArg;
+ let prevStateArg;
+ let snapshotArg;
+ let curProps;
+ let curState;
+
+ class Foo extends Component {
+ constructor(props) {
+ super(props);
+ this.state = {
+ value: 0
+ };
+ updateState = () =>
+ this.setState({
+ value: this.state.value + 1
+ });
+ }
+ static getDerivedStateFromProps(props, state) {
+ // NOTE: Don't do this in real production code!
+ // https://reactjs.org/blog/2018/06/07/you-probably-dont-need-derived-state.html
+ return {
+ value: state.value + 1
+ };
+ }
+ componentDidUpdate(prevProps, prevState, snapshot) {
+ // These object references might be updated later so copy
+ // object so we can assert their values at this snapshot in time
+ prevPropsArg = { ...prevProps };
+ prevStateArg = { ...prevState };
+ snapshotArg = snapshot;
+
+ curProps = { ...this.props };
+ curState = { ...this.state };
+ }
+ render() {
+ return <div>{this.state.value}</div>;
+ }
+ }
+
+ // Expectation:
+ // `prevState` in componentDidUpdate should be
+ // the state before setState and getDerivedStateFromProps was called.
+ // `this.state` in componentDidUpdate should be
+ // the updated state after getDerivedStateFromProps was called.
+
+ // Initial render
+ // state.value: initialized to 0 in constructor, 0 -> 1 in gDSFP
+ render(<Foo foo="foo" />, scratch);
+ expect(scratch.firstChild.textContent).to.be.equal('1');
+ expect(prevPropsArg).to.be.undefined;
+ expect(prevStateArg).to.be.undefined;
+ expect(snapshotArg).to.be.undefined;
+ expect(curProps).to.be.undefined;
+ expect(curState).to.be.undefined;
+
+ // New props
+ // state.value: 1 -> 2 in gDSFP
+ render(<Foo foo="bar" />, scratch);
+ expect(scratch.firstChild.textContent).to.be.equal('2');
+ expect(prevPropsArg).to.deep.equal({ foo: 'foo' });
+ expect(prevStateArg).to.deep.equal({ value: 1 });
+ expect(snapshotArg).to.be.undefined;
+ expect(curProps).to.deep.equal({ foo: 'bar' });
+ expect(curState).to.deep.equal({ value: 2 });
+
+ // New state
+ // state.value: 2 -> 3 in updateState, 3 -> 4 in gDSFP
+ updateState();
+ rerender();
+ expect(scratch.firstChild.textContent).to.be.equal('4');
+ expect(prevPropsArg).to.deep.equal({ foo: 'bar' });
+ expect(prevStateArg).to.deep.equal({ value: 2 });
+ expect(snapshotArg).to.be.undefined;
+ expect(curProps).to.deep.equal({ foo: 'bar' });
+ expect(curState).to.deep.equal({ value: 4 });
+ });
+
+ it('cDU should not be called when sDU returned false', () => {
+ let spy = sinon.spy();
+ let c;
+
+ class App extends Component {
+ constructor() {
+ super();
+ c = this;
+ }
+
+ shouldComponentUpdate() {
+ return false;
+ }
+
+ componentDidUpdate(prevProps) {
+ spy(prevProps);
+ }
+ }
+
+ render(<App />, scratch);
+ c.setState({});
+ rerender();
+
+ expect(spy).to.not.be.called;
+ });
+
+ it("prevState argument should be the same object if state doesn't change", () => {
+ let changeProps, cduPrevState, cduCurrentState;
+
+ class PropsProvider extends Component {
+ constructor() {
+ super();
+ this.state = { value: 0 };
+ changeProps = this.changeReceiverProps.bind(this);
+ }
+ changeReceiverProps() {
+ let value = (this.state.value + 1) % 2;
+ this.setState({
+ value
+ });
+ }
+ render() {
+ return <PropsReceiver value={this.state.value} />;
+ }
+ }
+
+ class PropsReceiver extends Component {
+ componentDidUpdate(prevProps, prevState) {
+ cduPrevState = prevState;
+ cduCurrentState = this.state;
+ }
+ render({ value }) {
+ return <div>{value}</div>;
+ }
+ }
+
+ render(<PropsProvider />, scratch);
+
+ changeProps();
+ rerender();
+
+ expect(cduPrevState).to.equal(cduCurrentState);
+ });
+
+ it('prevState argument should be a different object if state does change', () => {
+ let updateState, cduPrevState, cduCurrentState;
+
+ class Foo extends Component {
+ constructor() {
+ super();
+ this.state = { value: 0 };
+ updateState = this.updateState.bind(this);
+ }
+ updateState() {
+ let value = (this.state.value + 1) % 2;
+ this.setState({
+ value
+ });
+ }
+ componentDidUpdate(prevProps, prevState) {
+ cduPrevState = prevState;
+ cduCurrentState = this.state;
+ }
+ render() {
+ return <div>{this.state.value}</div>;
+ }
+ }
+
+ render(<Foo />, scratch);
+
+ updateState();
+ rerender();
+
+ expect(cduPrevState).to.not.equal(cduCurrentState);
+ });
+
+ it("prevProps argument should be the same object if props don't change", () => {
+ let updateState, cduPrevProps, cduCurrentProps;
+
+ class Foo extends Component {
+ constructor() {
+ super();
+ this.state = { value: 0 };
+ updateState = this.updateState.bind(this);
+ }
+ updateState() {
+ let value = (this.state.value + 1) % 2;
+ this.setState({
+ value
+ });
+ }
+ componentDidUpdate(prevProps) {
+ cduPrevProps = prevProps;
+ cduCurrentProps = this.props;
+ }
+ render() {
+ return <div>{this.state.value}</div>;
+ }
+ }
+
+ render(<Foo />, scratch);
+
+ updateState();
+ rerender();
+
+ expect(cduPrevProps).to.equal(cduCurrentProps);
+ });
+
+ it('prevProps argument should be a different object if props do change', () => {
+ let changeProps, cduPrevProps, cduCurrentProps;
+
+ class PropsProvider extends Component {
+ constructor() {
+ super();
+ this.state = { value: 0 };
+ changeProps = this.changeReceiverProps.bind(this);
+ }
+ changeReceiverProps() {
+ let value = (this.state.value + 1) % 2;
+ this.setState({
+ value
+ });
+ }
+ render() {
+ return <PropsReceiver value={this.state.value} />;
+ }
+ }
+
+ class PropsReceiver extends Component {
+ componentDidUpdate(prevProps) {
+ cduPrevProps = prevProps;
+ cduCurrentProps = this.props;
+ }
+ render({ value }) {
+ return <div>{value}</div>;
+ }
+ }
+
+ render(<PropsProvider />, scratch);
+
+ changeProps();
+ rerender();
+
+ expect(cduPrevProps).to.not.equal(cduCurrentProps);
+ });
+
+ it('is invoked after refs are set', () => {
+ const spy = sinon.spy();
+ let inst;
+ let i = 0;
+
+ class App extends Component {
+ componentDidUpdate() {
+ expect(spy).to.have.been.calledOnceWith(scratch.firstChild);
+ }
+
+ render() {
+ let ref = null;
+
+ if (i > 0) {
+ // Add ref after mount (i > 0)
+ ref = spy;
+ }
+
+ i++;
+ inst = this;
+ return <div ref={ref} />;
+ }
+ }
+
+ render(<App />, scratch);
+ expect(spy).not.to.have.been.called;
+
+ inst.setState({});
+ rerender();
+
+ expect(spy).to.have.been.calledOnceWith(scratch.firstChild);
+ });
+
+ it('should be called after children are mounted', () => {
+ let log = [];
+
+ class Inner extends Component {
+ componentDidMount() {
+ log.push('Inner mounted');
+
+ // Verify that the component is actually mounted when this
+ // callback is invoked.
+ expect(scratch.querySelector('#inner')).to.equalNode(this.base);
+ }
+
+ render() {
+ return <div id="inner" />;
+ }
+ }
+
+ class Outer extends Component {
+ componentDidUpdate() {
+ log.push('Outer updated');
+ }
+
+ render(props) {
+ return props.renderInner ? <Inner /> : <div />;
+ }
+ }
+
+ render(<Outer renderInner={false} />, scratch);
+ render(<Outer renderInner />, scratch);
+
+ expect(log).to.deep.equal(['Inner mounted', 'Outer updated']);
+ });
+
+ it('should be called after parent DOM elements are updated', () => {
+ let setValue;
+ let outerChildText;
+
+ class Outer extends Component {
+ constructor(p, c) {
+ super(p, c);
+
+ this.state = { i: 0 };
+ setValue = i => this.setState({ i });
+ }
+
+ render(props, { i }) {
+ return (
+ <div>
+ <Inner i={i} {...props} />
+ <p id="parent-child">Outer: {i}</p>
+ </div>
+ );
+ }
+ }
+
+ class Inner extends Component {
+ componentDidUpdate() {
+ // At this point, the parent's <p> tag should've been updated with the latest value
+ outerChildText = scratch.querySelector('#parent-child').textContent;
+ }
+
+ render(props, { i }) {
+ return <div>Inner: {i}</div>;
+ }
+ }
+
+ sinon.spy(Inner.prototype, 'componentDidUpdate');
+
+ // Initial render
+ render(<Outer />, scratch);
+ expect(Inner.prototype.componentDidUpdate).to.not.have.been.called;
+
+ // Set state with a new i
+ const newValue = 5;
+ setValue(newValue);
+ rerender();
+
+ expect(Inner.prototype.componentDidUpdate).to.have.been.called;
+ expect(outerChildText).to.equal(`Outer: ${newValue.toString()}`);
+ });
+ });
+});
diff --git a/preact/test/browser/lifecycles/componentWillMount.test.js b/preact/test/browser/lifecycles/componentWillMount.test.js
new file mode 100644
index 0000000..880803e
--- /dev/null
+++ b/preact/test/browser/lifecycles/componentWillMount.test.js
@@ -0,0 +1,43 @@
+import { createElement, render, Component } from 'preact';
+import { setupScratch, teardown } from '../../_util/helpers';
+
+/** @jsx createElement */
+
+describe('Lifecycle methods', () => {
+ /** @type {HTMLDivElement} */
+ let scratch;
+
+ beforeEach(() => {
+ scratch = setupScratch();
+ });
+
+ afterEach(() => {
+ teardown(scratch);
+ });
+
+ describe('#componentWillMount', () => {
+ it('should update state when called setState in componentWillMount', () => {
+ let componentState;
+
+ class Foo extends Component {
+ constructor(props) {
+ super(props);
+ this.state = {
+ value: 0
+ };
+ }
+ componentWillMount() {
+ this.setState({ value: 1 });
+ }
+ render() {
+ componentState = this.state;
+ return <div />;
+ }
+ }
+
+ render(<Foo />, scratch);
+
+ expect(componentState).to.deep.equal({ value: 1 });
+ });
+ });
+});
diff --git a/preact/test/browser/lifecycles/componentWillReceiveProps.test.js b/preact/test/browser/lifecycles/componentWillReceiveProps.test.js
new file mode 100644
index 0000000..0e10b9b
--- /dev/null
+++ b/preact/test/browser/lifecycles/componentWillReceiveProps.test.js
@@ -0,0 +1,296 @@
+import { setupRerender } from 'preact/test-utils';
+import { createElement, render, Component } from 'preact';
+import { setupScratch, teardown } from '../../_util/helpers';
+
+/** @jsx createElement */
+
+describe('Lifecycle methods', () => {
+ /** @type {HTMLDivElement} */
+ let scratch;
+
+ /** @type {() => void} */
+ let rerender;
+
+ beforeEach(() => {
+ scratch = setupScratch();
+ rerender = setupRerender();
+ });
+
+ afterEach(() => {
+ teardown(scratch);
+ });
+
+ describe('#componentWillReceiveProps', () => {
+ it('should update state when called setState in componentWillReceiveProps', () => {
+ let componentState;
+
+ class Foo extends Component {
+ constructor(props) {
+ super(props);
+ this.state = {
+ dummy: 0
+ };
+ }
+ componentDidMount() {
+ // eslint-disable-next-line react/no-did-mount-set-state
+ this.setState({ dummy: 1 });
+ }
+ render() {
+ return <Bar dummy={this.state.dummy} />;
+ }
+ }
+ class Bar extends Component {
+ constructor(props) {
+ super(props);
+ this.state = {
+ value: 0
+ };
+ }
+ componentWillReceiveProps() {
+ this.setState({ value: 1 });
+ }
+ render() {
+ componentState = this.state;
+ return <div />;
+ }
+ }
+
+ render(<Foo />, scratch);
+ rerender();
+
+ expect(componentState).to.deep.equal({ value: 1 });
+
+ const cWRP = Foo.prototype.componentWillReceiveProps;
+ delete Foo.prototype.componentWillReceiveProps;
+
+ Foo.prototype.shouldComponentUpdate = cWRP;
+
+ render(null, scratch);
+ render(<Foo />, scratch);
+ rerender();
+
+ expect(componentState, 'via shouldComponentUpdate').to.deep.equal({
+ value: 1
+ });
+
+ delete Foo.prototype.shouldComponentUpdate;
+ Foo.prototype.componentWillUpdate = cWRP;
+
+ render(null, scratch);
+ render(<Foo />, scratch);
+ rerender();
+
+ expect(componentState, 'via componentWillUpdate').to.deep.equal({
+ value: 1
+ });
+ });
+
+ it('should NOT be called on initial render', () => {
+ class ReceivePropsComponent extends Component {
+ componentWillReceiveProps() {}
+ render() {
+ return <div />;
+ }
+ }
+ sinon.spy(ReceivePropsComponent.prototype, 'componentWillReceiveProps');
+ render(<ReceivePropsComponent />, scratch);
+ expect(ReceivePropsComponent.prototype.componentWillReceiveProps).not.to
+ .have.been.called;
+ });
+
+ // See last paragraph of cWRP section https://reactjs.org/docs/react-component.html#unsafe_componentwillreceiveprops
+ it('should not be called on setState or forceUpdate', () => {
+ let spy = sinon.spy();
+ let spyInner = sinon.spy();
+ let c;
+
+ class Inner extends Component {
+ componentWillReceiveProps() {
+ spyInner();
+ }
+
+ render() {
+ return <div>foo</div>;
+ }
+ }
+
+ class Outer extends Component {
+ constructor() {
+ super();
+ c = this;
+ }
+
+ componentWillReceiveProps() {
+ spy();
+ }
+
+ render() {
+ return <Inner />;
+ }
+ }
+
+ render(<Outer />, scratch);
+ expect(spy).to.not.be.called;
+
+ c.setState({});
+ rerender();
+ expect(spy).to.not.be.called;
+ expect(spyInner).to.be.calledOnce;
+ spy.resetHistory();
+ spyInner.resetHistory();
+
+ c.forceUpdate();
+ rerender();
+ expect(spy).to.not.be.called;
+ expect(spyInner).to.be.calledOnce;
+ });
+
+ it('should be called when rerender with new props from parent', () => {
+ let doRender;
+ class Outer extends Component {
+ constructor(p, c) {
+ super(p, c);
+ this.state = { i: 0 };
+ }
+ componentDidMount() {
+ doRender = () => this.setState({ i: this.state.i + 1 });
+ }
+ render(props, { i }) {
+ return <Inner i={i} {...props} />;
+ }
+ }
+ class Inner extends Component {
+ componentWillMount() {
+ expect(this.props.i).to.be.equal(0);
+ }
+ componentWillReceiveProps(nextProps) {
+ expect(nextProps.i).to.be.equal(1);
+ }
+ render() {
+ return <div />;
+ }
+ }
+ sinon.spy(Inner.prototype, 'componentWillReceiveProps');
+ sinon.spy(Outer.prototype, 'componentDidMount');
+
+ // Initial render
+ render(<Outer />, scratch);
+ expect(Inner.prototype.componentWillReceiveProps).not.to.have.been.called;
+
+ // Rerender inner with new props
+ doRender();
+ rerender();
+ expect(Inner.prototype.componentWillReceiveProps).to.have.been.called;
+ });
+
+ it('should be called when rerender with new props from parent even with setState/forceUpdate in child', () => {
+ let setStateAndUpdateProps;
+ let forceUpdateAndUpdateProps;
+ let cWRPSpy = sinon.spy();
+
+ class Outer extends Component {
+ constructor(p, c) {
+ super(p, c);
+ this.state = { i: 0 };
+ this.update = this.update.bind(this);
+ }
+ update() {
+ this.setState({ i: this.state.i + 1 });
+ }
+ render(props, { i }) {
+ return <Inner i={i} update={this.update} />;
+ }
+ }
+ class Inner extends Component {
+ componentDidMount() {
+ expect(this.props.i).to.be.equal(0);
+
+ setStateAndUpdateProps = () => {
+ this.setState({});
+ this.props.update();
+ };
+ forceUpdateAndUpdateProps = () => {
+ this.forceUpdate();
+ this.props.update();
+ };
+ }
+ componentWillReceiveProps(nextProps) {
+ cWRPSpy(nextProps.i);
+ }
+ render() {
+ return <div />;
+ }
+ }
+ // Initial render
+ render(<Outer />, scratch);
+ expect(cWRPSpy).not.to.have.been.called;
+
+ // setState in inner component and update with new props
+ setStateAndUpdateProps();
+ rerender();
+ expect(cWRPSpy).to.have.been.calledWith(1);
+
+ // forceUpdate in inner component and update with new props
+ forceUpdateAndUpdateProps();
+ rerender();
+ expect(cWRPSpy).to.have.been.calledWith(2);
+ });
+
+ it('should be called in right execution order', () => {
+ let doRender;
+ class Outer extends Component {
+ constructor(p, c) {
+ super(p, c);
+ this.state = { i: 0 };
+ }
+ componentDidMount() {
+ doRender = () => this.setState({ i: this.state.i + 1 });
+ }
+ render(props, { i }) {
+ return <Inner i={i} {...props} />;
+ }
+ }
+ class Inner extends Component {
+ componentDidUpdate() {
+ expect(Inner.prototype.componentWillReceiveProps).to.have.been.called;
+ expect(Inner.prototype.componentWillUpdate).to.have.been.called;
+ }
+ componentWillReceiveProps() {
+ expect(Inner.prototype.componentWillUpdate).not.to.have.been.called;
+ expect(Inner.prototype.componentDidUpdate).not.to.have.been.called;
+ }
+ componentWillUpdate() {
+ expect(Inner.prototype.componentWillReceiveProps).to.have.been.called;
+ expect(Inner.prototype.componentDidUpdate).not.to.have.been.called;
+ }
+ shouldComponentUpdate() {
+ expect(Inner.prototype.componentWillReceiveProps).to.have.been.called;
+ expect(Inner.prototype.componentWillUpdate).not.to.have.been.called;
+ return true;
+ }
+ render() {
+ return <div />;
+ }
+ }
+ sinon.spy(Inner.prototype, 'componentWillReceiveProps');
+ sinon.spy(Inner.prototype, 'componentDidUpdate');
+ sinon.spy(Inner.prototype, 'componentWillUpdate');
+ sinon.spy(Inner.prototype, 'shouldComponentUpdate');
+ sinon.spy(Outer.prototype, 'componentDidMount');
+
+ render(<Outer />, scratch);
+ doRender();
+ rerender();
+
+ expect(
+ Inner.prototype.componentWillReceiveProps
+ ).to.have.been.calledBefore(Inner.prototype.componentWillUpdate);
+ expect(
+ Inner.prototype.componentWillReceiveProps
+ ).to.have.been.calledBefore(Inner.prototype.shouldComponentUpdate);
+ expect(Inner.prototype.componentWillUpdate).to.have.been.calledBefore(
+ Inner.prototype.componentDidUpdate
+ );
+ });
+ });
+});
diff --git a/preact/test/browser/lifecycles/componentWillUnmount.test.js b/preact/test/browser/lifecycles/componentWillUnmount.test.js
new file mode 100644
index 0000000..5aa0a84
--- /dev/null
+++ b/preact/test/browser/lifecycles/componentWillUnmount.test.js
@@ -0,0 +1,72 @@
+import { createElement, render, Component } from 'preact';
+import { setupScratch, teardown } from '../../_util/helpers';
+
+/** @jsx createElement */
+
+describe('Lifecycle methods', () => {
+ /** @type {HTMLDivElement} */
+ let scratch;
+
+ beforeEach(() => {
+ scratch = setupScratch();
+ });
+
+ afterEach(() => {
+ teardown(scratch);
+ });
+
+ describe('top-level componentWillUnmount', () => {
+ it('should invoke componentWillUnmount for top-level components', () => {
+ class Foo extends Component {
+ componentDidMount() {}
+ componentWillUnmount() {}
+ render() {
+ return 'foo';
+ }
+ }
+ class Bar extends Component {
+ componentDidMount() {}
+ componentWillUnmount() {}
+ render() {
+ return 'bar';
+ }
+ }
+ sinon.spy(Foo.prototype, 'componentDidMount');
+ sinon.spy(Foo.prototype, 'componentWillUnmount');
+ sinon.spy(Foo.prototype, 'render');
+
+ sinon.spy(Bar.prototype, 'componentDidMount');
+ sinon.spy(Bar.prototype, 'componentWillUnmount');
+ sinon.spy(Bar.prototype, 'render');
+
+ render(<Foo />, scratch);
+ expect(Foo.prototype.componentDidMount, 'initial render').to.have.been
+ .calledOnce;
+
+ render(<Bar />, scratch);
+ expect(Foo.prototype.componentWillUnmount, 'when replaced').to.have.been
+ .calledOnce;
+ expect(Bar.prototype.componentDidMount, 'when replaced').to.have.been
+ .calledOnce;
+
+ render(<div />, scratch);
+ expect(Bar.prototype.componentWillUnmount, 'when removed').to.have.been
+ .calledOnce;
+ });
+
+ it('should only remove dom after componentWillUnmount was called', () => {
+ class Foo extends Component {
+ componentWillUnmount() {
+ expect(document.getElementById('foo')).to.not.equal(null);
+ }
+
+ render() {
+ return <div id="foo" />;
+ }
+ }
+
+ render(<Foo />, scratch);
+ render(null, scratch);
+ });
+ });
+});
diff --git a/preact/test/browser/lifecycles/componentWillUpdate.test.js b/preact/test/browser/lifecycles/componentWillUpdate.test.js
new file mode 100644
index 0000000..73d8f1f
--- /dev/null
+++ b/preact/test/browser/lifecycles/componentWillUpdate.test.js
@@ -0,0 +1,95 @@
+import { setupRerender } from 'preact/test-utils';
+import { createElement, render, Component } from 'preact';
+import { setupScratch, teardown } from '../../_util/helpers';
+
+/** @jsx createElement */
+
+describe('Lifecycle methods', () => {
+ /** @type {HTMLDivElement} */
+ let scratch;
+
+ /** @type {() => void} */
+ let rerender;
+
+ beforeEach(() => {
+ scratch = setupScratch();
+ rerender = setupRerender();
+ });
+
+ afterEach(() => {
+ teardown(scratch);
+ });
+
+ describe('#componentWillUpdate', () => {
+ it('should NOT be called on initial render', () => {
+ class ReceivePropsComponent extends Component {
+ componentWillUpdate() {}
+ render() {
+ return <div />;
+ }
+ }
+ sinon.spy(ReceivePropsComponent.prototype, 'componentWillUpdate');
+ render(<ReceivePropsComponent />, scratch);
+ expect(ReceivePropsComponent.prototype.componentWillUpdate).not.to.have
+ .been.called;
+ });
+
+ it('should be called when rerender with new props from parent', () => {
+ let doRender;
+ class Outer extends Component {
+ constructor(p, c) {
+ super(p, c);
+ this.state = { i: 0 };
+ }
+ componentDidMount() {
+ doRender = () => this.setState({ i: this.state.i + 1 });
+ }
+ render(props, { i }) {
+ return <Inner i={i} {...props} />;
+ }
+ }
+ class Inner extends Component {
+ componentWillUpdate(nextProps, nextState) {
+ expect(nextProps).to.be.deep.equal({ i: 1 });
+ expect(nextState).to.be.deep.equal({});
+ }
+ render() {
+ return <div />;
+ }
+ }
+ sinon.spy(Inner.prototype, 'componentWillUpdate');
+ sinon.spy(Outer.prototype, 'componentDidMount');
+
+ // Initial render
+ render(<Outer />, scratch);
+ expect(Inner.prototype.componentWillUpdate).not.to.have.been.called;
+
+ // Rerender inner with new props
+ doRender();
+ rerender();
+ expect(Inner.prototype.componentWillUpdate).to.have.been.called;
+ });
+
+ it('should be called on new state', () => {
+ let doRender;
+ class ReceivePropsComponent extends Component {
+ componentWillUpdate() {}
+ componentDidMount() {
+ doRender = () => this.setState({ i: this.state.i + 1 });
+ }
+ render() {
+ return <div />;
+ }
+ }
+ sinon.spy(ReceivePropsComponent.prototype, 'componentWillUpdate');
+ render(<ReceivePropsComponent />, scratch);
+ expect(ReceivePropsComponent.prototype.componentWillUpdate).not.to.have
+ .been.called;
+
+ doRender();
+ rerender();
+ expect(ReceivePropsComponent.prototype.componentWillUpdate).to.have.been
+ .called;
+ });
+ });
+});
diff --git a/preact/test/browser/lifecycles/getDerivedStateFromError.test.js b/preact/test/browser/lifecycles/getDerivedStateFromError.test.js
new file mode 100644
index 0000000..4c279a8
--- /dev/null
+++ b/preact/test/browser/lifecycles/getDerivedStateFromError.test.js
@@ -0,0 +1,659 @@
+import { setupRerender } from 'preact/test-utils';
+import { createElement, render, Component } from 'preact';
+import { setupScratch, teardown } from '../../_util/helpers';
+
+/** @jsx createElement */
+
+describe('Lifecycle methods', () => {
+ /* eslint-disable react/display-name */
+
+ /** @type {HTMLDivElement} */
+ let scratch;
+
+ /** @type {() => void} */
+ let rerender;
+
+ beforeEach(() => {
+ scratch = setupScratch();
+ rerender = setupRerender();
+ });
+
+ afterEach(() => {
+ teardown(scratch);
+ });
+
+ describe('#getDerivedStateFromError', () => {
+ /** @type {Error} */
+ let expectedError;
+
+ /** @type {typeof import('../../../').Component} */
+ let ThrowErr;
+
+ let thrower;
+
+ class Receiver extends Component {
+ static getDerivedStateFromError(error) {
+ return { error };
+ }
+ render() {
+ return this.state.error
+ ? String(this.state.error)
+ : this.props.children;
+ }
+ }
+
+ sinon.spy(Receiver, 'getDerivedStateFromError');
+ sinon.spy(Receiver.prototype, 'render');
+
+ function throwExpectedError() {
+ throw (expectedError = new Error('Error!'));
+ }
+
+ beforeEach(() => {
+ ThrowErr = class ThrowErr extends Component {
+ constructor(props) {
+ super(props);
+ thrower = this;
+ }
+
+ static getDerivedStateFromError() {
+ expect.fail("Throwing component should not catch it's own error.");
+ return {};
+ }
+ render() {
+ return <div>ThrowErr: getDerivedStateFromError</div>;
+ }
+ };
+ sinon.spy(ThrowErr, 'getDerivedStateFromError');
+
+ expectedError = undefined;
+
+ Receiver.getDerivedStateFromError.resetHistory();
+ Receiver.prototype.render.resetHistory();
+ });
+
+ afterEach(() => {
+ expect(
+ ThrowErr.getDerivedStateFromError,
+ "Throwing component should not catch it's own error."
+ ).to.not.be.called;
+ thrower = undefined;
+ });
+
+ it('should be called when child fails in constructor', () => {
+ class ThrowErr extends Component {
+ constructor(props, context) {
+ super(props, context);
+ throwExpectedError();
+ }
+ static getDerivedStateFromError() {
+ expect.fail("Throwing component should not catch it's own error");
+ return {};
+ }
+ render() {
+ return <div />;
+ }
+ }
+
+ render(
+ <Receiver>
+ <ThrowErr />
+ </Receiver>,
+ scratch
+ );
+ rerender();
+
+ expect(Receiver.getDerivedStateFromError).to.have.been.calledWith(
+ expectedError
+ );
+ });
+
+ // https://github.com/preactjs/preact/issues/1570
+ it('should handle double child throws', () => {
+ const Child = ({ i }) => {
+ throw new Error(`error! ${i}`);
+ };
+
+ const fn = () =>
+ render(
+ <Receiver>
+ {[1, 2].map(i => (
+ <Child key={i} i={i} />
+ ))}
+ </Receiver>,
+ scratch
+ );
+ expect(fn).to.not.throw();
+
+ rerender();
+ expect(scratch.innerHTML).to.equal('Error: error! 2');
+ });
+
+ it('should be called when child fails in componentWillMount', () => {
+ ThrowErr.prototype.componentWillMount = throwExpectedError;
+
+ render(
+ <Receiver>
+ <ThrowErr />
+ </Receiver>,
+ scratch
+ );
+ expect(Receiver.getDerivedStateFromError).to.have.been.calledWith(
+ expectedError
+ );
+ });
+
+ it('should be called when child fails in render', () => {
+ ThrowErr.prototype.render = throwExpectedError;
+
+ render(
+ <Receiver>
+ <ThrowErr />
+ </Receiver>,
+ scratch
+ );
+ expect(Receiver.getDerivedStateFromError).to.have.been.calledWith(
+ expectedError
+ );
+ });
+
+ it('should be called when child fails in componentDidMount', () => {
+ ThrowErr.prototype.componentDidMount = throwExpectedError;
+
+ render(
+ <Receiver>
+ <ThrowErr />
+ </Receiver>,
+ scratch
+ );
+ expect(Receiver.getDerivedStateFromError).to.have.been.calledWith(
+ expectedError
+ );
+ });
+
+ it('should be called when child fails in getDerivedStateFromProps', () => {
+ ThrowErr.getDerivedStateFromProps = throwExpectedError;
+
+ sinon.spy(ThrowErr.prototype, 'render');
+ render(
+ <Receiver>
+ <ThrowErr />
+ </Receiver>,
+ scratch
+ );
+
+ expect(Receiver.getDerivedStateFromError).to.have.been.calledWith(
+ expectedError
+ );
+ expect(ThrowErr.prototype.render).not.to.have.been.called;
+ });
+
+ it('should be called when child fails in getSnapshotBeforeUpdate', () => {
+ ThrowErr.prototype.getSnapshotBeforeUpdate = throwExpectedError;
+
+ render(
+ <Receiver>
+ <ThrowErr />
+ </Receiver>,
+ scratch
+ );
+ thrower.forceUpdate();
+ rerender();
+
+ expect(Receiver.getDerivedStateFromError).to.have.been.calledWith(
+ expectedError
+ );
+ });
+
+ it('should be called when child fails in componentDidUpdate', () => {
+ ThrowErr.prototype.componentDidUpdate = throwExpectedError;
+
+ render(
+ <Receiver>
+ <ThrowErr />
+ </Receiver>,
+ scratch
+ );
+
+ thrower.forceUpdate();
+ rerender();
+ expect(Receiver.getDerivedStateFromError).to.have.been.calledWith(
+ expectedError
+ );
+ });
+
+ it('should be called when child fails in componentWillUpdate', () => {
+ ThrowErr.prototype.componentWillUpdate = throwExpectedError;
+
+ render(
+ <Receiver>
+ <ThrowErr />
+ </Receiver>,
+ scratch
+ );
+
+ thrower.forceUpdate();
+ rerender();
+ expect(Receiver.getDerivedStateFromError).to.have.been.calledWith(
+ expectedError
+ );
+ });
+
+ it('should be called when child fails in componentWillReceiveProps', () => {
+ ThrowErr.prototype.componentWillReceiveProps = throwExpectedError;
+
+ let receiver;
+ class Receiver extends Component {
+ constructor() {
+ super();
+ this.state = { foo: 'bar' };
+ receiver = this;
+ }
+ static getDerivedStateFromError(error) {
+ return { error };
+ }
+ render() {
+ return this.state.error ? (
+ String(this.state.error)
+ ) : (
+ <ThrowErr foo={this.state.foo} />
+ );
+ }
+ }
+
+ sinon.spy(Receiver, 'getDerivedStateFromError');
+ render(<Receiver />, scratch);
+
+ receiver.setState({ foo: 'baz' });
+ rerender();
+ expect(Receiver.getDerivedStateFromError).to.have.been.calledWith(
+ expectedError
+ );
+ });
+
+ it('should be called when child fails in shouldComponentUpdate', () => {
+ ThrowErr.prototype.shouldComponentUpdate = throwExpectedError;
+
+ let receiver;
+ class Receiver extends Component {
+ constructor() {
+ super();
+ this.state = { foo: 'bar' };
+ receiver = this;
+ }
+ static getDerivedStateFromError(error) {
+ return { error };
+ }
+ render() {
+ return this.state.error ? (
+ String(this.state.error)
+ ) : (
+ <ThrowErr foo={this.state.foo} />
+ );
+ }
+ }
+
+ sinon.spy(Receiver, 'getDerivedStateFromError');
+ render(<Receiver />, scratch);
+
+ receiver.setState({ foo: 'baz' });
+ rerender();
+
+ expect(Receiver.getDerivedStateFromError).to.have.been.calledWith(
+ expectedError
+ );
+ });
+
+ it('should be called when child fails in componentWillUnmount', () => {
+ ThrowErr.prototype.componentWillUnmount = throwExpectedError;
+
+ render(
+ <Receiver>
+ <ThrowErr />
+ </Receiver>,
+ scratch
+ );
+ render(
+ <Receiver>
+ <div />
+ </Receiver>,
+ scratch
+ );
+ expect(Receiver.getDerivedStateFromError).to.have.been.calledWith(
+ expectedError
+ );
+ });
+
+ it('should be called when applying a Component ref', () => {
+ const Foo = () => <div />;
+
+ const ref = value => {
+ if (value) {
+ throwExpectedError();
+ }
+ };
+
+ // In React, an error boundary handles it's own refs:
+ // https://codesandbox.io/s/react-throwing-refs-lk958
+ class Receiver extends Component {
+ static getDerivedStateFromError(error) {
+ return { error };
+ }
+ render() {
+ return this.state.error ? (
+ String(this.state.error)
+ ) : (
+ <Foo ref={ref} />
+ );
+ }
+ }
+
+ sinon.spy(Receiver, 'getDerivedStateFromError');
+ render(<Receiver />, scratch);
+ expect(Receiver.getDerivedStateFromError).to.have.been.calledWith(
+ expectedError
+ );
+ });
+
+ it('should be called when applying a DOM ref', () => {
+ const ref = value => {
+ if (value) {
+ throwExpectedError();
+ }
+ };
+
+ // In React, an error boundary handles it's own refs:
+ // https://codesandbox.io/s/react-throwing-refs-lk958
+ class Receiver extends Component {
+ static getDerivedStateFromError(error) {
+ return { error };
+ }
+ render() {
+ return this.state.error ? (
+ String(this.state.error)
+ ) : (
+ <div ref={ref} />
+ );
+ }
+ }
+
+ sinon.spy(Receiver, 'getDerivedStateFromError');
+ render(
+ <Receiver>
+ <ThrowErr />
+ </Receiver>,
+ scratch
+ );
+ expect(Receiver.getDerivedStateFromError).to.have.been.calledWith(
+ expectedError
+ );
+ });
+
+ it('should be called when unmounting a ref', () => {
+ const ref = value => {
+ if (value == null) {
+ throwExpectedError();
+ }
+ };
+
+ ThrowErr.prototype.render = () => <div ref={ref} />;
+
+ render(
+ <Receiver>
+ <ThrowErr />
+ </Receiver>,
+ scratch
+ );
+ render(
+ <Receiver>
+ <div />
+ </Receiver>,
+ scratch
+ );
+ expect(Receiver.getDerivedStateFromError).to.have.been.calledOnceWith(
+ expectedError
+ );
+ });
+
+ it('should be called when functional child fails', () => {
+ function ThrowErr() {
+ throwExpectedError();
+ }
+
+ render(
+ <Receiver>
+ <ThrowErr />
+ </Receiver>,
+ scratch
+ );
+ expect(Receiver.getDerivedStateFromError).to.have.been.calledWith(
+ expectedError
+ );
+ });
+
+ it('should re-render with new content', () => {
+ class ThrowErr extends Component {
+ componentWillMount() {
+ throw new Error('Error contents');
+ }
+ render() {
+ return 'No error!?!?';
+ }
+ }
+
+ render(
+ <Receiver>
+ <ThrowErr />
+ </Receiver>,
+ scratch
+ );
+ rerender();
+ expect(scratch).to.have.property('textContent', 'Error: Error contents');
+ });
+
+ it('should be able to adapt and rethrow errors', () => {
+ let adaptedError;
+ class Adapter extends Component {
+ static getDerivedStateFromError(error) {
+ throw (adaptedError = new Error(
+ 'Adapted ' +
+ String(error && 'message' in error ? error.message : error)
+ ));
+ }
+ render() {
+ return <div>{this.props.children}</div>;
+ }
+ }
+
+ function ThrowErr() {
+ throwExpectedError();
+ }
+
+ sinon.spy(Adapter, 'getDerivedStateFromError');
+ render(
+ <Receiver>
+ <Adapter>
+ <ThrowErr />
+ </Adapter>
+ </Receiver>,
+ scratch
+ );
+
+ expect(Adapter.getDerivedStateFromError).to.have.been.calledWith(
+ expectedError
+ );
+ expect(Receiver.getDerivedStateFromError).to.have.been.calledWith(
+ adaptedError
+ );
+
+ rerender();
+ expect(scratch).to.have.property('textContent', 'Error: Adapted Error!');
+ });
+
+ it('should bubble on repeated errors', () => {
+ class Adapter extends Component {
+ static getDerivedStateFromError(error) {
+ return { error };
+ }
+ render() {
+ // But fail at doing so
+ if (this.state.error) {
+ throw this.state.error;
+ }
+ return <div>{this.props.children}</div>;
+ }
+ }
+
+ function ThrowErr() {
+ throwExpectedError();
+ }
+
+ sinon.spy(Adapter, 'getDerivedStateFromError');
+
+ render(
+ <Receiver>
+ <Adapter>
+ <ThrowErr />
+ </Adapter>
+ </Receiver>,
+ scratch
+ );
+ rerender();
+
+ expect(Adapter.getDerivedStateFromError).to.have.been.calledWith(
+ expectedError
+ );
+ expect(Receiver.getDerivedStateFromError).to.have.been.calledWith(
+ expectedError
+ );
+ expect(scratch).to.have.property('textContent', 'Error: Error!');
+ });
+
+ it('should bubble on ignored errors', () => {
+ class Adapter extends Component {
+ static getDerivedStateFromError(error) {
+ // Ignore the error
+ return null;
+ }
+ render() {
+ return <div>{this.props.children}</div>;
+ }
+ }
+
+ function ThrowErr() {
+ throw new Error('Error!');
+ }
+
+ sinon.spy(Adapter, 'getDerivedStateFromError');
+
+ render(
+ <Receiver>
+ <Adapter>
+ <ThrowErr />
+ </Adapter>
+ </Receiver>,
+ scratch
+ );
+ rerender();
+
+ expect(Adapter.getDerivedStateFromError).to.have.been.called;
+ expect(Receiver.getDerivedStateFromError).to.have.been.called;
+ expect(scratch).to.have.property('textContent', 'Error: Error!');
+ });
+
+ it('should not bubble on caught errors', () => {
+ class TopReceiver extends Component {
+ static getDerivedStateFromError(error) {
+ return { error };
+ }
+ render() {
+ return (
+ <div>
+ {this.state.error
+ ? String(this.state.error)
+ : this.props.children}
+ </div>
+ );
+ }
+ }
+
+ function ThrowErr() {
+ throwExpectedError();
+ }
+
+ sinon.spy(TopReceiver, 'getDerivedStateFromError');
+
+ render(
+ <TopReceiver>
+ <Receiver>
+ <ThrowErr />
+ </Receiver>
+ </TopReceiver>,
+ scratch
+ );
+ rerender();
+
+ expect(TopReceiver.getDerivedStateFromError).not.to.have.been.called;
+ expect(Receiver.getDerivedStateFromError).to.have.been.calledWith(
+ expectedError
+ );
+ expect(scratch).to.have.property('textContent', 'Error: Error!');
+ });
+
+ it('should be called through non-component parent elements', () => {
+ ThrowErr.prototype.render = throwExpectedError;
+
+ render(
+ <Receiver>
+ <div>
+ <ThrowErr />
+ </div>
+ </Receiver>,
+ scratch
+ );
+ expect(Receiver.getDerivedStateFromError).to.have.been.calledWith(
+ expectedError
+ );
+ });
+
+ it('should bubble up when ref throws on component that is not an error boundary', () => {
+ const ref = value => {
+ if (value) {
+ throwExpectedError();
+ }
+ };
+
+ function ThrowErr() {
+ return <div ref={ref} />;
+ }
+
+ render(
+ <Receiver>
+ <ThrowErr />
+ </Receiver>,
+ scratch
+ );
+ expect(Receiver.getDerivedStateFromError).to.have.been.calledWith(
+ expectedError
+ );
+ });
+
+ it.skip('should successfully unmount constantly throwing ref', () => {
+ const buggyRef = throwExpectedError;
+
+ function ThrowErr() {
+ return <div ref={buggyRef}>ThrowErr</div>;
+ }
+
+ render(
+ <Receiver>
+ <ThrowErr />
+ </Receiver>,
+ scratch
+ );
+ rerender();
+
+ expect(scratch.innerHTML).to.equal('<div>Error: Error!</div>');
+ });
+ });
+});
diff --git a/preact/test/browser/lifecycles/getDerivedStateFromProps.test.js b/preact/test/browser/lifecycles/getDerivedStateFromProps.test.js
new file mode 100644
index 0000000..0fa1394
--- /dev/null
+++ b/preact/test/browser/lifecycles/getDerivedStateFromProps.test.js
@@ -0,0 +1,419 @@
+import { setupRerender } from 'preact/test-utils';
+import { createElement, render, Component } from 'preact';
+import { setupScratch, teardown } from '../../_util/helpers';
+
+/** @jsx createElement */
+
+describe('Lifecycle methods', () => {
+ /** @type {HTMLDivElement} */
+ let scratch;
+
+ /** @type {() => void} */
+ let rerender;
+
+ beforeEach(() => {
+ scratch = setupScratch();
+ rerender = setupRerender();
+ });
+
+ afterEach(() => {
+ teardown(scratch);
+ });
+
+ describe('static getDerivedStateFromProps', () => {
+ it('should set initial state with value returned from getDerivedStateFromProps', () => {
+ class Foo extends Component {
+ static getDerivedStateFromProps(props) {
+ return {
+ foo: props.foo,
+ bar: 'bar'
+ };
+ }
+ render() {
+ return <div className={`${this.state.foo} ${this.state.bar}`} />;
+ }
+ }
+
+ render(<Foo foo="foo" />, scratch);
+ expect(scratch.firstChild.className).to.be.equal('foo bar');
+ });
+
+ it('should update initial state with value returned from getDerivedStateFromProps', () => {
+ class Foo extends Component {
+ constructor(props, context) {
+ super(props, context);
+ this.state = {
+ foo: 'foo',
+ bar: 'bar'
+ };
+ }
+ static getDerivedStateFromProps(props, state) {
+ return {
+ foo: `not-${state.foo}`
+ };
+ }
+ render() {
+ return <div className={`${this.state.foo} ${this.state.bar}`} />;
+ }
+ }
+
+ render(<Foo />, scratch);
+ expect(scratch.firstChild.className).to.equal('not-foo bar');
+ });
+
+ it("should update the instance's state with the value returned from getDerivedStateFromProps when props change", () => {
+ class Foo extends Component {
+ constructor(props, context) {
+ super(props, context);
+ this.state = {
+ value: 'initial'
+ };
+ }
+ static getDerivedStateFromProps(props) {
+ if (props.update) {
+ return {
+ value: 'updated'
+ };
+ }
+
+ return null;
+ }
+ componentDidMount() {}
+ componentDidUpdate() {}
+ render() {
+ return <div className={this.state.value} />;
+ }
+ }
+
+ sinon.spy(Foo, 'getDerivedStateFromProps');
+ sinon.spy(Foo.prototype, 'componentDidMount');
+ sinon.spy(Foo.prototype, 'componentDidUpdate');
+
+ render(<Foo update={false} />, scratch);
+ expect(scratch.firstChild.className).to.equal('initial');
+ expect(Foo.getDerivedStateFromProps).to.have.callCount(1);
+ expect(Foo.prototype.componentDidMount).to.have.callCount(1); // verify mount occurred
+ expect(Foo.prototype.componentDidUpdate).to.have.callCount(0);
+
+ render(<Foo update />, scratch);
+ expect(scratch.firstChild.className).to.equal('updated');
+ expect(Foo.getDerivedStateFromProps).to.have.callCount(2);
+ expect(Foo.prototype.componentDidMount).to.have.callCount(1);
+ expect(Foo.prototype.componentDidUpdate).to.have.callCount(1); // verify update occurred
+ });
+
+ it("should update the instance's state with the value returned from getDerivedStateFromProps when state changes", () => {
+ class Foo extends Component {
+ constructor(props, context) {
+ super(props, context);
+ this.state = {
+ value: 'initial'
+ };
+ }
+ static getDerivedStateFromProps(props, state) {
+ // Don't change state for call that happens after the constructor
+ if (state.value === 'initial') {
+ return null;
+ }
+
+ return {
+ value: state.value + ' derived'
+ };
+ }
+ componentDidMount() {
+ // eslint-disable-next-line react/no-did-mount-set-state
+ this.setState({ value: 'updated' });
+ }
+ render() {
+ return <div className={this.state.value} />;
+ }
+ }
+
+ sinon.spy(Foo, 'getDerivedStateFromProps');
+
+ render(<Foo />, scratch);
+ expect(scratch.firstChild.className).to.equal('initial');
+ expect(Foo.getDerivedStateFromProps).to.have.been.calledOnce;
+
+ rerender(); // call rerender to handle cDM setState call
+ expect(scratch.firstChild.className).to.equal('updated derived');
+ expect(Foo.getDerivedStateFromProps).to.have.been.calledTwice;
+ });
+
+ it('should NOT modify state if null is returned', () => {
+ class Foo extends Component {
+ constructor(props, context) {
+ super(props, context);
+ this.state = {
+ foo: 'foo',
+ bar: 'bar'
+ };
+ }
+ static getDerivedStateFromProps() {
+ return null;
+ }
+ render() {
+ return <div className={`${this.state.foo} ${this.state.bar}`} />;
+ }
+ }
+
+ sinon.spy(Foo, 'getDerivedStateFromProps');
+
+ render(<Foo />, scratch);
+ expect(scratch.firstChild.className).to.equal('foo bar');
+ expect(Foo.getDerivedStateFromProps).to.have.been.called;
+ });
+
+ // NOTE: Difference from React
+ // React v16.3.2 warns if undefined if returned from getDerivedStateFromProps
+ it('should NOT modify state if undefined is returned', () => {
+ class Foo extends Component {
+ constructor(props, context) {
+ super(props, context);
+ this.state = {
+ foo: 'foo',
+ bar: 'bar'
+ };
+ }
+ static getDerivedStateFromProps() {}
+ render() {
+ return <div className={`${this.state.foo} ${this.state.bar}`} />;
+ }
+ }
+
+ sinon.spy(Foo, 'getDerivedStateFromProps');
+
+ render(<Foo />, scratch);
+ expect(scratch.firstChild.className).to.equal('foo bar');
+ expect(Foo.getDerivedStateFromProps).to.have.been.called;
+ });
+
+ it('should NOT invoke deprecated lifecycles (cWM/cWRP) if new static gDSFP is present', () => {
+ class Foo extends Component {
+ static getDerivedStateFromProps() {}
+ componentWillMount() {}
+ componentWillReceiveProps() {}
+ render() {
+ return <div />;
+ }
+ }
+
+ sinon.spy(Foo, 'getDerivedStateFromProps');
+ sinon.spy(Foo.prototype, 'componentWillMount');
+ sinon.spy(Foo.prototype, 'componentWillReceiveProps');
+
+ render(<Foo />, scratch);
+ expect(Foo.getDerivedStateFromProps).to.have.been.called;
+ expect(Foo.prototype.componentWillMount).to.not.have.been.called;
+ expect(Foo.prototype.componentWillReceiveProps).to.not.have.been.called;
+ });
+
+ it('is not called if neither state nor props have changed', () => {
+ let logs = [];
+ let childRef;
+
+ class Parent extends Component {
+ constructor(props) {
+ super(props);
+ this.state = { parentRenders: 0 };
+ }
+
+ static getDerivedStateFromProps(props, state) {
+ logs.push('parent getDerivedStateFromProps');
+ return state.parentRenders + 1;
+ }
+
+ render() {
+ logs.push('parent render');
+ return <Child parentRenders={this.state.parentRenders} />;
+ }
+ }
+
+ class Child extends Component {
+ constructor(props) {
+ super(props);
+ childRef = this;
+ }
+ render() {
+ logs.push('child render');
+ return this.props.parentRenders;
+ }
+ }
+
+ render(<Parent />, scratch);
+ expect(logs).to.deep.equal([
+ 'parent getDerivedStateFromProps',
+ 'parent render',
+ 'child render'
+ ]);
+
+ logs = [];
+ childRef.setState({});
+ rerender();
+ expect(logs).to.deep.equal(['child render']);
+ });
+
+ // TODO: Investigate this test:
+ // [should not override state with stale values if prevState is spread within getDerivedStateFromProps](https://github.com/facebook/react/blob/25dda90c1ecb0c662ab06e2c80c1ee31e0ae9d36/packages/react-dom/src/__tests__/ReactComponentLifeCycle-test.js#L1035)
+
+ it('should be passed next props and state', () => {
+ /** @type {() => void} */
+ let updateState;
+
+ let propsArg;
+ let stateArg;
+
+ class Foo extends Component {
+ constructor(props) {
+ super(props);
+ this.state = {
+ value: 0
+ };
+ updateState = () =>
+ this.setState({
+ value: this.state.value + 1
+ });
+ }
+ static getDerivedStateFromProps(props, state) {
+ // These object references might be updated later so copy
+ // object so we can assert their values at this snapshot in time
+ propsArg = { ...props };
+ stateArg = { ...state };
+
+ // NOTE: Don't do this in real production code!
+ // https://reactjs.org/blog/2018/06/07/you-probably-dont-need-derived-state.html
+ return {
+ value: state.value + 1
+ };
+ }
+ render() {
+ return <div>{this.state.value}</div>;
+ }
+ }
+
+ // Initial render
+ // state.value: initialized to 0 in constructor, 0 -> 1 in gDSFP
+ render(<Foo foo="foo" />, scratch);
+
+ let element = scratch.firstChild;
+ expect(element.textContent).to.be.equal('1');
+ expect(propsArg).to.deep.equal({
+ foo: 'foo'
+ });
+ expect(stateArg).to.deep.equal({
+ value: 0
+ });
+
+ // New Props
+ // state.value: 1 -> 2 in gDSFP
+ render(<Foo foo="bar" />, scratch);
+ expect(element.textContent).to.be.equal('2');
+ expect(propsArg).to.deep.equal({
+ foo: 'bar'
+ });
+ expect(stateArg).to.deep.equal({
+ value: 1
+ });
+
+ // New state
+ // state.value: 2 -> 3 in updateState, 3 -> 4 in gDSFP
+ updateState();
+ rerender();
+ expect(element.textContent).to.be.equal('4');
+ expect(propsArg).to.deep.equal({
+ foo: 'bar'
+ });
+ expect(stateArg).to.deep.equal({
+ value: 3
+ });
+
+ // New Props (see #1446)
+ // 4 -> 5 in gDSFP
+ render(<Foo foo="baz" />, scratch);
+ expect(element.textContent).to.be.equal('5');
+ expect(stateArg).to.deep.equal({
+ value: 4
+ });
+
+ // New Props (see #1446)
+ // 5 -> 6 in gDSFP
+ render(<Foo foo="qux" />, scratch);
+ expect(element.textContent).to.be.equal('6');
+ expect(stateArg).to.deep.equal({
+ value: 5
+ });
+ });
+
+ // From preactjs/preact#1170
+ it('should NOT mutate state on mount, only create new versions', () => {
+ const stateConstant = {};
+ let componentState;
+
+ class Stateful extends Component {
+ static getDerivedStateFromProps() {
+ return { key: 'value' };
+ }
+
+ constructor() {
+ super(...arguments);
+ this.state = stateConstant;
+ }
+
+ componentDidMount() {
+ componentState = this.state;
+ }
+
+ render() {
+ return <div />;
+ }
+ }
+
+ render(<Stateful />, scratch);
+
+ // Verify captured object references didn't get mutated
+ expect(componentState).to.deep.equal({ key: 'value' });
+ expect(stateConstant).to.deep.equal({});
+ });
+
+ it('should NOT mutate state on update, only create new versions', () => {
+ const initialState = {};
+ const capturedStates = [];
+
+ let setState;
+
+ class Stateful extends Component {
+ static getDerivedStateFromProps(props, state) {
+ return { value: (state.value || 0) + 1 };
+ }
+
+ constructor() {
+ super(...arguments);
+ this.state = initialState;
+ capturedStates.push(this.state);
+
+ setState = this.setState.bind(this);
+ }
+
+ componentDidMount() {
+ capturedStates.push(this.state);
+ }
+
+ componentDidUpdate() {
+ capturedStates.push(this.state);
+ }
+
+ render() {
+ return <div />;
+ }
+ }
+
+ render(<Stateful />, scratch);
+
+ setState({ value: 10 });
+ rerender();
+
+ // Verify captured object references didn't get mutated
+ expect(capturedStates).to.deep.equal([{}, { value: 1 }, { value: 11 }]);
+ });
+ });
+});
diff --git a/preact/test/browser/lifecycles/getSnapshotBeforeUpdate.test.js b/preact/test/browser/lifecycles/getSnapshotBeforeUpdate.test.js
new file mode 100644
index 0000000..645424b
--- /dev/null
+++ b/preact/test/browser/lifecycles/getSnapshotBeforeUpdate.test.js
@@ -0,0 +1,211 @@
+import { setupRerender } from 'preact/test-utils';
+import { createElement, render, Component } from 'preact';
+import { setupScratch, teardown } from '../../_util/helpers';
+
+/** @jsx createElement */
+
+describe('Lifecycle methods', () => {
+ /** @type {HTMLDivElement} */
+ let scratch;
+
+ /** @type {() => void} */
+ let rerender;
+
+ beforeEach(() => {
+ scratch = setupScratch();
+ rerender = setupRerender();
+ });
+
+ afterEach(() => {
+ teardown(scratch);
+ });
+
+ describe('#getSnapshotBeforeUpdate', () => {
+ it('should pass the return value from getSnapshotBeforeUpdate to componentDidUpdate', () => {
+ let log = [];
+
+ class MyComponent extends Component {
+ constructor(props) {
+ super(props);
+ this.state = {
+ value: 0
+ };
+ }
+ static getDerivedStateFromProps(nextProps, prevState) {
+ return {
+ value: prevState.value + 1
+ };
+ }
+ getSnapshotBeforeUpdate(prevProps, prevState) {
+ log.push(
+ `getSnapshotBeforeUpdate() prevProps:${prevProps.value} prevState:${prevState.value}`
+ );
+ return 'abc';
+ }
+ componentDidUpdate(prevProps, prevState, snapshot) {
+ log.push(
+ `componentDidUpdate() prevProps:${prevProps.value} prevState:${prevState.value} snapshot:${snapshot}`
+ );
+ }
+ render() {
+ log.push('render');
+ return null;
+ }
+ }
+
+ render(<MyComponent value="foo" />, scratch);
+ expect(log).to.deep.equal(['render']);
+ log = [];
+
+ render(<MyComponent value="bar" />, scratch);
+ expect(log).to.deep.equal([
+ 'render',
+ 'getSnapshotBeforeUpdate() prevProps:foo prevState:1',
+ 'componentDidUpdate() prevProps:foo prevState:1 snapshot:abc'
+ ]);
+ log = [];
+
+ render(<MyComponent value="baz" />, scratch);
+ expect(log).to.deep.equal([
+ 'render',
+ 'getSnapshotBeforeUpdate() prevProps:bar prevState:2',
+ 'componentDidUpdate() prevProps:bar prevState:2 snapshot:abc'
+ ]);
+ log = [];
+
+ render(<div />, scratch, scratch.firstChild);
+ expect(log).to.deep.equal([]);
+ });
+
+ it('should call getSnapshotBeforeUpdate before mutations are committed', () => {
+ let log = [];
+
+ class MyComponent extends Component {
+ getSnapshotBeforeUpdate(prevProps) {
+ log.push('getSnapshotBeforeUpdate');
+ expect(this.divRef.textContent).to.equal(`value:${prevProps.value}`);
+ return 'foobar';
+ }
+ componentDidUpdate(prevProps, prevState, snapshot) {
+ log.push('componentDidUpdate');
+ expect(this.divRef.textContent).to.equal(`value:${this.props.value}`);
+ expect(snapshot).to.equal('foobar');
+ }
+ render() {
+ log.push('render');
+ return (
+ <div
+ ref={ref => (this.divRef = ref)}
+ >{`value:${this.props.value}`}</div>
+ );
+ }
+ }
+
+ render(<MyComponent value="foo" />, scratch);
+ expect(log).to.deep.equal(['render']);
+ log = [];
+
+ render(<MyComponent value="bar" />, scratch);
+ expect(log).to.deep.equal([
+ 'render',
+ 'getSnapshotBeforeUpdate',
+ 'componentDidUpdate'
+ ]);
+ });
+
+ it('should be passed the previous props and state', () => {
+ /** @type {() => void} */
+ let updateState;
+
+ let prevPropsArg;
+ let prevStateArg;
+ let curProps;
+ let curState;
+
+ class Foo extends Component {
+ constructor(props) {
+ super(props);
+ this.state = {
+ value: 0
+ };
+ updateState = () =>
+ this.setState({
+ value: this.state.value + 1
+ });
+ }
+ static getDerivedStateFromProps(props, state) {
+ // NOTE: Don't do this in real production code!
+ // https://reactjs.org/blog/2018/06/07/you-probably-dont-need-derived-state.html
+ return {
+ value: state.value + 1
+ };
+ }
+ getSnapshotBeforeUpdate(prevProps, prevState) {
+ // These object references might be updated later so copy
+ // object so we can assert their values at this snapshot in time
+ prevPropsArg = { ...prevProps };
+ prevStateArg = { ...prevState };
+
+ curProps = { ...this.props };
+ curState = { ...this.state };
+ }
+ render() {
+ return <div>{this.state.value}</div>;
+ }
+ }
+
+ // Expectation:
+ // `prevState` in getSnapshotBeforeUpdate should be
+ // the state before setState or getDerivedStateFromProps was called.
+ // `this.state` in getSnapshotBeforeUpdate should be
+ // the updated state after getDerivedStateFromProps was called.
+
+ // Initial render
+ // state.value: initialized to 0 in constructor, 0 -> 1 in gDSFP
+ render(<Foo foo="foo" />, scratch);
+ const element = scratch.firstChild;
+
+ expect(element.textContent).to.be.equal('1');
+ expect(prevPropsArg).to.be.undefined;
+ expect(prevStateArg).to.be.undefined;
+ expect(curProps).to.be.undefined;
+ expect(curState).to.be.undefined;
+
+ // New props
+ // state.value: 1 -> 2 in gDSFP
+ render(<Foo foo="bar" />, scratch);
+
+ expect(element.textContent).to.be.equal('2');
+ expect(prevPropsArg).to.deep.equal({
+ foo: 'foo'
+ });
+ expect(prevStateArg).to.deep.equal({
+ value: 1
+ });
+ expect(curProps).to.deep.equal({
+ foo: 'bar'
+ });
+ expect(curState).to.deep.equal({
+ value: 2
+ });
+
+ // New state
+ // state.value: 2 -> 3 in updateState, 3 -> 4 in gDSFP
+ updateState();
+ rerender();
+ expect(element.textContent).to.be.equal('4');
+ expect(prevPropsArg).to.deep.equal({
+ foo: 'bar'
+ });
+ expect(prevStateArg).to.deep.equal({
+ value: 2
+ });
+ expect(curProps).to.deep.equal({
+ foo: 'bar'
+ });
+ expect(curState).to.deep.equal({
+ value: 4
+ });
+ });
+ });
+});
diff --git a/preact/test/browser/lifecycles/lifecycle.test.js b/preact/test/browser/lifecycles/lifecycle.test.js
new file mode 100644
index 0000000..56cced6
--- /dev/null
+++ b/preact/test/browser/lifecycles/lifecycle.test.js
@@ -0,0 +1,672 @@
+import { setupRerender } from 'preact/test-utils';
+import { createElement, render, Component } from 'preact';
+import { setupScratch, teardown } from '../../_util/helpers';
+
+/** @jsx createElement */
+
+describe('Lifecycle methods', () => {
+ /** @type {HTMLDivElement} */
+ let scratch;
+
+ /** @type {() => void} */
+ let rerender;
+
+ beforeEach(() => {
+ scratch = setupScratch();
+ rerender = setupRerender();
+ });
+
+ afterEach(() => {
+ teardown(scratch);
+ });
+
+ it('should call nested new lifecycle methods in the right order', () => {
+ let updateOuterState;
+ let updateInnerState;
+ let forceUpdateOuter;
+ let forceUpdateInner;
+
+ let log;
+ function logger(msg) {
+ return function() {
+ // return true for shouldComponentUpdate
+ log.push(msg);
+ return true;
+ };
+ }
+
+ class Outer extends Component {
+ static getDerivedStateFromProps() {
+ log.push('outer getDerivedStateFromProps');
+ return null;
+ }
+ constructor() {
+ super();
+ log.push('outer constructor');
+
+ this.state = { value: 0 };
+ forceUpdateOuter = () =>
+ this.forceUpdate(() => log.push('outer forceUpdate callback'));
+ updateOuterState = () =>
+ this.setState(
+ prevState => ({ value: prevState.value % 2 }),
+ () => log.push('outer setState callback')
+ );
+ }
+ render() {
+ log.push('outer render');
+ return (
+ <div>
+ <Inner x={this.props.x} outerValue={this.state.value} />
+ </div>
+ );
+ }
+ }
+ Object.assign(Outer.prototype, {
+ componentDidMount: logger('outer componentDidMount'),
+ shouldComponentUpdate: logger('outer shouldComponentUpdate'),
+ getSnapshotBeforeUpdate: logger('outer getSnapshotBeforeUpdate'),
+ componentDidUpdate: logger('outer componentDidUpdate'),
+ componentWillUnmount: logger('outer componentWillUnmount')
+ });
+
+ class Inner extends Component {
+ static getDerivedStateFromProps() {
+ log.push('inner getDerivedStateFromProps');
+ return null;
+ }
+ constructor() {
+ super();
+ log.push('inner constructor');
+
+ this.state = { value: 0 };
+ forceUpdateInner = () =>
+ this.forceUpdate(() => log.push('inner forceUpdate callback'));
+ updateInnerState = () =>
+ this.setState(
+ prevState => ({ value: prevState.value % 2 }),
+ () => log.push('inner setState callback')
+ );
+ }
+ render() {
+ log.push('inner render');
+ return (
+ <span>
+ {this.props.x} {this.props.outerValue} {this.state.value}
+ </span>
+ );
+ }
+ }
+ Object.assign(Inner.prototype, {
+ componentDidMount: logger('inner componentDidMount'),
+ shouldComponentUpdate: logger('inner shouldComponentUpdate'),
+ getSnapshotBeforeUpdate: logger('inner getSnapshotBeforeUpdate'),
+ componentDidUpdate: logger('inner componentDidUpdate'),
+ componentWillUnmount: logger('inner componentWillUnmount')
+ });
+
+ // Constructor & mounting
+ log = [];
+ render(<Outer x={1} />, scratch);
+ expect(log).to.deep.equal([
+ 'outer constructor',
+ 'outer getDerivedStateFromProps',
+ 'outer render',
+ 'inner constructor',
+ 'inner getDerivedStateFromProps',
+ 'inner render',
+ 'inner componentDidMount',
+ 'outer componentDidMount'
+ ]);
+
+ // Outer & Inner props update
+ log = [];
+ render(<Outer x={2} />, scratch);
+ // Note: we differ from react here in that we apply changes to the dom
+ // as we find them while diffing. React on the other hand separates this
+ // into specific phases, meaning changes to the dom are only flushed
+ // once the whole diff-phase is complete. This is why
+ // "outer getSnapshotBeforeUpdate" is called just before the "inner" hooks.
+ // For react this call would be right before "outer componentDidUpdate"
+ expect(log).to.deep.equal([
+ 'outer getDerivedStateFromProps',
+ 'outer shouldComponentUpdate',
+ 'outer render',
+ 'outer getSnapshotBeforeUpdate',
+ 'inner getDerivedStateFromProps',
+ 'inner shouldComponentUpdate',
+ 'inner render',
+ 'inner getSnapshotBeforeUpdate',
+ 'inner componentDidUpdate',
+ 'outer componentDidUpdate'
+ ]);
+
+ // Outer state update & Inner props update
+ log = [];
+ updateOuterState();
+ rerender();
+ expect(log).to.deep.equal([
+ 'outer getDerivedStateFromProps',
+ 'outer shouldComponentUpdate',
+ 'outer render',
+ 'outer getSnapshotBeforeUpdate',
+ 'inner getDerivedStateFromProps',
+ 'inner shouldComponentUpdate',
+ 'inner render',
+ 'inner getSnapshotBeforeUpdate',
+ 'inner componentDidUpdate',
+ 'outer setState callback',
+ 'outer componentDidUpdate'
+ ]);
+
+ // Inner state update
+ log = [];
+ updateInnerState();
+ rerender();
+ expect(log).to.deep.equal([
+ 'inner getDerivedStateFromProps',
+ 'inner shouldComponentUpdate',
+ 'inner render',
+ 'inner getSnapshotBeforeUpdate',
+ 'inner setState callback',
+ 'inner componentDidUpdate'
+ ]);
+
+ // Force update Outer
+ log = [];
+ forceUpdateOuter();
+ rerender();
+ expect(log).to.deep.equal([
+ 'outer getDerivedStateFromProps',
+ 'outer render',
+ 'outer getSnapshotBeforeUpdate',
+ 'inner getDerivedStateFromProps',
+ 'inner shouldComponentUpdate',
+ 'inner render',
+ 'inner getSnapshotBeforeUpdate',
+ 'inner componentDidUpdate',
+ 'outer forceUpdate callback',
+ 'outer componentDidUpdate'
+ ]);
+
+ // Force update Inner
+ log = [];
+ forceUpdateInner();
+ rerender();
+ expect(log).to.deep.equal([
+ 'inner getDerivedStateFromProps',
+ 'inner render',
+ 'inner getSnapshotBeforeUpdate',
+ 'inner forceUpdate callback',
+ 'inner componentDidUpdate'
+ ]);
+
+ // Unmounting Outer & Inner
+ log = [];
+ render(<table />, scratch);
+ expect(log).to.deep.equal([
+ 'outer componentWillUnmount',
+ 'inner componentWillUnmount'
+ ]);
+ });
+
+ describe('#constructor and component(Did|Will)(Mount|Unmount)', () => {
+ let setState;
+ class Outer extends Component {
+ constructor(p, c) {
+ super(p, c);
+ this.state = { show: true };
+ setState = s => this.setState(s);
+ }
+ render(props, { show }) {
+ return <div>{show && <Inner {...props} />}</div>;
+ }
+ }
+
+ class LifecycleTestComponent extends Component {
+ componentWillMount() {}
+ componentDidMount() {}
+ componentWillUnmount() {}
+ render() {
+ return <div />;
+ }
+ }
+
+ class Inner extends LifecycleTestComponent {
+ render() {
+ return (
+ <div>
+ <InnerMost />
+ </div>
+ );
+ }
+ }
+
+ class InnerMost extends LifecycleTestComponent {
+ render() {
+ return <div />;
+ }
+ }
+
+ let spies = [
+ 'componentWillMount',
+ 'componentDidMount',
+ 'componentWillUnmount'
+ ];
+
+ let verifyLifecycleMethods = TestComponent => {
+ let proto = TestComponent.prototype;
+ spies.forEach(s => sinon.spy(proto, s));
+ let reset = () => spies.forEach(s => proto[s].resetHistory());
+
+ it('should be invoked for components on initial render', () => {
+ reset();
+ render(<Outer />, scratch);
+ expect(proto.componentDidMount).to.have.been.called;
+ expect(proto.componentWillMount).to.have.been.calledBefore(
+ proto.componentDidMount
+ );
+ expect(proto.componentDidMount).to.have.been.called;
+ });
+
+ it('should be invoked for components on unmount', () => {
+ reset();
+ setState({ show: false });
+ rerender();
+
+ expect(proto.componentWillUnmount).to.have.been.called;
+ });
+
+ it('should be invoked for components on re-render', () => {
+ reset();
+ setState({ show: true });
+ rerender();
+
+ expect(proto.componentDidMount).to.have.been.called;
+ expect(proto.componentWillMount).to.have.been.calledBefore(
+ proto.componentDidMount
+ );
+ expect(proto.componentDidMount).to.have.been.called;
+ });
+ };
+
+ describe('inner components', () => {
+ verifyLifecycleMethods(Inner);
+ });
+
+ describe('innermost components', () => {
+ verifyLifecycleMethods(InnerMost);
+ });
+
+ describe('when shouldComponentUpdate() returns false', () => {
+ let setState;
+
+ class Outer extends Component {
+ constructor() {
+ super();
+ this.state = { show: true };
+ setState = s => this.setState(s);
+ }
+ render(props, { show }) {
+ return (
+ <div>
+ {show && (
+ <div>
+ <Inner {...props} />
+ </div>
+ )}
+ </div>
+ );
+ }
+ }
+
+ class Inner extends Component {
+ shouldComponentUpdate() {
+ return false;
+ }
+ componentWillMount() {}
+ componentDidMount() {}
+ componentWillUnmount() {}
+ render() {
+ return <div />;
+ }
+ }
+
+ let proto = Inner.prototype;
+ let spies = [
+ 'componentWillMount',
+ 'componentDidMount',
+ 'componentWillUnmount'
+ ];
+ spies.forEach(s => sinon.spy(proto, s));
+
+ let reset = () => spies.forEach(s => proto[s].resetHistory());
+
+ beforeEach(() => reset());
+
+ it('should be invoke normally on initial mount', () => {
+ render(<Outer />, scratch);
+ expect(proto.componentWillMount).to.have.been.called;
+ expect(proto.componentWillMount).to.have.been.calledBefore(
+ proto.componentDidMount
+ );
+ expect(proto.componentDidMount).to.have.been.called;
+ });
+
+ it('should be invoked normally on unmount', () => {
+ setState({ show: false });
+ rerender();
+
+ expect(proto.componentWillUnmount).to.have.been.called;
+ });
+
+ it('should still invoke mount for shouldComponentUpdate():false', () => {
+ setState({ show: true });
+ rerender();
+
+ expect(proto.componentWillMount).to.have.been.called;
+ expect(proto.componentWillMount).to.have.been.calledBefore(
+ proto.componentDidMount
+ );
+ expect(proto.componentDidMount).to.have.been.called;
+ });
+
+ it('should still invoke unmount for shouldComponentUpdate():false', () => {
+ setState({ show: false });
+ rerender();
+
+ expect(proto.componentWillUnmount).to.have.been.called;
+ });
+ });
+ });
+
+ describe('#setState', () => {
+ // From preactjs/preact#1170
+ it('should NOT mutate state, only create new versions', () => {
+ const stateConstant = {};
+ let didMount = false;
+ let componentState;
+
+ class Stateful extends Component {
+ constructor() {
+ super(...arguments);
+ this.state = stateConstant;
+ }
+
+ componentDidMount() {
+ didMount = true;
+
+ // eslint-disable-next-line react/no-did-mount-set-state
+ this.setState({ key: 'value' }, () => {
+ componentState = this.state;
+ });
+ }
+
+ render() {
+ return <div />;
+ }
+ }
+
+ render(<Stateful />, scratch);
+ rerender();
+
+ expect(didMount).to.equal(true);
+ expect(componentState).to.deep.equal({ key: 'value' });
+ expect(stateConstant).to.deep.equal({});
+ });
+
+ // This feature is not mentioned in the docs, but is part of the release
+ // notes for react v16.0.0: https://reactjs.org/blog/2017/09/26/react-v16.0.html#breaking-changes
+ it('should abort if updater function returns null', () => {
+ let updateState;
+ class Foo extends Component {
+ constructor() {
+ super();
+ this.state = { value: 0 };
+ updateState = () =>
+ this.setState(prev => {
+ prev.value++;
+ return null;
+ });
+ }
+
+ render() {
+ return 'value: ' + this.state.value;
+ }
+ }
+
+ let renderSpy = sinon.spy(Foo.prototype, 'render');
+ render(<Foo />, scratch);
+ renderSpy.resetHistory();
+
+ updateState();
+ rerender();
+ expect(renderSpy).to.not.be.called;
+ });
+
+ it('should call callback with correct this binding', () => {
+ let inst;
+ let updateState;
+ class Foo extends Component {
+ constructor() {
+ super();
+ updateState = () => this.setState({}, this.onUpdate);
+ }
+
+ onUpdate() {
+ inst = this;
+ }
+ }
+
+ render(<Foo />, scratch);
+ updateState();
+ rerender();
+
+ expect(inst).to.be.instanceOf(Foo);
+ });
+ });
+
+ describe('Lifecycle DOM Timing', () => {
+ it('should be invoked when dom does (DidMount, WillUnmount) or does not (WillMount, DidUnmount) exist', () => {
+ let setState;
+ class Outer extends Component {
+ constructor() {
+ super();
+ this.state = { show: true };
+ setState = s => {
+ this.setState(s);
+ this.forceUpdate();
+ };
+ }
+ componentWillMount() {
+ expect(
+ document.getElementById('OuterDiv'),
+ 'Outer componentWillMount'
+ ).to.not.exist;
+ }
+ componentDidMount() {
+ expect(document.getElementById('OuterDiv'), 'Outer componentDidMount')
+ .to.exist;
+ }
+ componentWillUnmount() {
+ expect(
+ document.getElementById('OuterDiv'),
+ 'Outer componentWillUnmount'
+ ).to.exist;
+ setTimeout(() => {
+ expect(
+ document.getElementById('OuterDiv'),
+ 'Outer after componentWillUnmount'
+ ).to.not.exist;
+ }, 0);
+ }
+ render(props, { show }) {
+ return (
+ <div id="OuterDiv">
+ {show && (
+ <div>
+ <Inner {...props} />
+ </div>
+ )}
+ </div>
+ );
+ }
+ }
+
+ class Inner extends Component {
+ componentWillMount() {
+ expect(
+ document.getElementById('InnerDiv'),
+ 'Inner componentWillMount'
+ ).to.not.exist;
+ }
+ componentDidMount() {
+ expect(document.getElementById('InnerDiv'), 'Inner componentDidMount')
+ .to.exist;
+ }
+ componentWillUnmount() {
+ // @TODO Component mounted into elements (non-components)
+ // are currently unmounted after those elements, so their
+ // DOM is unmounted prior to the method being called.
+ //expect(document.getElementById('InnerDiv'), 'Inner componentWillUnmount').to.exist;
+ setTimeout(() => {
+ expect(
+ document.getElementById('InnerDiv'),
+ 'Inner after componentWillUnmount'
+ ).to.not.exist;
+ }, 0);
+ }
+
+ render() {
+ return <div id="InnerDiv" />;
+ }
+ }
+
+ let proto = Inner.prototype;
+ let spies = [
+ 'componentWillMount',
+ 'componentDidMount',
+ 'componentWillUnmount'
+ ];
+ spies.forEach(s => sinon.spy(proto, s));
+
+ let reset = () => spies.forEach(s => proto[s].resetHistory());
+
+ render(<Outer />, scratch);
+ expect(proto.componentWillMount).to.have.been.called;
+ expect(proto.componentWillMount).to.have.been.calledBefore(
+ proto.componentDidMount
+ );
+ expect(proto.componentDidMount).to.have.been.called;
+
+ reset();
+ setState({ show: false });
+ rerender();
+
+ expect(proto.componentWillUnmount).to.have.been.called;
+
+ reset();
+ setState({ show: true });
+ rerender();
+
+ expect(proto.componentWillMount).to.have.been.called;
+ expect(proto.componentWillMount).to.have.been.calledBefore(
+ proto.componentDidMount
+ );
+ expect(proto.componentDidMount).to.have.been.called;
+ });
+
+ it('should be able to use getDerivedStateFromError and componentDidCatch together', () => {
+ let didCatch = sinon.spy(),
+ getDerived = sinon.spy();
+ const error = new Error('hi');
+
+ class Boundary extends Component {
+ static getDerivedStateFromError(err) {
+ getDerived(err);
+ return { err };
+ }
+
+ componentDidCatch(err) {
+ didCatch(err);
+ }
+
+ render() {
+ return this.state.err ? <div /> : this.props.children;
+ }
+ }
+
+ const ThrowErr = () => {
+ throw error;
+ };
+
+ render(
+ <Boundary>
+ <ThrowErr />
+ </Boundary>,
+ scratch
+ );
+ rerender();
+
+ expect(didCatch).to.have.been.calledWith(error);
+
+ expect(getDerived).to.have.been.calledWith(error);
+ });
+
+ it('should remove this.base for HOC', () => {
+ let createComponent = (name, fn) => {
+ class C extends Component {
+ componentWillUnmount() {
+ expect(this.base, `${name}.componentWillUnmount`).to.exist;
+ setTimeout(() => {
+ expect(this.base, `after ${name}.componentWillUnmount`).not.to
+ .exist;
+ }, 0);
+ }
+ render(props) {
+ return fn(props);
+ }
+ }
+ sinon.spy(C.prototype, 'componentWillUnmount');
+ sinon.spy(C.prototype, 'render');
+ return C;
+ };
+
+ class Wrapper extends Component {
+ render({ children }) {
+ return <div class="wrapper">{children}</div>;
+ }
+ }
+
+ let One = createComponent('One', () => <Wrapper>one</Wrapper>);
+ let Two = createComponent('Two', () => <Wrapper>two</Wrapper>);
+ let Three = createComponent('Three', () => <Wrapper>three</Wrapper>);
+
+ let components = [One, Two, Three];
+
+ let Selector = createComponent('Selector', ({ page }) => {
+ let Child = components[page];
+ return Child && <Child />;
+ });
+
+ let app;
+ class App extends Component {
+ constructor() {
+ super();
+ app = this;
+ }
+
+ render(_, { page }) {
+ return <Selector page={page} />;
+ }
+ }
+
+ render(<App />, scratch);
+
+ for (let i = 0; i < 20; i++) {
+ app.setState({ page: i % components.length });
+ app.forceUpdate();
+ }
+ });
+ });
+});
diff --git a/preact/test/browser/lifecycles/shouldComponentUpdate.test.js b/preact/test/browser/lifecycles/shouldComponentUpdate.test.js
new file mode 100644
index 0000000..6b5f187
--- /dev/null
+++ b/preact/test/browser/lifecycles/shouldComponentUpdate.test.js
@@ -0,0 +1,916 @@
+import { setupRerender } from 'preact/test-utils';
+import { createElement, render, Component, Fragment } from 'preact';
+import { setupScratch, teardown } from '../../_util/helpers';
+import { logCall, clearLog } from '../../_util/logCall';
+
+/** @jsx createElement */
+
+describe('Lifecycle methods', () => {
+ /** @type {HTMLDivElement} */
+ let scratch;
+
+ /** @type {() => void} */
+ let rerender;
+
+ // function expectDomLogToBe(expectedOperations, message) {
+ // expect(getLog()).to.deep.equal(expectedOperations, message);
+ // }
+ let resetInsertBefore;
+ let resetRemoveChild;
+ let resetRemove;
+
+ before(() => {
+ resetInsertBefore = logCall(Element.prototype, 'insertBefore');
+ resetRemoveChild = logCall(Element.prototype, 'appendChild');
+ resetRemove = logCall(Element.prototype, 'removeChild');
+ });
+
+ after(() => {
+ resetInsertBefore();
+ resetRemoveChild();
+ resetRemove();
+ });
+
+ beforeEach(() => {
+ scratch = setupScratch();
+ rerender = setupRerender();
+
+ clearLog();
+ });
+
+ afterEach(() => {
+ teardown(scratch);
+ });
+
+ describe('#shouldComponentUpdate', () => {
+ let setState;
+
+ class Should extends Component {
+ constructor() {
+ super();
+ this.state = { show: true };
+ setState = s => this.setState(s);
+ }
+ render(props, { show }) {
+ return show ? <div /> : null;
+ }
+ }
+
+ class ShouldNot extends Should {
+ shouldComponentUpdate() {
+ return false;
+ }
+ }
+
+ sinon.spy(Should.prototype, 'render');
+ sinon.spy(ShouldNot.prototype, 'shouldComponentUpdate');
+
+ beforeEach(() => Should.prototype.render.resetHistory());
+
+ it('should rerender component on change by default', () => {
+ render(<Should />, scratch);
+ setState({ show: false });
+ rerender();
+
+ expect(Should.prototype.render).to.have.been.calledTwice;
+ });
+
+ it('should not rerender component if shouldComponentUpdate returns false', () => {
+ render(<ShouldNot />, scratch);
+ setState({ show: false });
+ rerender();
+
+ expect(ShouldNot.prototype.shouldComponentUpdate).to.have.been.calledOnce;
+ expect(ShouldNot.prototype.render).to.have.been.calledOnce;
+ });
+
+ it('should reorder non-updating text children', () => {
+ const rows = [
+ { id: '1', a: 5, b: 100 },
+ { id: '2', a: 50, b: 10 },
+ { id: '3', a: 25, b: 1000 }
+ ];
+
+ class Row extends Component {
+ shouldComponentUpdate(nextProps) {
+ return nextProps.id !== this.props.id;
+ }
+
+ render() {
+ return this.props.id;
+ }
+ }
+
+ const App = ({ sortBy }) => (
+ <div>
+ <table>
+ {rows
+ .sort((a, b) => (a[sortBy] > b[sortBy] ? -1 : 1))
+ .map(row => (
+ <Row id={row.id} key={row.id} />
+ ))}
+ </table>
+ </div>
+ );
+
+ render(<App sortBy="a" />, scratch);
+ expect(scratch.innerHTML).to.equal('<div><table>231</table></div>');
+
+ render(<App sortBy="b" />, scratch);
+ expect(scratch.innerHTML).to.equal('<div><table>312</table></div>');
+ });
+
+ it('should rerender when sCU returned false before', () => {
+ let c;
+ let spy = sinon.spy();
+
+ class App extends Component {
+ constructor() {
+ super();
+ c = this;
+ }
+
+ shouldComponentUpdate(_, nextState) {
+ return !!nextState.update;
+ }
+
+ render() {
+ spy();
+ return <div>foo</div>;
+ }
+ }
+
+ render(<App />, scratch);
+
+ c.setState({});
+ rerender();
+ spy.resetHistory();
+
+ c.setState({ update: true });
+ rerender();
+ expect(spy).to.be.calledOnce;
+ });
+
+ it('should be called with nextState', () => {
+ let c;
+ let spy = sinon.spy();
+
+ class App extends Component {
+ constructor() {
+ super();
+ c = this;
+ this.state = { a: false };
+ }
+
+ shouldComponentUpdate(_, nextState) {
+ return this.state !== nextState;
+ }
+
+ render() {
+ spy();
+ return <div>foo</div>;
+ }
+ }
+
+ render(<App />, scratch);
+
+ c.setState({});
+ rerender();
+ spy.resetHistory();
+
+ c.setState({ a: true });
+ rerender();
+ expect(spy).to.be.calledOnce;
+ });
+
+ it('should clear renderCallbacks', () => {
+ const spy = sinon.spy();
+ let c,
+ renders = 0;
+
+ class App extends Component {
+ constructor() {
+ super();
+ c = this;
+ this.state = { a: false };
+ }
+
+ shouldComponentUpdate(_, nextState) {
+ return false;
+ }
+
+ render() {
+ renders += 1;
+ return <div>foo</div>;
+ }
+ }
+
+ render(<App />, scratch);
+ expect(renders).to.equal(1);
+
+ c.setState({}, spy);
+ rerender();
+ expect(renders).to.equal(1);
+ expect(spy).to.be.calledOnce;
+ });
+
+ it('should not be called on forceUpdate', () => {
+ let Comp;
+ class Foo extends Component {
+ constructor() {
+ super();
+ Comp = this;
+ }
+
+ shouldComponentUpdate() {
+ return false;
+ }
+
+ render() {
+ return <ShouldNot />;
+ }
+ }
+
+ sinon.spy(Foo.prototype, 'shouldComponentUpdate');
+ sinon.spy(Foo.prototype, 'render');
+
+ render(<Foo />, scratch);
+ Comp.forceUpdate();
+ rerender();
+
+ expect(Foo.prototype.shouldComponentUpdate).to.not.have.been.called;
+ expect(Foo.prototype.render).to.have.been.calledTwice;
+ });
+
+ it('should not be called on forceUpdate followed by setState', () => {
+ let Comp;
+ class Foo extends Component {
+ constructor() {
+ super();
+ Comp = this;
+ }
+
+ shouldComponentUpdate() {
+ return false;
+ }
+
+ render() {
+ return <ShouldNot />;
+ }
+ }
+
+ sinon.spy(Foo.prototype, 'shouldComponentUpdate');
+ sinon.spy(Foo.prototype, 'render');
+
+ render(<Foo />, scratch);
+ Comp.forceUpdate();
+ Comp.setState({});
+ rerender();
+
+ expect(Foo.prototype.render).to.have.been.calledTwice;
+ expect(Foo.prototype.shouldComponentUpdate).to.not.have.been.called;
+ });
+
+ it('should not block queued child forceUpdate', () => {
+ let i = 0;
+ let updateInner;
+ class Inner extends Component {
+ shouldComponentUpdate() {
+ return i === 0;
+ }
+ render() {
+ updateInner = () => this.forceUpdate();
+ return <div>{++i}</div>;
+ }
+ }
+
+ let updateOuter;
+ class Outer extends Component {
+ shouldComponentUpdate() {
+ return i === 0;
+ }
+ render() {
+ updateOuter = () => this.forceUpdate();
+ return <Inner />;
+ }
+ }
+
+ class App extends Component {
+ render() {
+ return <Outer />;
+ }
+ }
+
+ render(<App />, scratch);
+
+ updateOuter();
+ updateInner();
+ rerender();
+
+ expect(scratch.textContent).to.equal('2');
+
+ // The inner sCU should return false on second render because
+ // it was not enqueued via forceUpdate
+ updateOuter();
+ rerender();
+ expect(scratch.textContent).to.equal('2');
+ });
+
+ it('should be passed next props and state', () => {
+ /** @type {() => void} */
+ let updateState;
+
+ let curProps;
+ let curState;
+ let nextPropsArg;
+ let nextStateArg;
+
+ class Foo extends Component {
+ constructor(props) {
+ super(props);
+ this.state = {
+ value: 0
+ };
+ updateState = () =>
+ this.setState({
+ value: this.state.value + 1
+ });
+ }
+ static getDerivedStateFromProps(props, state) {
+ // NOTE: Don't do this in real production code!
+ // https://reactjs.org/blog/2018/06/07/you-probably-dont-need-derived-state.html
+ return {
+ value: state.value + 1
+ };
+ }
+ shouldComponentUpdate(nextProps, nextState) {
+ nextPropsArg = { ...nextProps };
+ nextStateArg = { ...nextState };
+
+ curProps = { ...this.props };
+ curState = { ...this.state };
+
+ return true;
+ }
+ render() {
+ return <div>{this.state.value}</div>;
+ }
+ }
+
+ // Expectation:
+ // `this.state` in shouldComponentUpdate should be
+ // the state before setState or getDerivedStateFromProps was called
+ // `nextState` in shouldComponentUpdate should be
+ // the updated state after getDerivedStateFromProps was called
+
+ // Initial render
+ // state.value: initialized to 0 in constructor, 0 -> 1 in gDSFP
+ render(<Foo foo="foo" />, scratch);
+ expect(scratch.firstChild.textContent).to.be.equal('1');
+ expect(curProps).to.be.undefined;
+ expect(curState).to.be.undefined;
+ expect(nextPropsArg).to.be.undefined;
+ expect(nextStateArg).to.be.undefined;
+
+ // New props
+ // state.value: 1 -> 2 in gDSFP
+ render(<Foo foo="bar" />, scratch);
+ expect(scratch.firstChild.textContent).to.be.equal('2');
+ expect(curProps).to.deep.equal({ foo: 'foo' });
+ expect(curState).to.deep.equal({ value: 1 });
+ expect(nextPropsArg).to.deep.equal({ foo: 'bar' });
+ expect(nextStateArg).to.deep.equal({ value: 2 });
+
+ // New state
+ // state.value: 2 -> 3 in updateState, 3 -> 4 in gDSFP
+ updateState();
+ rerender();
+
+ expect(scratch.firstChild.textContent).to.be.equal('4');
+ expect(curProps).to.deep.equal({ foo: 'bar' });
+ expect(curState).to.deep.equal({ value: 2 });
+ expect(nextPropsArg).to.deep.equal({ foo: 'bar' });
+ expect(nextStateArg).to.deep.equal({ value: 4 });
+ });
+
+ it('should update props reference when sCU returns false', () => {
+ let spy = sinon.spy();
+
+ let updateState;
+ class Foo extends Component {
+ constructor() {
+ super();
+ updateState = () => this.setState({});
+ }
+
+ shouldComponentUpdate(nextProps) {
+ if (nextProps !== this.props) {
+ spy();
+ return false;
+ }
+ return true;
+ }
+ }
+
+ render(<Foo foo="foo" />, scratch);
+ render(<Foo foo="bar" />, scratch);
+ expect(spy).to.be.calledOnce;
+
+ updateState();
+ rerender();
+
+ expect(spy).to.be.calledOnce;
+ });
+
+ it('should update state reference when sCU returns false', () => {
+ let spy = sinon.spy();
+
+ let updateState;
+ class Foo extends Component {
+ constructor() {
+ super();
+ this.state = { foo: 1 };
+ updateState = () => this.setState({ foo: 2 });
+ }
+
+ shouldComponentUpdate(_, nextState) {
+ if (nextState !== this.state) {
+ spy(this.state, nextState);
+ return false;
+ }
+ return true;
+ }
+ }
+
+ render(<Foo />, scratch);
+ updateState();
+ rerender();
+
+ expect(spy).to.be.calledOnce;
+ expect(spy).to.be.calledWithMatch({ foo: 1 }, { foo: 2 });
+
+ updateState();
+ rerender();
+
+ expect(spy).to.be.calledWithMatch({ foo: 2 }, { foo: 2 });
+ expect(spy).to.be.calledTwice;
+ });
+
+ // issue #1864
+ it('should update dom pointers correctly when returning an empty string', () => {
+ function Child({ showMe, counter }) {
+ return showMe ? <div>Counter: {counter}</div> : '';
+ }
+
+ class Parent extends Component {
+ shouldComponentUpdate() {
+ return false;
+ }
+ render() {
+ return <Inner />;
+ }
+ }
+
+ let updateChild = () => null;
+ class Inner extends Component {
+ constructor(props) {
+ super(props);
+ this.state = { showMe: false };
+ updateChild = () => {
+ this.setState({ showMe: (display = !display) });
+ };
+ }
+ render() {
+ return <Child showMe={this.state.showMe} counter={0} />;
+ }
+ }
+
+ let display = false;
+ let updateApp = () => null;
+ class App extends Component {
+ constructor(props) {
+ super(props);
+ updateApp = () => this.setState({});
+ }
+ render() {
+ return (
+ <div>
+ <div />
+ <div />
+ <Parent />
+ </div>
+ );
+ }
+ }
+
+ render(<App />, scratch);
+ expect(scratch.textContent).to.equal('');
+
+ updateChild();
+ rerender();
+
+ expect(scratch.textContent).to.equal('Counter: 0');
+
+ updateApp();
+ rerender();
+
+ expect(scratch.textContent).to.equal('Counter: 0');
+
+ updateChild();
+ rerender();
+
+ expect(scratch.textContent).to.equal('');
+
+ updateApp();
+ rerender();
+ expect(scratch.textContent).to.equal('');
+ });
+
+ // issue #1864 second case
+ it('should update dom pointers correctly when returning a string', () => {
+ function Child({ showMe, counter }) {
+ return showMe ? <div>Counter: {counter}</div> : 'foo';
+ }
+
+ class Parent extends Component {
+ shouldComponentUpdate() {
+ return false;
+ }
+ render() {
+ return <Inner />;
+ }
+ }
+
+ let updateChild = () => null;
+ class Inner extends Component {
+ constructor(props) {
+ super(props);
+ this.state = { showMe: false };
+ updateChild = () => {
+ this.setState({ showMe: (display = !display) });
+ };
+ }
+ render() {
+ return <Child showMe={this.state.showMe} counter={0} />;
+ }
+ }
+
+ let display = false;
+ let updateApp = () => null;
+ class App extends Component {
+ constructor(props) {
+ super(props);
+ updateApp = () => this.setState({});
+ }
+ render() {
+ return (
+ <div>
+ <div />
+ <div />
+ <Parent />
+ </div>
+ );
+ }
+ }
+
+ render(<App />, scratch);
+ expect(scratch.textContent).to.equal('foo');
+
+ updateChild();
+ rerender();
+
+ expect(scratch.textContent).to.equal('Counter: 0');
+
+ updateApp();
+ rerender();
+
+ expect(scratch.textContent).to.equal('Counter: 0');
+
+ updateChild();
+ rerender();
+
+ expect(scratch.textContent).to.equal('foo');
+
+ updateApp();
+ rerender();
+ expect(scratch.textContent).to.equal('foo');
+ });
+
+ it('should correctly update nested children', () => {
+ let hideThree, incrementThree;
+
+ class One extends Component {
+ shouldComponentUpdate() {
+ return false;
+ }
+ render(p) {
+ return p.children;
+ }
+ }
+
+ class Two extends Component {
+ constructor(props) {
+ super(props);
+ this.state = { hideMe: false };
+ hideThree = () => this.setState(s => ({ hideMe: !s.hideMe }));
+ }
+
+ shouldComponentUpdate(nextProps, nextState) {
+ return this.state.hideMe !== nextState.hideMe;
+ }
+
+ render(p, { hideMe }) {
+ return hideMe ? <Fragment /> : p.children;
+ }
+ }
+
+ class Three extends Component {
+ constructor(props) {
+ super(props);
+ this.state = { counter: 1 };
+ incrementThree = () =>
+ this.setState(s => ({ counter: s.counter + 1 }));
+ }
+
+ render(p, { counter }) {
+ return <span>{counter}</span>;
+ }
+ }
+
+ render(
+ <One>
+ <Two>
+ <Three />
+ </Two>
+ </One>,
+ scratch
+ );
+ expect(scratch.innerHTML).to.equal('<span>1</span>');
+
+ hideThree();
+ rerender();
+ expect(scratch.innerHTML).to.equal('');
+
+ hideThree();
+ rerender();
+ expect(scratch.innerHTML).to.equal('<span>1</span>');
+
+ incrementThree();
+ rerender();
+ expect(scratch.innerHTML).to.equal('<span>2</span>');
+ });
+
+ // issue #1864 third case
+ it('should update dom pointers correctly without siblings', () => {
+ function Child({ showMe, counter }) {
+ return showMe ? <div>Counter: {counter}</div> : 'foo';
+ }
+
+ class Parent extends Component {
+ shouldComponentUpdate() {
+ return false;
+ }
+ render() {
+ return <Inner />;
+ }
+ }
+
+ let updateChild = () => null;
+ class Inner extends Component {
+ constructor(props) {
+ super(props);
+ this.state = { showMe: false };
+ updateChild = () => {
+ this.setState({ showMe: (display = !display) });
+ };
+ }
+ render() {
+ return <Child showMe={this.state.showMe} counter={0} />;
+ }
+ }
+
+ let display = false;
+ let updateApp = () => null;
+ class App extends Component {
+ constructor(props) {
+ super(props);
+ updateApp = () => this.setState({});
+ }
+ render() {
+ return (
+ <div>
+ <Parent />
+ </div>
+ );
+ }
+ }
+
+ render(<App />, scratch);
+ expect(scratch.textContent).to.equal('foo');
+
+ updateChild();
+ rerender();
+
+ expect(scratch.textContent).to.equal('Counter: 0');
+
+ updateApp();
+ rerender();
+
+ expect(scratch.textContent).to.equal('Counter: 0');
+
+ updateChild();
+ rerender();
+
+ expect(scratch.textContent).to.equal('foo');
+
+ updateApp();
+ rerender();
+
+ expect(scratch.textContent).to.equal('foo');
+ });
+ });
+
+ it('should correctly render when sCU component has null children', () => {
+ class App extends Component {
+ shouldComponentUpdate() {
+ return false;
+ }
+ render() {
+ return [null, <div>Hello World!</div>, null];
+ }
+ }
+
+ render(<App />, scratch);
+ expect(scratch.innerHTML).to.equal('<div>Hello World!</div>');
+
+ render(<App />, scratch);
+ expect(scratch.innerHTML).to.equal('<div>Hello World!</div>');
+
+ render(<App />, scratch);
+ expect(scratch.innerHTML).to.equal('<div>Hello World!</div>');
+ });
+
+ it('should support nested update with strict-equal vnodes', () => {
+ let wrapperSetState, childSetState;
+
+ class Child extends Component {
+ constructor(props) {
+ super(props);
+ this.state = { foo: 'baz' };
+ }
+
+ render() {
+ childSetState = this.setState.bind(this);
+ return <p>{this.state.foo}</p>;
+ }
+ }
+
+ class Wrapper extends Component {
+ render() {
+ wrapperSetState = this.setState.bind(this);
+ return this.props.children;
+ }
+ }
+
+ const App = () => (
+ <Wrapper>
+ <Child />
+ </Wrapper>
+ );
+
+ render(<App />, scratch);
+ expect(scratch.innerHTML).to.equal('<p>baz</p>');
+
+ wrapperSetState({ hi: 'world' });
+ childSetState({ foo: 'bar' });
+ rerender();
+ expect(scratch.innerHTML).to.equal('<p>bar</p>');
+ });
+
+ it('should reorder non-updating nested Fragment children', () => {
+ const rows = [
+ { id: '1', a: 5, b: 100 },
+ { id: '2', a: 50, b: 10 },
+ { id: '3', a: 25, b: 1000 }
+ ];
+
+ function Cell({ id, a, b }) {
+ // Return an array to really test out the reordering algorithm :)
+ return (
+ <Fragment>
+ <div>id: {id}</div>
+ <Fragment>
+ <div>a: {a}</div>
+ <div>b: {b}</div>
+ </Fragment>
+ </Fragment>
+ );
+ }
+
+ class Row extends Component {
+ shouldComponentUpdate(nextProps) {
+ return nextProps.id !== this.props.id;
+ }
+
+ render(props) {
+ return <Cell id={props.id} a={props.a} b={props.b} />;
+ }
+ }
+
+ const App = ({ sortBy }) => (
+ <div>
+ <table>
+ {rows
+ .sort((a, b) => (a[sortBy] > b[sortBy] ? -1 : 1))
+ .map(row => (
+ <Row key={row.id} id={row.id} a={row.a} b={row.b} />
+ ))}
+ </table>
+ </div>
+ );
+
+ render(<App sortBy="a" />, scratch);
+ expect(scratch.innerHTML).to.equal(
+ `<div><table>${[
+ '<div>id: 2</div><div>a: 50</div><div>b: 10</div>',
+ '<div>id: 3</div><div>a: 25</div><div>b: 1000</div>',
+ '<div>id: 1</div><div>a: 5</div><div>b: 100</div>'
+ ].join('')}</table></div>`
+ );
+
+ clearLog();
+ render(<App sortBy="b" />, scratch);
+ expect(scratch.innerHTML).to.equal(
+ `<div><table>${[
+ '<div>id: 3</div><div>a: 25</div><div>b: 1000</div>',
+ '<div>id: 1</div><div>a: 5</div><div>b: 100</div>',
+ '<div>id: 2</div><div>a: 50</div><div>b: 10</div>'
+ ].join('')}</table></div>`
+ );
+ // TODO: these tests pass in isolation but not when all tests are running, figure out why logCall stops appending to log.
+ // expectDomLogToBe([
+ // '<table>id: 2a: 50b: 10id: 3a: 25b: 1000id: 1a: 5b: 100.insertBefore(<div>id: 3, <div>id: 2)',
+ // '<table>id: 3id: 2a: 50b: 10a: 25b: 1000id: 1a: 5b: 100.insertBefore(<div>a: 25, <div>id: 2)',
+ // '<table>id: 3a: 25id: 2a: 50b: 10b: 1000id: 1a: 5b: 100.insertBefore(<div>b: 1000, <div>id: 2)',
+ // '<table>id: 3a: 25b: 1000id: 2a: 50b: 10id: 1a: 5b: 100.insertBefore(<div>id: 1, <div>id: 2)',
+ // '<table>id: 3a: 25b: 1000id: 1id: 2a: 50b: 10a: 5b: 100.insertBefore(<div>a: 5, <div>id: 2)',
+ // '<table>id: 3a: 25b: 1000id: 1a: 5id: 2a: 50b: 10b: 100.insertBefore(<div>b: 100, <div>id: 2)'
+ // ]);
+ });
+
+ it('should maintain the order if memoised component initially rendered empty content', () => {
+ let showText, updateParent;
+ class Child extends Component {
+ constructor(props) {
+ super(props);
+ this.state = {
+ show: false
+ };
+ showText = () => this.setState({ show: true });
+ }
+ render(props, { show }) {
+ if (!show) return null;
+
+ return <div>Component</div>;
+ }
+ }
+
+ class Memoized extends Component {
+ shouldComponentUpdate() {
+ return false;
+ }
+ render() {
+ return <Child />;
+ }
+ }
+ class Parent extends Component {
+ constructor(props) {
+ super(props);
+ updateParent = () => this.setState({});
+ }
+ render() {
+ return (
+ <Fragment>
+ <div>Before</div>
+ <Memoized />
+ <div>After</div>
+ </Fragment>
+ );
+ }
+ }
+
+ render(<Parent />, scratch);
+ expect(scratch.innerHTML).to.equal(`<div>Before</div><div>After</div>`);
+
+ updateParent();
+ rerender();
+ expect(scratch.innerHTML).to.equal(`<div>Before</div><div>After</div>`);
+
+ showText();
+ rerender();
+
+ expect(scratch.innerHTML).to.equal(
+ `<div>Before</div><div>Component</div><div>After</div>`
+ );
+ });
+});
diff --git a/preact/test/browser/placeholders.test.js b/preact/test/browser/placeholders.test.js
new file mode 100644
index 0000000..5b52ee6
--- /dev/null
+++ b/preact/test/browser/placeholders.test.js
@@ -0,0 +1,308 @@
+import { createElement, Component, render, createRef } from 'preact';
+import { setupRerender } from 'preact/test-utils';
+import { setupScratch, teardown } from '../_util/helpers';
+import { logCall, clearLog, getLog } from '../_util/logCall';
+import { div } from '../_util/dom';
+
+/** @jsx createElement */
+
+describe('null placeholders', () => {
+ /** @type {HTMLDivElement} */
+ let scratch;
+
+ /** @type {() => void} */
+ let rerender;
+
+ /** @type {string[]} */
+ let ops;
+
+ function createNullable(name) {
+ return function Nullable(props) {
+ return props.show ? name : null;
+ };
+ }
+
+ /**
+ * @param {string} name
+ * @returns {[import('preact').ComponentClass, import('preact').RefObject<{ toggle(): void }>]}
+ */
+ function createStatefulNullable(name) {
+ let ref = createRef();
+ class Nullable extends Component {
+ constructor(props) {
+ super(props);
+ this.state = { show: props.initialShow || true };
+ ref.current = this;
+ }
+ toggle() {
+ this.setState({ show: !this.state.show });
+ }
+ componentDidUpdate() {
+ ops.push(`Update ${name}`);
+ }
+ componentDidMount() {
+ ops.push(`Mount ${name}`);
+ }
+ componentWillUnmount() {
+ ops.push(`Unmount ${name}`);
+ }
+ render() {
+ return this.state.show ? <div>{name}</div> : null;
+ }
+ }
+
+ return [Nullable, ref];
+ }
+
+ 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();
+ ops = [];
+ });
+
+ afterEach(() => {
+ teardown(scratch);
+ clearLog();
+ });
+
+ it('should treat undefined as a hole', () => {
+ let Bar = () => <div>bar</div>;
+
+ function Foo(props) {
+ let sibling;
+ if (props.condition) {
+ sibling = <Bar />;
+ }
+
+ return (
+ <div>
+ <div>Hello</div>
+ {sibling}
+ </div>
+ );
+ }
+
+ render(<Foo condition />, scratch);
+ expect(scratch.innerHTML).to.equal(
+ '<div><div>Hello</div><div>bar</div></div>'
+ );
+ clearLog();
+
+ render(<Foo />, scratch);
+ expect(scratch.innerHTML).to.equal('<div><div>Hello</div></div>');
+ expect(getLog()).to.deep.equal(['<div>bar.remove()']);
+ });
+
+ it('should preserve state of Components when using null or booleans as placeholders', () => {
+ // Must be the same class for all children in <App /> for this test to be valid
+ class Stateful extends Component {
+ constructor(props) {
+ super(props);
+ this.state = { count: 0 };
+ }
+ increment() {
+ this.setState({ count: this.state.count + 1 });
+ }
+ componentDidUpdate() {
+ ops.push(`Update ${this.props.name}`);
+ }
+ componentDidMount() {
+ ops.push(`Mount ${this.props.name}`);
+ }
+ componentWillUnmount() {
+ ops.push(`Unmount ${this.props.name}`);
+ }
+ render() {
+ return (
+ <div>
+ {this.props.name}: {this.state.count}
+ </div>
+ );
+ }
+ }
+
+ const s1ref = createRef();
+ const s2ref = createRef();
+ const s3ref = createRef();
+
+ function App({ first = null, second = false }) {
+ return [first, second, <Stateful name="third" ref={s3ref} />];
+ }
+
+ // Mount third stateful - Initial render
+ render(<App />, scratch);
+ expect(scratch.innerHTML).to.equal('<div>third: 0</div>');
+ expect(ops).to.deep.equal(['Mount third'], 'mount third');
+
+ // Update third stateful
+ ops = [];
+ s3ref.current.increment();
+ rerender();
+ expect(scratch.innerHTML).to.equal('<div>third: 1</div>');
+ expect(ops).to.deep.equal(['Update third'], 'update third');
+
+ // Mount first stateful
+ ops = [];
+ render(<App first={<Stateful name="first" ref={s1ref} />} />, scratch);
+ expect(scratch.innerHTML).to.equal(
+ '<div>first: 0</div><div>third: 1</div>'
+ );
+ expect(ops).to.deep.equal(['Mount first', 'Update third'], 'mount first');
+
+ // Update first stateful
+ ops = [];
+ s1ref.current.increment();
+ s3ref.current.increment();
+ rerender();
+ expect(scratch.innerHTML).to.equal(
+ '<div>first: 1</div><div>third: 2</div>'
+ );
+ expect(ops).to.deep.equal(['Update first', 'Update third'], 'update first');
+
+ // Mount second stateful
+ ops = [];
+ render(
+ <App
+ first={<Stateful name="first" ref={s1ref} />}
+ second={<Stateful name="second" ref={s2ref} />}
+ />,
+ scratch
+ );
+ expect(scratch.innerHTML).to.equal(
+ '<div>first: 1</div><div>second: 0</div><div>third: 2</div>'
+ );
+ expect(ops).to.deep.equal(
+ ['Update first', 'Mount second', 'Update third'],
+ 'mount second'
+ );
+
+ // Update second stateful
+ ops = [];
+ s1ref.current.increment();
+ s2ref.current.increment();
+ s3ref.current.increment();
+ rerender();
+ expect(scratch.innerHTML).to.equal(
+ '<div>first: 2</div><div>second: 1</div><div>third: 3</div>'
+ );
+ expect(ops).to.deep.equal(
+ ['Update first', 'Update second', 'Update third'],
+ 'update second'
+ );
+ });
+
+ it('should efficiently replace self-updating null placeholders', () => {
+ // These Nullable components replace themselves with null without the parent re-rendering
+ const [Nullable, ref] = createStatefulNullable('Nullable');
+ const [Nullable2, ref2] = createStatefulNullable('Nullable2');
+ function App() {
+ return (
+ <div>
+ <div>1</div>
+ <Nullable />
+ <div>3</div>
+ <Nullable2 />
+ </div>
+ );
+ }
+
+ render(<App />, scratch);
+ expect(scratch.innerHTML).to.equal(
+ div([div(1), div('Nullable'), div(3), div('Nullable2')])
+ );
+
+ clearLog();
+ ref2.current.toggle();
+ ref.current.toggle();
+ rerender();
+ expect(scratch.innerHTML).to.equal(div([div(1), div(3)]));
+ expect(getLog()).to.deep.equal([
+ '<div>Nullable2.remove()',
+ '<div>Nullable.remove()'
+ ]);
+
+ clearLog();
+ ref2.current.toggle();
+ ref.current.toggle();
+ rerender();
+ expect(scratch.innerHTML).to.equal(
+ div([div(1), div('Nullable'), div(3), div('Nullable2')])
+ );
+ expect(getLog()).to.deep.equal([
+ '<div>.appendChild(#text)',
+ '<div>13.appendChild(<div>Nullable2)',
+ '<div>.appendChild(#text)',
+ '<div>13Nullable2.insertBefore(<div>Nullable, <div>3)'
+ ]);
+ });
+
+ // See preactjs/preact#2350
+ it('should efficiently replace null placeholders in parent rerenders (#2350)', () => {
+ // This Nullable only changes when it's parent rerenders
+ const Nullable1 = createNullable('Nullable 1');
+ const Nullable2 = createNullable('Nullable 2');
+
+ /** @type {() => void} */
+ let toggle;
+ class App extends Component {
+ constructor(props) {
+ super(props);
+ this.state = { show: false };
+ toggle = () => this.setState({ show: !this.state.show });
+ }
+ render() {
+ return (
+ <div>
+ <div>{this.state.show.toString()}</div>
+ <Nullable1 show={this.state.show} />
+ <div>the middle</div>
+ <Nullable2 show={this.state.show} />
+ </div>
+ );
+ }
+ }
+
+ render(<App />, scratch);
+ expect(scratch.innerHTML).to.equal(div([div('false'), div('the middle')]));
+
+ clearLog();
+ toggle();
+ rerender();
+ expect(scratch.innerHTML).to.equal(
+ div([div('true'), 'Nullable 1', div('the middle'), 'Nullable 2'])
+ );
+ expect(getLog()).to.deep.equal([
+ '<div>truethe middle.insertBefore(#text, <div>the middle)',
+ '<div>trueNullable 1the middle.appendChild(#text)'
+ ]);
+
+ clearLog();
+ toggle();
+ rerender();
+ expect(scratch.innerHTML).to.equal(div([div('false'), div('the middle')]));
+ expect(getLog()).to.deep.equal([
+ '#text.remove()',
+ // '<div>falsethe middleNullable 2.appendChild(<div>the middle)',
+ '#text.remove()'
+ ]);
+ });
+});
diff --git a/preact/test/browser/refs.test.js b/preact/test/browser/refs.test.js
new file mode 100644
index 0000000..b697a4f
--- /dev/null
+++ b/preact/test/browser/refs.test.js
@@ -0,0 +1,481 @@
+import { setupRerender } from 'preact/test-utils';
+import { createElement, render, Component, createRef } from 'preact';
+import { setupScratch, teardown } from '../_util/helpers';
+
+/** @jsx createElement */
+
+// gives call count and argument errors names (otherwise sinon just uses "spy"):
+let spy = (name, ...args) => {
+ let spy = sinon.spy(...args);
+ spy.displayName = `spy('${name}')`;
+ return spy;
+};
+
+describe('refs', () => {
+ let scratch;
+ let rerender;
+
+ beforeEach(() => {
+ scratch = setupScratch();
+ rerender = setupRerender();
+ });
+
+ afterEach(() => {
+ teardown(scratch);
+ });
+
+ it('should invoke refs in render()', () => {
+ let ref = spy('ref');
+ render(<div ref={ref} />, scratch);
+ expect(ref).to.have.been.calledOnce.and.calledWith(scratch.firstChild);
+ });
+
+ it('should not call stale refs', () => {
+ let ref = spy('ref');
+ let ref2 = spy('ref2');
+ let bool = true;
+ const App = () => <div ref={bool ? ref : ref2} />;
+
+ render(<App />, scratch);
+ expect(ref).to.have.been.calledOnce.and.calledWith(scratch.firstChild);
+
+ bool = false;
+ render(<App />, scratch);
+ expect(ref).to.have.been.calledTwice.and.calledWith(null);
+ expect(ref2).to.have.been.calledOnce.and.calledWith(scratch.firstChild);
+ });
+
+ it('should support createRef', () => {
+ const r = createRef();
+ expect(r.current).to.equal(null);
+
+ render(<div ref={r} />, scratch);
+ expect(r.current).to.equalNode(scratch.firstChild);
+ });
+
+ it('should invoke refs in Component.render()', () => {
+ let outer = spy('outer'),
+ inner = spy('inner');
+ class Foo extends Component {
+ render() {
+ return (
+ <div ref={outer}>
+ <span ref={inner} />
+ </div>
+ );
+ }
+ }
+ render(<Foo />, scratch);
+
+ expect(outer).to.have.been.calledWith(scratch.firstChild);
+ expect(inner).to.have.been.calledWith(scratch.firstChild.firstChild);
+ });
+
+ it('should pass components to ref functions', () => {
+ let ref = spy('ref'),
+ instance;
+ class Foo extends Component {
+ constructor() {
+ super();
+ instance = this;
+ }
+ render() {
+ return <div />;
+ }
+ }
+ render(<Foo ref={ref} />, scratch);
+
+ expect(ref).to.have.been.calledOnce.and.calledWith(instance);
+ });
+
+ it('should have a consistent order', () => {
+ const events = [];
+ const App = () => (
+ <div ref={r => events.push('called with ' + (r && r.tagName))}>
+ <h1 ref={r => events.push('called with ' + (r && r.tagName))}>hi</h1>
+ </div>
+ );
+
+ render(<App />, scratch);
+ render(<App />, scratch);
+ expect(events.length).to.equal(6);
+ expect(events).to.deep.equal([
+ 'called with H1',
+ 'called with DIV',
+ 'called with null',
+ 'called with H1',
+ 'called with null',
+ 'called with DIV'
+ ]);
+ });
+
+ it('should pass rendered DOM from functional components to ref functions', () => {
+ let ref = spy('ref');
+
+ const Foo = () => <div />;
+
+ render(<Foo ref={ref} />, scratch);
+ expect(ref).to.have.been.calledOnce;
+
+ ref.resetHistory();
+ render(<Foo ref={ref} />, scratch);
+ expect(ref).not.to.have.been.called;
+
+ ref.resetHistory();
+ render(<span />, scratch);
+ expect(ref).to.have.been.calledOnce.and.calledWith(null);
+ });
+
+ it('should pass children to ref functions', () => {
+ let outer = spy('outer'),
+ inner = spy('inner'),
+ InnermostComponent = 'span',
+ update,
+ inst;
+ class Outer extends Component {
+ constructor() {
+ super();
+ update = () => this.forceUpdate();
+ }
+ render() {
+ return (
+ <div>
+ <Inner ref={outer} />
+ </div>
+ );
+ }
+ }
+ class Inner extends Component {
+ constructor() {
+ super();
+ inst = this;
+ }
+ render() {
+ return <InnermostComponent ref={inner} />;
+ }
+ }
+
+ render(<Outer />, scratch);
+
+ expect(outer).to.have.been.calledOnce.and.calledWith(inst);
+ expect(inner).to.have.been.calledOnce.and.calledWith(inst.base);
+
+ outer.resetHistory();
+ inner.resetHistory();
+ update();
+ rerender();
+
+ expect(outer, 're-render').not.to.have.been.called;
+ expect(inner, 're-render').not.to.have.been.called;
+
+ inner.resetHistory();
+ InnermostComponent = 'x-span';
+ update();
+ rerender();
+
+ expect(inner, 're-render swap');
+ expect(inner.firstCall, 're-render swap').to.have.been.calledWith(null);
+ expect(inner.secondCall, 're-render swap').to.have.been.calledWith(
+ inst.base
+ );
+
+ InnermostComponent = 'span';
+ outer.resetHistory();
+ inner.resetHistory();
+ render(<div />, scratch);
+
+ expect(outer, 'unrender').to.have.been.calledOnce.and.calledWith(null);
+ expect(inner, 'unrender').to.have.been.calledOnce.and.calledWith(null);
+ });
+
+ it('should pass high-order children to ref functions', () => {
+ let outer = spy('outer'),
+ inner = spy('inner'),
+ innermost = spy('innermost'),
+ InnermostComponent = 'span',
+ outerInst,
+ innerInst;
+ class Outer extends Component {
+ constructor() {
+ super();
+ outerInst = this;
+ }
+ render() {
+ return <Inner ref={inner} />;
+ }
+ }
+ class Inner extends Component {
+ constructor() {
+ super();
+ innerInst = this;
+ }
+ render() {
+ return <InnermostComponent ref={innermost} />;
+ }
+ }
+
+ render(<Outer ref={outer} />, scratch);
+
+ expect(outer, 'outer initial').to.have.been.calledOnce.and.calledWith(
+ outerInst
+ );
+ expect(inner, 'inner initial').to.have.been.calledOnce.and.calledWith(
+ innerInst
+ );
+ expect(
+ innermost,
+ 'innerMost initial'
+ ).to.have.been.calledOnce.and.calledWith(innerInst.base);
+
+ outer.resetHistory();
+ inner.resetHistory();
+ innermost.resetHistory();
+ render(<Outer ref={outer} />, scratch);
+
+ expect(outer, 'outer update').not.to.have.been.called;
+ expect(inner, 'inner update').not.to.have.been.called;
+ expect(innermost, 'innerMost update').not.to.have.been.called;
+
+ innermost.resetHistory();
+ InnermostComponent = 'x-span';
+ render(<Outer ref={outer} />, scratch);
+
+ expect(innermost, 'innerMost swap');
+ expect(innermost.firstCall, 'innerMost swap').to.have.been.calledWith(null);
+ expect(innermost.secondCall, 'innerMost swap').to.have.been.calledWith(
+ innerInst.base
+ );
+ InnermostComponent = 'span';
+
+ outer.resetHistory();
+ inner.resetHistory();
+ innermost.resetHistory();
+ render(<div />, scratch);
+
+ expect(outer, 'outer unmount').to.have.been.calledOnce.and.calledWith(null);
+ expect(inner, 'inner unmount').to.have.been.calledOnce.and.calledWith(null);
+ expect(
+ innermost,
+ 'innerMost unmount'
+ ).to.have.been.calledOnce.and.calledWith(null);
+ });
+
+ // Test for #1143
+ it('should not pass ref into component as a prop', () => {
+ let foo = spy('foo'),
+ bar = spy('bar');
+
+ class Foo extends Component {
+ render() {
+ return <div />;
+ }
+ }
+ const Bar = spy('Bar', () => <div />);
+
+ sinon.spy(Foo.prototype, 'render');
+
+ render(
+ <div>
+ <Foo ref={foo} a="a" />
+ <Bar ref={bar} b="b" />
+ </div>,
+ scratch
+ );
+
+ expect(Foo.prototype.render).to.have.been.calledWithMatch(
+ { ref: sinon.match.falsy, a: 'a' },
+ {},
+ {}
+ );
+ expect(Bar).to.have.been.calledWithMatch(
+ { b: 'b', ref: sinon.match.falsy },
+ {}
+ );
+ });
+
+ // Test for #232
+ it('should only null refs after unmount', () => {
+ let outer, inner;
+
+ class TestUnmount extends Component {
+ componentWillUnmount() {
+ expect(this).to.have.property('outer', outer);
+ expect(this).to.have.property('inner', inner);
+
+ setTimeout(() => {
+ expect(this).to.have.property('outer', null);
+ expect(this).to.have.property('inner', null);
+ });
+ }
+
+ render() {
+ return (
+ <div id="outer" ref={c => (this.outer = c)}>
+ <div id="inner" ref={c => (this.inner = c)} />
+ </div>
+ );
+ }
+ }
+
+ sinon.spy(TestUnmount.prototype, 'componentWillUnmount');
+
+ render(
+ <div>
+ <TestUnmount />
+ </div>,
+ scratch
+ );
+ outer = scratch.querySelector('#outer');
+ inner = scratch.querySelector('#inner');
+
+ expect(TestUnmount.prototype.componentWillUnmount).not.to.have.been.called;
+
+ render(<div />, scratch);
+ expect(TestUnmount.prototype.componentWillUnmount).to.have.been.calledOnce;
+ });
+
+ it('should null and re-invoke refs when swapping component root element type', () => {
+ let inst;
+
+ class App extends Component {
+ render() {
+ return (
+ <div>
+ <Child />
+ </div>
+ );
+ }
+ }
+
+ class Child extends Component {
+ constructor(props, context) {
+ super(props, context);
+ this.state = { show: false };
+ inst = this;
+ }
+ handleMount() {}
+ render(_, { show }) {
+ if (!show) return <div id="div" ref={this.handleMount} />;
+ return (
+ <span id="span" ref={this.handleMount}>
+ some test content
+ </span>
+ );
+ }
+ }
+ sinon.spy(Child.prototype, 'handleMount');
+
+ render(<App />, scratch);
+ expect(inst.handleMount).to.have.been.calledOnce.and.calledWith(
+ scratch.querySelector('#div')
+ );
+ inst.handleMount.resetHistory();
+
+ inst.setState({ show: true });
+ rerender();
+ expect(inst.handleMount).to.have.been.calledTwice;
+ expect(inst.handleMount.firstCall).to.have.been.calledWith(null);
+ expect(inst.handleMount.secondCall).to.have.been.calledWith(
+ scratch.querySelector('#span')
+ );
+ inst.handleMount.resetHistory();
+
+ inst.setState({ show: false });
+ rerender();
+ expect(inst.handleMount).to.have.been.calledTwice;
+ expect(inst.handleMount.firstCall).to.have.been.calledWith(null);
+ expect(inst.handleMount.secondCall).to.have.been.calledWith(
+ scratch.querySelector('#div')
+ );
+ });
+
+ it('should add refs to components representing DOM nodes with no attributes if they have been pre-rendered', () => {
+ // Simulate pre-render
+ let parent = document.createElement('div');
+ let child = document.createElement('div');
+ parent.appendChild(child);
+ scratch.appendChild(parent); // scratch contains: <div><div></div></div>
+
+ let ref = spy('ref');
+
+ class Wrapper extends Component {
+ render() {
+ return <div />;
+ }
+ }
+
+ render(
+ <div>
+ <Wrapper ref={c => ref(c.base)} />
+ </div>,
+ scratch
+ );
+ expect(ref).to.have.been.calledOnce.and.calledWith(
+ scratch.firstChild.firstChild
+ );
+ });
+
+ // Test for #1177
+ it('should call ref after children are rendered', done => {
+ let input;
+ function autoFocus(el) {
+ if (el) {
+ input = el;
+
+ // Chrome bug: It will somehow drop the focus event if it fires too soon.
+ // See https://stackoverflow.com/questions/17384464/
+ setTimeout(() => {
+ el.focus();
+ done();
+ }, 1);
+ }
+ }
+
+ render(<input type="text" ref={autoFocus} value="foo" />, scratch);
+ expect(input.value).to.equal('foo');
+ });
+
+ it('should correctly set nested child refs', () => {
+ const ref = createRef();
+ const App = ({ open }) =>
+ open ? (
+ <div class="open" key="open">
+ <div ref={ref} />
+ </div>
+ ) : (
+ <div class="closes" key="closed">
+ <div ref={ref} />
+ </div>
+ );
+
+ render(<App />, scratch);
+ expect(ref.current).to.not.be.null;
+
+ render(<App open />, scratch);
+ expect(ref.current).to.not.be.null;
+ });
+
+ it('should correctly call child refs for un-keyed children on re-render', () => {
+ let el = null;
+ let ref = e => {
+ el = e;
+ };
+
+ class App extends Component {
+ render({ headerVisible }) {
+ return (
+ <div>
+ {headerVisible && <div>foo</div>}
+ <div ref={ref}>bar</div>
+ </div>
+ );
+ }
+ }
+
+ render(<App headerVisible />, scratch);
+ expect(el).to.not.be.equal(null);
+
+ render(<App />, scratch);
+ expect(el).to.not.be.equal(null);
+ });
+});
diff --git a/preact/test/browser/render.test.js b/preact/test/browser/render.test.js
new file mode 100644
index 0000000..63fb0c4
--- /dev/null
+++ b/preact/test/browser/render.test.js
@@ -0,0 +1,1164 @@
+import { setupRerender } from 'preact/test-utils';
+import { createElement, render, Component, options } from 'preact';
+import {
+ setupScratch,
+ teardown,
+ getMixedArray,
+ mixedArrayHTML,
+ serializeHtml,
+ supportsDataList,
+ sortAttributes,
+ spyOnElementAttributes,
+ createEvent
+} from '../_util/helpers';
+import { clearLog, getLog, logCall } from '../_util/logCall';
+import { useState } from 'preact/hooks';
+
+/** @jsx createElement */
+
+function getAttributes(node) {
+ let attrs = {};
+ for (let i = node.attributes.length; i--; ) {
+ attrs[node.attributes[i].name] = node.attributes[i].value;
+ }
+ return attrs;
+}
+
+const isIE11 = /Trident\//.test(navigator.userAgent);
+
+describe('render()', () => {
+ let scratch, rerender;
+
+ let resetAppendChild;
+ let resetInsertBefore;
+ let resetRemoveChild;
+ let resetRemove;
+
+ beforeEach(() => {
+ scratch = setupScratch();
+ rerender = setupRerender();
+ });
+
+ afterEach(() => {
+ teardown(scratch);
+ });
+
+ 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();
+ });
+
+ it('should rerender when value from "" to 0', () => {
+ render('', scratch);
+ expect(scratch.innerHTML).to.equal('');
+
+ render(0, scratch);
+ expect(scratch.innerHTML).to.equal('0');
+ });
+
+ it('should render an empty text node given an empty string', () => {
+ render('', scratch);
+ let c = scratch.childNodes;
+ expect(c).to.have.length(1);
+ expect(c[0].data).to.equal('');
+ expect(c[0].nodeName).to.equal('#text');
+ });
+
+ it('should allow node type change with content', () => {
+ render(<span>Bad</span>, scratch);
+ render(<div>Good</div>, scratch);
+ expect(scratch.innerHTML).to.eql(`<div>Good</div>`);
+ });
+
+ it('should not render when detecting JSON-injection', () => {
+ const vnode = JSON.parse('{"type":"span","children":"Malicious"}');
+ render(vnode, scratch);
+ expect(scratch.firstChild).to.be.null;
+ });
+
+ it('should create empty nodes (<* />)', () => {
+ render(<div />, scratch);
+ expect(scratch.childNodes).to.have.length(1);
+ expect(scratch.childNodes[0].nodeName).to.equal('DIV');
+
+ scratch.parentNode.removeChild(scratch);
+ scratch = document.createElement('div');
+ (document.body || document.documentElement).appendChild(scratch);
+
+ render(<span />, scratch);
+ expect(scratch.childNodes).to.have.length(1);
+ expect(scratch.childNodes[0].nodeName).to.equal('SPAN');
+ });
+
+ it('should not throw error in IE11 with type date', () => {
+ expect(() => render(<input type="date" />, scratch)).to.not.throw();
+ });
+
+ it('should support custom tag names', () => {
+ render(<foo />, scratch);
+ expect(scratch.childNodes).to.have.length(1);
+ expect(scratch.firstChild).to.have.property('nodeName', 'FOO');
+
+ scratch.parentNode.removeChild(scratch);
+ scratch = document.createElement('div');
+ (document.body || document.documentElement).appendChild(scratch);
+
+ render(<x-bar />, scratch);
+ expect(scratch.childNodes).to.have.length(1);
+ expect(scratch.firstChild).to.have.property('nodeName', 'X-BAR');
+ });
+
+ it('should support the form attribute', () => {
+ render(
+ <div>
+ <form id="myform" />
+ <button form="myform">test</button>
+ <input form="myform" />
+ </div>,
+ scratch
+ );
+ const div = scratch.childNodes[0];
+ const form = div.childNodes[0];
+ const button = div.childNodes[1];
+ const input = div.childNodes[2];
+
+ // IE11 doesn't support the form attribute
+ if (!isIE11) {
+ expect(button).to.have.property('form', form);
+ expect(input).to.have.property('form', form);
+ }
+ });
+
+ it('should allow VNode reuse', () => {
+ let reused = <div class="reuse">Hello World!</div>;
+ render(
+ <div>
+ {reused}
+ <hr />
+ {reused}
+ </div>,
+ scratch
+ );
+ expect(serializeHtml(scratch)).to.eql(
+ `<div><div class="reuse">Hello World!</div><hr><div class="reuse">Hello World!</div></div>`
+ );
+
+ render(
+ <div>
+ <hr />
+ {reused}
+ </div>,
+ scratch
+ );
+ expect(serializeHtml(scratch)).to.eql(
+ `<div><hr><div class="reuse">Hello World!</div></div>`
+ );
+ });
+
+ it('should merge new elements when called multiple times', () => {
+ render(<div />, scratch);
+ expect(scratch.childNodes).to.have.length(1);
+ expect(scratch.firstChild).to.have.property('nodeName', 'DIV');
+ expect(scratch.innerHTML).to.equal('<div></div>');
+
+ render(<span />, scratch);
+ expect(scratch.childNodes).to.have.length(1);
+ expect(scratch.firstChild).to.have.property('nodeName', 'SPAN');
+ expect(scratch.innerHTML).to.equal('<span></span>');
+
+ render(<span class="hello">Hello!</span>, scratch);
+ expect(scratch.childNodes).to.have.length(1);
+ expect(scratch.firstChild).to.have.property('nodeName', 'SPAN');
+ expect(scratch.innerHTML).to.equal('<span class="hello">Hello!</span>');
+ });
+
+ it('should nest empty nodes', () => {
+ render(
+ <div>
+ <span />
+ <foo />
+ <x-bar />
+ </div>,
+ scratch
+ );
+
+ expect(scratch.childNodes).to.have.length(1);
+ expect(scratch.childNodes[0].nodeName).to.equal('DIV');
+
+ let c = scratch.childNodes[0].childNodes;
+ expect(c).to.have.length(3);
+ expect(c[0].nodeName).to.equal('SPAN');
+ expect(c[1].nodeName).to.equal('FOO');
+ expect(c[2].nodeName).to.equal('X-BAR');
+ });
+
+ it('should not render falsy values', () => {
+ render(
+ <div>
+ {null},{undefined},{false},{0},{NaN}
+ </div>,
+ scratch
+ );
+
+ expect(scratch.firstChild).to.have.property('innerHTML', ',,,0,NaN');
+ });
+
+ it('should not render null', () => {
+ render(null, scratch);
+ expect(scratch.innerHTML).to.equal('');
+ expect(scratch.childNodes).to.have.length(0);
+ });
+
+ it('should not render undefined', () => {
+ render(undefined, scratch);
+ expect(scratch.innerHTML).to.equal('');
+ expect(scratch.childNodes).to.have.length(0);
+ });
+
+ it('should not render boolean true', () => {
+ render(true, scratch);
+ expect(scratch.innerHTML).to.equal('');
+ expect(scratch.childNodes).to.have.length(0);
+ });
+
+ it('should not render boolean false', () => {
+ render(false, scratch);
+ expect(scratch.innerHTML).to.equal('');
+ expect(scratch.childNodes).to.have.length(0);
+ });
+
+ it('should not render children when using function children', () => {
+ render(<div>{() => {}}</div>, scratch);
+ expect(scratch.innerHTML).to.equal('<div></div>');
+ });
+
+ it('should render NaN as text content', () => {
+ render(NaN, scratch);
+ expect(scratch.innerHTML).to.equal('NaN');
+ });
+
+ it('should render numbers (0) as text content', () => {
+ render(0, scratch);
+ expect(scratch.innerHTML).to.equal('0');
+ });
+
+ it('should render numbers (42) as text content', () => {
+ render(42, scratch);
+ expect(scratch.innerHTML).to.equal('42');
+ });
+
+ it('should render bigint as text content', () => {
+ // Skip in browsers not supporting big integers
+ if (typeof BigInt === 'undefined') {
+ return;
+ }
+
+ // eslint-disable-next-line no-undef, new-cap
+ render(BigInt(4), scratch);
+ expect(scratch.innerHTML).to.equal('4');
+ });
+
+ it('should render strings as text content', () => {
+ render('Testing, huh! How is it going?', scratch);
+ expect(scratch.innerHTML).to.equal('Testing, huh! How is it going?');
+ });
+
+ it('should render arrays of mixed elements', () => {
+ render(getMixedArray(), scratch);
+ expect(scratch.innerHTML).to.equal(mixedArrayHTML);
+ });
+
+ it('should clear falsy attributes', () => {
+ render(
+ <div
+ anull="anull"
+ aundefined="aundefined"
+ afalse="afalse"
+ anan="aNaN"
+ a0="a0"
+ />,
+ scratch
+ );
+
+ render(
+ <div
+ anull={null}
+ aundefined={undefined}
+ afalse={false}
+ anan={NaN}
+ a0={0}
+ />,
+ scratch
+ );
+
+ expect(
+ getAttributes(scratch.firstChild),
+ 'from previous truthy values'
+ ).to.eql({
+ a0: '0',
+ anan: 'NaN'
+ });
+ });
+
+ it('should not render falsy attributes on hydrate', () => {
+ render(
+ <div
+ anull={null}
+ aundefined={undefined}
+ afalse={false}
+ anan={NaN}
+ a0={0}
+ />,
+ scratch
+ );
+
+ expect(getAttributes(scratch.firstChild), 'initial render').to.eql({
+ a0: '0',
+ anan: 'NaN'
+ });
+ });
+
+ it('should clear falsy input values', () => {
+ // Note: this test just demonstrates the default browser behavior
+ render(
+ <div>
+ <input value={0} />
+ <input value={false} />
+ <input value={null} />
+ <input value={undefined} />
+ </div>,
+ scratch
+ );
+
+ let root = scratch.firstChild;
+ expect(root.children[0]).to.have.property('value', '0');
+ expect(root.children[1]).to.have.property('value', 'false');
+ expect(root.children[2]).to.have.property('value', '');
+ expect(root.children[3]).to.have.property('value', '');
+ });
+
+ it('should set value inside the specified range', () => {
+ render(
+ <input type="range" value={0.5} min="0" max="1" step="0.05" />,
+ scratch
+ );
+ expect(scratch.firstChild.value).to.equal('0.5');
+ });
+
+ // IE or IE Edge will throw when attribute values don't conform to the
+ // spec. That's the correct behaviour, but bad for this test...
+ if (!/(Edge|MSIE|Trident)/.test(navigator.userAgent)) {
+ it('should not clear falsy DOM properties', () => {
+ function test(val) {
+ render(
+ <div>
+ <input value={val} />
+ <table border={val} />
+ </div>,
+ scratch
+ );
+ }
+
+ test('2');
+ test(false);
+ expect(scratch.innerHTML).to.equal(
+ '<div><input><table border="false"></table></div>',
+ 'for false'
+ );
+
+ test('3');
+ test(null);
+ expect(scratch.innerHTML).to.equal(
+ '<div><input><table border=""></table></div>',
+ 'for null'
+ );
+
+ test('4');
+ test(undefined);
+ expect(scratch.innerHTML).to.equal(
+ '<div><input><table border=""></table></div>',
+ 'for undefined'
+ );
+ });
+ }
+
+ // Test for preactjs/preact#651
+ it('should set enumerable boolean attribute', () => {
+ render(<input spellcheck={false} />, scratch);
+ expect(scratch.firstChild.spellcheck).to.equal(false);
+ });
+
+ it('should render download attribute', () => {
+ render(<a download="" />, scratch);
+ expect(scratch.firstChild.getAttribute('download')).to.equal('');
+
+ render(<a download={null} />, scratch);
+ expect(scratch.firstChild.getAttribute('download')).to.equal(null);
+ });
+
+ it('should not set tagName', () => {
+ expect(() => render(<input tagName="div" />, scratch)).not.to.throw();
+ });
+
+ it('should apply string attributes', () => {
+ render(<div foo="bar" data-foo="databar" />, scratch);
+ expect(serializeHtml(scratch)).to.equal(
+ '<div data-foo="databar" foo="bar"></div>'
+ );
+ });
+
+ it('should not serialize function props as attributes', () => {
+ render(<div click={function a() {}} ONCLICK={function b() {}} />, scratch);
+
+ let div = scratch.childNodes[0];
+ expect(div.attributes.length).to.equal(0);
+ });
+
+ it('should serialize object props as attributes', () => {
+ render(
+ <div
+ foo={{ a: 'b' }}
+ bar={{
+ toString() {
+ return 'abc';
+ }
+ }}
+ />,
+ scratch
+ );
+
+ let div = scratch.childNodes[0];
+ expect(div.attributes.length).to.equal(2);
+
+ // Normalize attribute order because it's different in various browsers
+ let normalized = {};
+ for (let i = 0; i < div.attributes.length; i++) {
+ let attr = div.attributes[i];
+ normalized[attr.name] = attr.value;
+ }
+
+ expect(normalized).to.deep.equal({
+ bar: 'abc',
+ foo: '[object Object]'
+ });
+ });
+
+ it('should apply class as String', () => {
+ render(<div class="foo" />, scratch);
+ expect(scratch.childNodes[0]).to.have.property('className', 'foo');
+ });
+
+ it('should alias className to class', () => {
+ render(<div className="bar" />, scratch);
+ expect(scratch.childNodes[0]).to.have.property('className', 'bar');
+ });
+
+ it('should support false aria-* attributes', () => {
+ render(<div aria-checked="false" />, scratch);
+ expect(scratch.firstChild.getAttribute('aria-checked')).to.equal('false');
+ });
+
+ it('should set checked attribute on custom elements without checked property', () => {
+ render(<o-checkbox checked />, scratch);
+ expect(scratch.innerHTML).to.equal(
+ '<o-checkbox checked="true"></o-checkbox>'
+ );
+ });
+
+ it('should set value attribute on custom elements without value property', () => {
+ render(<o-input value="test" />, scratch);
+ expect(scratch.innerHTML).to.equal('<o-input value="test"></o-input>');
+ });
+
+ it('should mask value on password input elements', () => {
+ render(<input value="xyz" type="password" />, scratch);
+ expect(scratch.innerHTML).to.equal('<input type="password">');
+ });
+
+ it('should unset href if null || undefined', () => {
+ render(
+ <pre>
+ <a href="#">href="#"</a>
+ <a href={undefined}>href="undefined"</a>
+ <a href={null}>href="null"</a>
+ <a href={''}>href="''"</a>
+ </pre>,
+ scratch
+ );
+
+ const links = scratch.querySelectorAll('a');
+ expect(links[0].hasAttribute('href')).to.equal(true);
+ expect(links[1].hasAttribute('href')).to.equal(false);
+ expect(links[2].hasAttribute('href')).to.equal(false);
+ expect(links[3].hasAttribute('href')).to.equal(true);
+ });
+
+ describe('dangerouslySetInnerHTML', () => {
+ it('should support dangerouslySetInnerHTML', () => {
+ let html = '<b>foo &amp; bar</b>';
+ // eslint-disable-next-line react/no-danger
+ render(<div dangerouslySetInnerHTML={{ __html: html }} />, scratch);
+
+ expect(scratch.firstChild, 'set').to.have.property('innerHTML', html);
+ expect(scratch.innerHTML).to.equal('<div>' + html + '</div>');
+
+ render(
+ <div>
+ a<strong>b</strong>
+ </div>,
+ scratch
+ );
+
+ expect(scratch, 'unset').to.have.property(
+ 'innerHTML',
+ `<div>a<strong>b</strong></div>`
+ );
+
+ // eslint-disable-next-line react/no-danger
+ render(<div dangerouslySetInnerHTML={{ __html: html }} />, scratch);
+ expect(scratch.innerHTML, 're-set').to.equal('<div>' + html + '</div>');
+ });
+
+ it('should apply proper mutation for VNodes with dangerouslySetInnerHTML attr', () => {
+ let thing;
+ class Thing extends Component {
+ constructor(props, context) {
+ super(props, context);
+ this.state = { html: this.props.html };
+ thing = this;
+ }
+ render(props, { html }) {
+ // eslint-disable-next-line react/no-danger
+ return html ? (
+ <div dangerouslySetInnerHTML={{ __html: html }} />
+ ) : (
+ <div />
+ );
+ }
+ }
+
+ render(<Thing html="<b><i>test</i></b>" />, scratch);
+ expect(scratch.innerHTML).to.equal('<div><b><i>test</i></b></div>');
+
+ thing.setState({ html: false });
+ rerender();
+ expect(scratch.innerHTML).to.equal('<div></div>');
+
+ thing.setState({ html: '<foo><bar>test</bar></foo>' });
+ rerender();
+ expect(scratch.innerHTML).to.equal(
+ '<div><foo><bar>test</bar></foo></div>'
+ );
+ });
+
+ it('should not hydrate with dangerouslySetInnerHTML', () => {
+ let html = '<b>foo &amp; bar</b>';
+ scratch.innerHTML = `<div>${html}</div>`;
+ // eslint-disable-next-line react/no-danger
+ render(<div dangerouslySetInnerHTML={{ __html: html }} />, scratch);
+
+ expect(scratch.firstChild).to.have.property('innerHTML', html);
+ expect(scratch.innerHTML).to.equal(`<div>${html}</div>`);
+ });
+
+ it('should avoid reapplying innerHTML when __html property of dangerouslySetInnerHTML attr remains unchanged', () => {
+ class Thing extends Component {
+ render() {
+ // eslint-disable-next-line react/no-danger
+ return (
+ <div dangerouslySetInnerHTML={{ __html: '<span>same</span>' }} />
+ );
+ }
+ }
+
+ let thing;
+ render(<Thing ref={r => (thing = r)} />, scratch);
+
+ let firstInnerHTMLChild = scratch.firstChild.firstChild;
+
+ // Re-render
+ thing.forceUpdate();
+
+ expect(firstInnerHTMLChild).to.equalNode(scratch.firstChild.firstChild);
+ });
+
+ it('should unmount dangerouslySetInnerHTML', () => {
+ let set;
+
+ const TextDiv = () => (
+ <div dangerouslySetInnerHTML={{ __html: '' }}>some text</div>
+ );
+
+ class App extends Component {
+ constructor(props) {
+ super(props);
+ set = this.setState.bind(this);
+ this.state = { show: true };
+ }
+
+ render() {
+ return this.state.show && <TextDiv />;
+ }
+ }
+
+ render(<App />, scratch);
+ expect(scratch.innerHTML).to.equal('<div></div>');
+
+ set({ show: false });
+ rerender();
+ expect(scratch.innerHTML).to.equal('');
+ });
+ });
+
+ it('should reconcile mutated DOM attributes', () => {
+ let check = p => render(<input type="checkbox" checked={p} />, scratch),
+ value = () => scratch.lastChild.checked,
+ setValue = p => (scratch.lastChild.checked = p);
+ check(true);
+ expect(value()).to.equal(true);
+ check(false);
+ expect(value()).to.equal(false);
+ check(true);
+ expect(value()).to.equal(true);
+ setValue(true);
+ check(false);
+ expect(value()).to.equal(false);
+ setValue(false);
+ check(true);
+ expect(value()).to.equal(true);
+ });
+
+ it('should reorder child pairs', () => {
+ render(
+ <div>
+ <a>a</a>
+ <b>b</b>
+ </div>,
+ scratch
+ );
+
+ let a = scratch.firstChild.firstChild;
+ let b = scratch.firstChild.lastChild;
+
+ expect(a).to.have.property('nodeName', 'A');
+ expect(b).to.have.property('nodeName', 'B');
+
+ render(
+ <div>
+ <b>b</b>
+ <a>a</a>
+ </div>,
+ scratch
+ );
+
+ expect(scratch.firstChild.firstChild).to.equalNode(b);
+ expect(scratch.firstChild.lastChild).to.equalNode(a);
+ });
+
+ // Discussion: https://github.com/preactjs/preact/issues/287
+ // <datalist> is not supported in Safari, even though the element
+ // constructor is present
+ if (supportsDataList()) {
+ it('should allow <input list /> to pass through as an attribute', () => {
+ render(
+ <div>
+ <input type="range" min="0" max="100" list="steplist" />
+ <datalist id="steplist">
+ <option>0</option>
+ <option>50</option>
+ <option>100</option>
+ </datalist>
+ </div>,
+ scratch
+ );
+
+ let html = scratch.firstElementChild.firstElementChild.outerHTML;
+ expect(sortAttributes(html)).to.equal(
+ sortAttributes('<input type="range" min="0" max="100" list="steplist">')
+ );
+ });
+ }
+
+ // Issue #2284
+ it('should not throw when setting size to an invalid value', () => {
+ // These values are usually used to reset the `size` attribute to its
+ // initial state.
+ expect(() => render(<input size={undefined} />, scratch)).to.not.throw();
+ expect(() => render(<input size={null} />, scratch)).to.not.throw();
+ expect(() => render(<input size={0} />, scratch)).to.not.throw();
+ });
+
+ it('should not execute append operation when child is at last', () => {
+ // See preactjs/preact#717 for discussion about the issue this addresses
+
+ let todoText = 'new todo that I should complete';
+ let input;
+ let setText;
+ let addTodo;
+
+ const ENTER = 13;
+
+ class TodoList extends Component {
+ constructor(props) {
+ super(props);
+ this.state = { todos: [], text: '' };
+ setText = this.setText = this.setText.bind(this);
+ addTodo = this.addTodo = this.addTodo.bind(this);
+ }
+ setText(e) {
+ this.setState({ text: e.target.value });
+ }
+ addTodo(e) {
+ if (e.keyCode === ENTER) {
+ let { todos, text } = this.state;
+ todos = todos.concat({ text });
+ this.setState({ todos, text: '' });
+ }
+ }
+ render() {
+ const { todos, text } = this.state;
+ return (
+ <div onKeyDown={this.addTodo}>
+ {todos.map(todo => [
+ <span>{todo.text}</span>,
+ <span>
+ {' '}
+ [ <a href="javascript:;">Delete</a> ]
+ </span>,
+ <br />
+ ])}
+ <input value={text} onInput={this.setText} ref={i => (input = i)} />
+ </div>
+ );
+ }
+ }
+
+ render(<TodoList />, scratch);
+
+ // Simulate user typing
+ input.focus();
+ input.value = todoText;
+ setText({
+ target: input
+ });
+
+ // Commit the user typing setState call
+ rerender();
+
+ // Simulate user pressing enter
+ addTodo({
+ keyCode: ENTER
+ });
+
+ // Before Preact rerenders, focus should be on the input
+ expect(document.activeElement).to.equalNode(input);
+
+ rerender();
+
+ // After Preact rerenders, focus should remain on the input
+ expect(document.activeElement).to.equalNode(input);
+ expect(scratch.innerHTML).to.contain(`<span>${todoText}</span>`);
+ });
+
+ it('should keep value of uncontrolled inputs', () => {
+ render(<input value={undefined} />, scratch);
+ scratch.firstChild.value = 'foo';
+ render(<input value={undefined} />, scratch);
+ expect(scratch.firstChild.value).to.equal('foo');
+ });
+
+ it('should keep value of uncontrolled checkboxes', () => {
+ render(<input type="checkbox" checked={undefined} />, scratch);
+ scratch.firstChild.checked = true;
+ render(<input type="checkbox" checked={undefined} />, scratch);
+ expect(scratch.firstChild.checked).to.equal(true);
+ });
+
+ // #2756
+ it('should set progress value to 0', () => {
+ render(<progress value={0} max="100" />, scratch);
+ expect(scratch.firstChild.value).to.equal(0);
+ expect(scratch.firstChild.getAttribute('value')).to.equal('0');
+ });
+
+ it('should always diff `checked` and `value` properties against the DOM', () => {
+ // See https://github.com/preactjs/preact/issues/1324
+
+ let inputs;
+ let text;
+ let checkbox;
+
+ class Inputs extends Component {
+ render() {
+ return (
+ <div>
+ <input value={'Hello'} ref={el => (text = el)} />
+ <input type="checkbox" checked ref={el => (checkbox = el)} />
+ </div>
+ );
+ }
+ }
+
+ render(<Inputs ref={x => (inputs = x)} />, scratch);
+
+ expect(text.value).to.equal('Hello');
+ expect(checkbox.checked).to.equal(true);
+
+ text.value = 'World';
+ checkbox.checked = false;
+
+ inputs.forceUpdate();
+ rerender();
+
+ expect(text.value).to.equal('Hello');
+ expect(checkbox.checked).to.equal(true);
+ });
+
+ it('should always diff `contenteditable` `innerHTML` against the DOM', () => {
+ // This tests that we do not cause any cursor jumps in contenteditable fields
+ // See https://github.com/preactjs/preact/issues/2691
+
+ function Editable() {
+ const [value, setValue] = useState('Hello');
+
+ return (
+ <div
+ contentEditable
+ dangerouslySetInnerHTML={{ __html: value }}
+ onInput={e => setValue(e.currentTarget.innerHTML)}
+ />
+ );
+ }
+
+ render(<Editable />, scratch);
+
+ let editable = scratch.querySelector('[contenteditable]');
+
+ // modify the innerHTML and set the caret to character 2 to simulate a user typing
+ editable.innerHTML = 'World';
+
+ const range = document.createRange();
+ range.selectNodeContents(editable);
+ range.setStart(editable.childNodes[0], 2);
+ range.collapse(true);
+ const sel = window.getSelection();
+ sel.removeAllRanges();
+ sel.addRange(range);
+
+ // ensure we didn't mess up setting the cursor to position 2
+ expect(window.getSelection().getRangeAt(0).startOffset).to.equal(2);
+
+ // dispatch the input event to tell preact to re-render
+ editable.dispatchEvent(createEvent('input'));
+ rerender();
+
+ // ensure innerHTML is still correct (was not an issue before) and
+ // more importantly the caret is still at character 2
+ editable = scratch.querySelector('[contenteditable]');
+ expect(editable.innerHTML).to.equal('World');
+ expect(window.getSelection().getRangeAt(0).startOffset).to.equal(2);
+ });
+
+ it('should not re-render when a component returns undefined', () => {
+ let Dialog = () => undefined;
+ let updateState;
+ class App extends Component {
+ constructor(props) {
+ super(props);
+ this.state = { name: '' };
+ updateState = () => this.setState({ name: ', friend' });
+ }
+
+ render(props, { name }) {
+ return (
+ <div>
+ <Dialog />
+ <h1 class="fade-down">Hi{name}</h1>
+ </div>
+ );
+ }
+ }
+
+ render(<App />, scratch);
+ clearLog();
+
+ updateState();
+ rerender();
+
+ // We don't log text updates
+ expect(getLog()).to.deep.equal([]);
+ });
+
+ it('should not lead to stale DOM nodes', () => {
+ let i = 0;
+ let updateApp;
+ class App extends Component {
+ render() {
+ updateApp = () => this.forceUpdate();
+ return <Parent />;
+ }
+ }
+
+ let updateParent;
+ function Parent() {
+ updateParent = () => this.forceUpdate();
+ i++;
+ return <Child i={i} />;
+ }
+
+ function Child({ i }) {
+ return i < 3 ? null : <div>foo</div>;
+ }
+
+ render(<App />, scratch);
+
+ updateApp();
+ rerender();
+ updateParent();
+ rerender();
+ updateApp();
+ rerender();
+
+ // Without a fix it would render: `<div>foo</div><div></div>`
+ expect(scratch.innerHTML).to.equal('<div>foo</div>');
+ });
+
+ // see preact/#1327
+ it('should not reuse unkeyed components', () => {
+ class X extends Component {
+ constructor() {
+ super();
+ this.state = { i: 0 };
+ }
+
+ update() {
+ this.setState(prev => ({ i: prev.i + 1 }));
+ }
+
+ componentWillUnmount() {
+ clearTimeout(this.id);
+ }
+
+ render() {
+ return <div>{this.state.i}</div>;
+ }
+ }
+
+ let ref;
+ let updateApp;
+ class App extends Component {
+ constructor() {
+ super();
+ this.state = { i: 0 };
+ updateApp = () => this.setState(prev => ({ i: prev.i ^ 1 }));
+ }
+
+ render() {
+ return (
+ <div>
+ {this.state.i === 0 && <X />}
+ <X ref={node => (ref = node)} />
+ </div>
+ );
+ }
+ }
+
+ render(<App />, scratch);
+ expect(scratch.textContent).to.equal('00');
+
+ ref.update();
+ updateApp();
+ rerender();
+ expect(scratch.textContent).to.equal('1');
+
+ updateApp();
+ rerender();
+
+ expect(scratch.textContent).to.equal('01');
+ });
+
+ it('should not cause infinite loop with referentially equal props', () => {
+ let i = 0;
+ let prevDiff = options._diff;
+ options._diff = () => {
+ if (++i > 10) {
+ options._diff = prevDiff;
+ throw new Error('Infinite loop');
+ }
+ };
+
+ function App({ children, ...rest }) {
+ return (
+ <div {...rest}>
+ <div {...rest}>{children}</div>
+ </div>
+ );
+ }
+
+ render(<App>10</App>, scratch);
+ expect(scratch.textContent).to.equal('10');
+ options._diff = prevDiff;
+ });
+
+ it('should not call options.debounceRendering unnecessarily', () => {
+ let comp;
+
+ class A extends Component {
+ constructor(props) {
+ super(props);
+ this.state = { updates: 0 };
+ comp = this;
+ }
+
+ render() {
+ return <div>{this.state.updates}</div>;
+ }
+ }
+
+ render(<A />, scratch);
+ expect(scratch.innerHTML).to.equal('<div>0</div>');
+
+ const sandbox = sinon.createSandbox();
+ try {
+ sandbox.spy(options, 'debounceRendering');
+
+ comp.setState({ updates: 1 }, () => {
+ comp.setState({ updates: 2 });
+ });
+ rerender();
+ expect(scratch.innerHTML).to.equal('<div>2</div>');
+
+ expect(options.debounceRendering).to.have.been.calledOnce;
+ } finally {
+ sandbox.restore();
+ }
+ });
+
+ it('should remove attributes on pre-existing DOM', () => {
+ const div = document.createElement('div');
+ div.setAttribute('class', 'red');
+ const span = document.createElement('span');
+ const text = document.createTextNode('Hi');
+
+ span.appendChild(text);
+ div.appendChild(span);
+ scratch.appendChild(div);
+
+ const App = () => (
+ <div>
+ <span>Bye</span>
+ </div>
+ );
+
+ render(<App />, scratch);
+ expect(serializeHtml(scratch)).to.equal('<div><span>Bye</span></div>');
+ });
+
+ it('should remove class attributes', () => {
+ const App = props => (
+ <div className={props.class}>
+ <span>Bye</span>
+ </div>
+ );
+
+ render(<App class="hi" />, scratch);
+ expect(scratch.innerHTML).to.equal(
+ '<div class="hi"><span>Bye</span></div>'
+ );
+
+ render(<App />, scratch);
+ expect(serializeHtml(scratch)).to.equal('<div><span>Bye</span></div>');
+ });
+
+ it('should not read DOM attributes on render without existing DOM', () => {
+ const attributesSpy = spyOnElementAttributes();
+ render(
+ <div id="wrapper">
+ <div id="page1">Page 1</div>
+ </div>,
+ scratch
+ );
+ expect(scratch.innerHTML).to.equal(
+ '<div id="wrapper"><div id="page1">Page 1</div></div>'
+ );
+
+ // IE11 doesn't allow modifying Element.prototype functions properly.
+ // Custom spies will never be called.
+ if (!isIE11) {
+ expect(attributesSpy.get).to.not.have.been.called;
+ }
+
+ render(
+ <div id="wrapper">
+ <div id="page2">Page 2</div>
+ </div>,
+ scratch
+ );
+ expect(scratch.innerHTML).to.equal(
+ '<div id="wrapper"><div id="page2">Page 2</div></div>'
+ );
+
+ // IE11 doesn't allow modifying Element.prototype functions properly.
+ // Custom spies will never be called.
+ if (!isIE11) {
+ expect(attributesSpy.get).to.not.have.been.called;
+ }
+ });
+
+ // #2926
+ it('should not throw when changing contentEditable to undefined or null', () => {
+ render(<p contentEditable>foo</p>, scratch);
+
+ expect(() =>
+ render(<p contentEditable={undefined}>foo</p>, scratch)
+ ).to.not.throw();
+ expect(scratch.firstChild.contentEditable).to.equal('inherit');
+
+ expect(() =>
+ render(<p contentEditable={null}>foo</p>, scratch)
+ ).to.not.throw();
+ expect(scratch.firstChild.contentEditable).to.equal('inherit');
+ });
+
+ // #2926 Part 2
+ it('should allow setting contentEditable to false', () => {
+ render(
+ <div contentEditable>
+ <span>editable</span>
+ <p contentEditable={false}>not editable</p>
+ </div>,
+ scratch
+ );
+
+ expect(scratch.firstChild.contentEditable).to.equal('true');
+ expect(scratch.querySelector('p').contentEditable).to.equal('false');
+ });
+
+ // #3060
+ it('should reset tabindex on undefined/null', () => {
+ const defaultValue = isIE11 ? 0 : -1;
+
+ render(<div tabIndex={0} />, scratch);
+ expect(scratch.firstChild.tabIndex).to.equal(0);
+ render(<div tabIndex={undefined} />, scratch);
+ expect(scratch.firstChild.tabIndex).to.equal(defaultValue);
+ render(<div tabIndex={null} />, scratch);
+ expect(scratch.firstChild.tabIndex).to.equal(defaultValue);
+
+ render(<div tabindex={0} />, scratch);
+ expect(scratch.firstChild.tabIndex).to.equal(0);
+ render(<div tabindex={undefined} />, scratch);
+ expect(scratch.firstChild.tabIndex).to.equal(defaultValue);
+ render(<div tabindex={null} />, scratch);
+ expect(scratch.firstChild.tabIndex).to.equal(defaultValue);
+ });
+});
diff --git a/preact/test/browser/replaceNode.test.js b/preact/test/browser/replaceNode.test.js
new file mode 100644
index 0000000..63cdd2d
--- /dev/null
+++ b/preact/test/browser/replaceNode.test.js
@@ -0,0 +1,239 @@
+import { createElement, render, Component } from 'preact';
+import {
+ setupScratch,
+ teardown,
+ serializeHtml,
+ sortAttributes
+} from '../_util/helpers';
+
+/** @jsx createElement */
+
+describe('replaceNode parameter in render()', () => {
+ let scratch;
+
+ /**
+ * @param {HTMLDivElement} container
+ * @returns {HTMLDivElement[]}
+ */
+ function setupABCDom(container) {
+ return ['a', 'b', 'c'].map(id => {
+ const child = document.createElement('div');
+ child.id = id;
+ container.appendChild(child);
+
+ return child;
+ });
+ }
+
+ beforeEach(() => {
+ scratch = setupScratch();
+ });
+
+ afterEach(() => {
+ teardown(scratch);
+ });
+
+ it('should use replaceNode as render root and not inject into it', () => {
+ setupABCDom(scratch);
+ const childA = scratch.querySelector('#a');
+
+ render(<div id="a">contents</div>, scratch, childA);
+ expect(scratch.querySelector('#a')).to.equalNode(childA);
+ expect(childA.innerHTML).to.equal('contents');
+ });
+
+ it('should not remove siblings of replaceNode', () => {
+ setupABCDom(scratch);
+ const childA = scratch.querySelector('#a');
+
+ render(<div id="a" />, scratch, childA);
+ expect(scratch.innerHTML).to.equal(
+ '<div id="a"></div><div id="b"></div><div id="c"></div>'
+ );
+ });
+
+ it('should notice prop changes on replaceNode', () => {
+ setupABCDom(scratch);
+ const childA = scratch.querySelector('#a');
+
+ render(<div id="a" className="b" />, scratch, childA);
+ expect(sortAttributes(String(scratch.innerHTML))).to.equal(
+ sortAttributes(
+ '<div id="a" class="b"></div><div id="b"></div><div id="c"></div>'
+ )
+ );
+ });
+
+ it('should unmount existing components', () => {
+ const unmount = sinon.spy();
+ const mount = sinon.spy();
+ class App extends Component {
+ componentDidMount() {
+ mount();
+ }
+
+ componentWillUnmount() {
+ unmount();
+ }
+
+ render() {
+ return <div>App</div>;
+ }
+ }
+
+ render(
+ <div id="a">
+ <App />
+ </div>,
+ scratch
+ );
+ expect(scratch.innerHTML).to.equal('<div id="a"><div>App</div></div>');
+ expect(mount).to.be.calledOnce;
+
+ render(<div id="a">new</div>, scratch, scratch.querySelector('#a'));
+ expect(scratch.innerHTML).to.equal('<div id="a">new</div>');
+ expect(unmount).to.be.calledOnce;
+ });
+
+ it('should unmount existing components in prerendered HTML', () => {
+ const unmount = sinon.spy();
+ const mount = sinon.spy();
+ class App extends Component {
+ componentDidMount() {
+ mount();
+ }
+
+ componentWillUnmount() {
+ unmount();
+ }
+
+ render() {
+ return <span>App</span>;
+ }
+ }
+
+ scratch.innerHTML = `<div id="child"></div>`;
+
+ const childContainer = scratch.querySelector('#child');
+
+ render(<App />, childContainer);
+ expect(serializeHtml(childContainer)).to.equal('<span>App</span>');
+ expect(mount).to.be.calledOnce;
+
+ render(<div />, scratch, scratch.firstElementChild);
+ expect(serializeHtml(scratch)).to.equal('<div id=""></div>');
+ expect(unmount).to.be.calledOnce;
+ });
+
+ it('should render multiple render roots in one parentDom', () => {
+ setupABCDom(scratch);
+ const childA = scratch.querySelector('#a');
+ const childB = scratch.querySelector('#b');
+ const childC = scratch.querySelector('#c');
+
+ const expectedA = '<div id="a">childA</div>';
+ const expectedB = '<div id="b">childB</div>';
+ const expectedC = '<div id="c">childC</div>';
+
+ render(<div id="a">childA</div>, scratch, childA);
+ render(<div id="b">childB</div>, scratch, childB);
+ render(<div id="c">childC</div>, scratch, childC);
+
+ expect(scratch.innerHTML).to.equal(`${expectedA}${expectedB}${expectedC}`);
+ });
+
+ it('should call unmount when working with replaceNode', () => {
+ const mountSpy = sinon.spy();
+ const unmountSpy = sinon.spy();
+ class MyComponent extends Component {
+ componentDidMount() {
+ mountSpy();
+ }
+ componentWillUnmount() {
+ unmountSpy();
+ }
+ render() {
+ return <div>My Component</div>;
+ }
+ }
+
+ const container = document.createElement('div');
+ scratch.appendChild(container);
+
+ render(<MyComponent />, scratch, container);
+ expect(mountSpy).to.be.calledOnce;
+
+ render(<div>Not my component</div>, document.body, container);
+ expect(unmountSpy).to.be.calledOnce;
+ });
+
+ it('should double replace', () => {
+ const container = document.createElement('div');
+ scratch.appendChild(container);
+
+ render(<div>Hello</div>, scratch, scratch.firstElementChild);
+ expect(scratch.innerHTML).to.equal('<div>Hello</div>');
+
+ render(<div>Hello</div>, scratch, scratch.firstElementChild);
+ expect(scratch.innerHTML).to.equal('<div>Hello</div>');
+ });
+
+ it('should replaceNode after rendering', () => {
+ function App({ i }) {
+ return <p>{i}</p>;
+ }
+
+ render(<App i={2} />, scratch);
+ expect(scratch.innerHTML).to.equal('<p>2</p>');
+
+ render(<App i={3} />, scratch, scratch.firstChild);
+ expect(scratch.innerHTML).to.equal('<p>3</p>');
+ });
+
+ it("shouldn't remove elements on subsequent renders with replaceNode", () => {
+ const placeholder = document.createElement('div');
+ scratch.appendChild(placeholder);
+ const App = () => (
+ <div>
+ New content
+ <button>Update</button>
+ </div>
+ );
+
+ render(<App />, scratch, placeholder);
+ expect(scratch.innerHTML).to.equal(
+ '<div>New content<button>Update</button></div>'
+ );
+
+ render(<App />, scratch, placeholder);
+ expect(scratch.innerHTML).to.equal(
+ '<div>New content<button>Update</button></div>'
+ );
+ });
+
+ it('should remove redundant elements on subsequent renders with replaceNode', () => {
+ const placeholder = document.createElement('div');
+ scratch.appendChild(placeholder);
+ const App = () => (
+ <div>
+ New content
+ <button>Update</button>
+ </div>
+ );
+
+ render(<App />, scratch, placeholder);
+ expect(scratch.innerHTML).to.equal(
+ '<div>New content<button>Update</button></div>'
+ );
+
+ placeholder.appendChild(document.createElement('span'));
+ expect(scratch.innerHTML).to.equal(
+ '<div>New content<button>Update</button><span></span></div>'
+ );
+
+ render(<App />, scratch, placeholder);
+ expect(scratch.innerHTML).to.equal(
+ '<div>New content<button>Update</button></div>'
+ );
+ });
+});
diff --git a/preact/test/browser/select.test.js b/preact/test/browser/select.test.js
new file mode 100644
index 0000000..74b08ce
--- /dev/null
+++ b/preact/test/browser/select.test.js
@@ -0,0 +1,72 @@
+import { createElement, render } from 'preact';
+import { setupScratch, teardown } from '../_util/helpers';
+
+/** @jsx createElement */
+
+describe('Select', () => {
+ let scratch;
+
+ beforeEach(() => {
+ scratch = setupScratch();
+ });
+
+ afterEach(() => {
+ teardown(scratch);
+ });
+
+ it('should set <select> value', () => {
+ function App() {
+ return (
+ <select value="B">
+ <option value="A">A</option>
+ <option value="B">B</option>
+ <option value="C">C</option>
+ </select>
+ );
+ }
+
+ render(<App />, scratch);
+ expect(scratch.firstChild.value).to.equal('B');
+ });
+
+ it('should set value with selected', () => {
+ function App() {
+ return (
+ <select>
+ <option value="A">A</option>
+ <option selected value="B">
+ B
+ </option>
+ <option value="C">C</option>
+ </select>
+ );
+ }
+
+ render(<App />, scratch);
+ expect(scratch.firstChild.value).to.equal('B');
+ });
+
+ it('should work with multiple selected', () => {
+ function App() {
+ return (
+ <select multiple>
+ <option value="A">A</option>
+ <option selected value="B">
+ B
+ </option>
+ <option selected value="C">
+ C
+ </option>
+ </select>
+ );
+ }
+
+ render(<App />, scratch);
+ Array.prototype.slice.call(scratch.firstChild.childNodes).forEach(node => {
+ if (node.value === 'B' || node.value === 'C') {
+ expect(node.selected).to.equal(true);
+ }
+ });
+ expect(scratch.firstChild.value).to.equal('B');
+ });
+});
diff --git a/preact/test/browser/spec.test.js b/preact/test/browser/spec.test.js
new file mode 100644
index 0000000..ee5f388
--- /dev/null
+++ b/preact/test/browser/spec.test.js
@@ -0,0 +1,151 @@
+import { setupRerender } from 'preact/test-utils';
+import { createElement, render, Component } from 'preact';
+import { setupScratch, teardown } from '../_util/helpers';
+
+/** @jsx createElement */
+
+describe('Component spec', () => {
+ let scratch, rerender;
+
+ beforeEach(() => {
+ scratch = setupScratch();
+ rerender = setupRerender();
+ });
+
+ afterEach(() => {
+ teardown(scratch);
+ });
+
+ describe('defaultProps', () => {
+ it('should apply default props on initial render', () => {
+ class WithDefaultProps extends Component {
+ constructor(props, context) {
+ super(props, context);
+ expect(props).to.be.deep.equal({
+ fieldA: 1,
+ fieldB: 2,
+ fieldC: 1,
+ fieldD: 2
+ });
+ }
+ render() {
+ return <div />;
+ }
+ }
+ WithDefaultProps.defaultProps = { fieldC: 1, fieldD: 1 };
+ render(<WithDefaultProps fieldA={1} fieldB={2} fieldD={2} />, scratch);
+ });
+
+ it('should apply default props on rerender', () => {
+ let doRender;
+ class Outer extends Component {
+ constructor() {
+ super();
+ this.state = { i: 1 };
+ }
+ componentDidMount() {
+ doRender = () => this.setState({ i: 2 });
+ }
+ render(props, { i }) {
+ return <WithDefaultProps fieldA={1} fieldB={i} fieldD={i} />;
+ }
+ }
+ class WithDefaultProps extends Component {
+ constructor(props, context) {
+ super(props, context);
+ this.ctor(props, context);
+ }
+ ctor() {}
+ componentWillReceiveProps() {}
+ render() {
+ return <div />;
+ }
+ }
+ WithDefaultProps.defaultProps = { fieldC: 1, fieldD: 1 };
+
+ let proto = WithDefaultProps.prototype;
+ sinon.spy(proto, 'ctor');
+ sinon.spy(proto, 'componentWillReceiveProps');
+ sinon.spy(proto, 'render');
+
+ render(<Outer />, scratch);
+ doRender();
+
+ const PROPS1 = {
+ fieldA: 1,
+ fieldB: 1,
+ fieldC: 1,
+ fieldD: 1
+ };
+
+ const PROPS2 = {
+ fieldA: 1,
+ fieldB: 2,
+ fieldC: 1,
+ fieldD: 2
+ };
+
+ expect(proto.ctor).to.have.been.calledWithMatch(PROPS1);
+ expect(proto.render).to.have.been.calledWithMatch(PROPS1);
+
+ rerender();
+
+ // expect(proto.ctor).to.have.been.calledWith(PROPS2);
+ expect(proto.componentWillReceiveProps).to.have.been.calledWithMatch(
+ PROPS2
+ );
+ expect(proto.render).to.have.been.calledWithMatch(PROPS2);
+ });
+ });
+
+ describe('forceUpdate', () => {
+ it('should force a rerender', () => {
+ let forceUpdate;
+ class ForceUpdateComponent extends Component {
+ componentWillUpdate() {}
+ componentDidMount() {
+ forceUpdate = () => this.forceUpdate();
+ }
+ render() {
+ return <div />;
+ }
+ }
+ sinon.spy(ForceUpdateComponent.prototype, 'componentWillUpdate');
+ sinon.spy(ForceUpdateComponent.prototype, 'forceUpdate');
+ render(<ForceUpdateComponent />, scratch);
+ expect(ForceUpdateComponent.prototype.componentWillUpdate).not.to.have
+ .been.called;
+
+ forceUpdate();
+ rerender();
+
+ expect(ForceUpdateComponent.prototype.componentWillUpdate).to.have.been
+ .called;
+ expect(ForceUpdateComponent.prototype.forceUpdate).to.have.been.called;
+ });
+
+ it('should add callback to renderCallbacks', () => {
+ let forceUpdate;
+ let callback = sinon.spy();
+ class ForceUpdateComponent extends Component {
+ componentDidMount() {
+ forceUpdate = () => this.forceUpdate(callback);
+ }
+ render() {
+ return <div />;
+ }
+ }
+ sinon.spy(ForceUpdateComponent.prototype, 'forceUpdate');
+ render(<ForceUpdateComponent />, scratch);
+
+ forceUpdate();
+ rerender();
+
+ expect(ForceUpdateComponent.prototype.forceUpdate).to.have.been.called;
+ expect(
+ ForceUpdateComponent.prototype.forceUpdate
+ ).to.have.been.calledWith(callback);
+ expect(callback).to.have.been.called;
+ });
+ });
+});
diff --git a/preact/test/browser/style.test.js b/preact/test/browser/style.test.js
new file mode 100644
index 0000000..a2b6afc
--- /dev/null
+++ b/preact/test/browser/style.test.js
@@ -0,0 +1,225 @@
+import { createElement, render } from 'preact';
+import { setupScratch, teardown, sortCss } from '../_util/helpers';
+
+/** @jsx createElement */
+
+describe('style attribute', () => {
+ let scratch;
+
+ beforeEach(() => {
+ scratch = setupScratch();
+ });
+
+ afterEach(() => {
+ teardown(scratch);
+ });
+
+ it('should apply style as String', () => {
+ render(<div style="top: 5px; position: relative;" />, scratch);
+ expect(scratch.childNodes[0].style.cssText).to.equal(
+ 'top: 5px; position: relative;'
+ );
+ });
+
+ it('should not call CSSStyleDeclaration.setProperty for style strings', () => {
+ render(<div style="top: 5px; position: relative;" />, scratch);
+ sinon.stub(scratch.firstChild.style, 'setProperty');
+ render(<div style="top: 10px; position: absolute;" />, scratch);
+ expect(scratch.firstChild.style.setProperty).to.not.be.called;
+ });
+
+ it('should properly switch from string styles to object styles and back', () => {
+ render(<div style="display: inline;">test</div>, scratch);
+
+ let style = scratch.firstChild.style;
+ expect(style.cssText).to.equal('display: inline;');
+
+ render(<div style={{ color: 'red' }} />, scratch);
+ expect(style.cssText).to.equal('color: red;');
+
+ render(<div style="color: blue" />, scratch);
+ expect(style.cssText).to.equal('color: blue;');
+
+ render(<div style={{ color: 'yellow' }} />, scratch);
+ expect(style.cssText).to.equal('color: yellow;');
+
+ render(<div style="display: block" />, scratch);
+ expect(style.cssText).to.equal('display: block;');
+ });
+
+ it('should serialize style objects', () => {
+ const styleObj = {
+ color: 'rgb(255, 255, 255)',
+ background: 'rgb(255, 100, 0)',
+ backgroundPosition: '10px 10px',
+ 'background-size': 'cover',
+ gridRowStart: 1,
+ padding: 5,
+ top: 100,
+ left: '100%'
+ };
+
+ render(<div style={styleObj}>test</div>, scratch);
+
+ let style = scratch.firstChild.style;
+ expect(style.color).to.equal('rgb(255, 255, 255)');
+ expect(style.background).to.contain('rgb(255, 100, 0)');
+ expect(style.backgroundPosition).to.equal('10px 10px');
+ expect(style.backgroundSize).to.equal('cover');
+ expect(style.padding).to.equal('5px');
+ expect(style.top).to.equal('100px');
+ expect(style.left).to.equal('100%');
+
+ // Only check for this in browsers that support css grids
+ if (typeof scratch.style.grid == 'string') {
+ expect(style.gridRowStart).to.equal('1');
+ }
+ });
+
+ it('should support opacity 0', () => {
+ render(<div style={{ opacity: 1 }}>Test</div>, scratch);
+ let style = scratch.firstChild.style;
+ expect(style)
+ .to.have.property('opacity')
+ .that.equals('1');
+
+ render(<div style={{ opacity: 0 }}>Test</div>, scratch);
+ style = scratch.firstChild.style;
+ expect(style)
+ .to.have.property('opacity')
+ .that.equals('0');
+ });
+
+ it('should support animation-iteration-count as number', () => {
+ render(<div style={{ animationIterationCount: 1 }}>Test</div>, scratch);
+ let style = scratch.firstChild.style;
+ expect(style)
+ .to.have.property('animationIterationCount')
+ .that.equals('1');
+
+ render(<div style={{ animationIterationCount: 2.5 }}>Test</div>, scratch);
+ style = scratch.firstChild.style;
+ expect(style)
+ .to.have.property('animationIterationCount')
+ .that.equals('2.5');
+ });
+
+ it('should replace previous style objects', () => {
+ render(<div style={{ display: 'inline' }}>test</div>, scratch);
+
+ let style = scratch.firstChild.style;
+ expect(style.cssText).to.equal('display: inline;');
+ expect(style)
+ .to.have.property('display')
+ .that.equals('inline');
+ expect(style)
+ .to.have.property('color')
+ .that.equals('');
+ expect(style.zIndex.toString()).to.equal('');
+
+ render(
+ <div style={{ color: 'rgb(0, 255, 255)', zIndex: '3' }}>test</div>,
+ scratch
+ );
+
+ style = scratch.firstChild.style;
+ expect(style.cssText).to.equal('color: rgb(0, 255, 255); z-index: 3;');
+ expect(style)
+ .to.have.property('display')
+ .that.equals('');
+ expect(style)
+ .to.have.property('color')
+ .that.equals('rgb(0, 255, 255)');
+
+ // IE stores numeric z-index values as a number
+ expect(style.zIndex.toString()).to.equal('3');
+
+ render(
+ <div style={{ color: 'rgb(0, 255, 255)', display: 'inline' }}>test</div>,
+ scratch
+ );
+
+ style = scratch.firstChild.style;
+ expect(style.cssText).to.equal('color: rgb(0, 255, 255); display: inline;');
+ expect(style)
+ .to.have.property('display')
+ .that.equals('inline');
+ expect(style)
+ .to.have.property('color')
+ .that.equals('rgb(0, 255, 255)');
+ expect(style.zIndex.toString()).to.equal('');
+ });
+
+ it('should remove old styles', () => {
+ render(<div style={{ color: 'red' }} />, scratch);
+ render(<div style={{ backgroundColor: 'blue' }} />, scratch);
+ expect(scratch.firstChild.style.color).to.equal('');
+ expect(scratch.firstChild.style.backgroundColor).to.equal('blue');
+ });
+
+ // Issue #1850
+ it('should remove empty styles', () => {
+ render(<div style={{ visibility: 'hidden' }} />, scratch);
+ expect(scratch.firstChild.style.visibility).to.equal('hidden');
+ render(<div style={{ visibility: undefined }} />, scratch);
+ expect(scratch.firstChild.style.visibility).to.equal('');
+ });
+
+ // Skip test if the currently running browser doesn't support CSS Custom Properties
+ if (window.CSS && CSS.supports('color', 'var(--fake-var)')) {
+ it('should support css custom properties', () => {
+ render(
+ <div style={{ '--foo': 'red', color: 'var(--foo)' }}>test</div>,
+ scratch
+ );
+ expect(sortCss(scratch.firstChild.style.cssText)).to.equal(
+ '--foo: red; color: var(--foo);'
+ );
+ expect(window.getComputedStyle(scratch.firstChild).color).to.equal(
+ 'rgb(255, 0, 0)'
+ );
+ });
+
+ it('should not add "px" suffix for custom properties', () => {
+ render(
+ <div style={{ '--foo': '100px', width: 'var(--foo)' }}>test</div>,
+ scratch
+ );
+ expect(sortCss(scratch.firstChild.style.cssText)).to.equal(
+ '--foo: 100px; width: var(--foo);'
+ );
+ });
+
+ it('css vars should not be transformed into dash-separated', () => {
+ render(
+ <div
+ style={{
+ '--fooBar': 1,
+ '--foo-baz': 2,
+ opacity: 'var(--fooBar)',
+ zIndex: 'var(--foo-baz)'
+ }}
+ >
+ test
+ </div>,
+ scratch
+ );
+ expect(sortCss(scratch.firstChild.style.cssText)).to.equal(
+ '--foo-baz: 2; --fooBar: 1; opacity: var(--fooBar); z-index: var(--foo-baz);'
+ );
+ });
+
+ it('should call CSSStyleDeclaration.setProperty for css vars', () => {
+ render(<div style={{ padding: '10px' }} />, scratch);
+ sinon.stub(scratch.firstChild.style, 'setProperty');
+ render(
+ <div style={{ '--foo': '10px', padding: 'var(--foo)' }} />,
+ scratch
+ );
+ expect(scratch.firstChild.style.setProperty).to.be.calledWith(
+ '--foo',
+ '10px'
+ );
+ });
+ }
+});
diff --git a/preact/test/browser/svg.test.js b/preact/test/browser/svg.test.js
new file mode 100644
index 0000000..ef2e796
--- /dev/null
+++ b/preact/test/browser/svg.test.js
@@ -0,0 +1,226 @@
+import { createElement, render } from 'preact';
+import { setupScratch, teardown, sortAttributes } from '../_util/helpers';
+
+/** @jsx createElement */
+
+describe('svg', () => {
+ let scratch;
+
+ beforeEach(() => {
+ scratch = setupScratch();
+ });
+
+ afterEach(() => {
+ teardown(scratch);
+ });
+
+ it('should render SVG to string', () => {
+ render(
+ <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"
+ />
+ </svg>,
+ scratch
+ );
+
+ let html = sortAttributes(
+ String(scratch.innerHTML).replace(
+ ' xmlns="http://www.w3.org/2000/svg"',
+ ''
+ )
+ );
+ expect(html).to.equal(
+ sortAttributes(
+ `
+ <svg viewBox="0 0 360 360">
+ <path 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" fill="black" stroke="white"></path>
+ </svg>
+ `.replace(/[\n\t]+/g, '')
+ )
+ );
+ });
+
+ it('should support svg attributes', () => {
+ const Demo = ({ url }) => (
+ <svg viewBox="0 0 360 360" xlinkHref={url}>
+ <path
+ 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"
+ fill="black"
+ stroke="white"
+ />
+ </svg>
+ );
+ render(<Demo url="www.preact.com" />, scratch);
+
+ let html = String(scratch.innerHTML).replace(
+ ' xmlns="http://www.w3.org/2000/svg"',
+ ''
+ );
+ html = sortAttributes(
+ html.replace(' xmlns:xlink="http://www.w3.org/1999/xlink"', '')
+ );
+ expect(html).to.equal(
+ sortAttributes(
+ `
+ <svg viewBox="0 0 360 360" href="www.preact.com">
+ <path 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" fill="black" stroke="white"></path>
+ </svg>
+ `.replace(/[\n\t]+/g, '')
+ )
+ );
+ render(<Demo />, scratch);
+
+ html = String(scratch.innerHTML).replace(
+ ' xmlns="http://www.w3.org/2000/svg"',
+ ''
+ );
+ html = sortAttributes(
+ html.replace(' xmlns:xlink="http://www.w3.org/1999/xlink"', '')
+ );
+ expect(html).to.equal(
+ sortAttributes(
+ `
+ <svg viewBox="0 0 360 360">
+ <path 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" fill="black" stroke="white"></path>
+ </svg>
+ `.replace(/[\n\t]+/g, '')
+ )
+ );
+ });
+
+ it('should render SVG to DOM', () => {
+ const Demo = () => (
+ <svg viewBox="0 0 360 360">
+ <path
+ 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"
+ fill="black"
+ stroke="white"
+ />
+ </svg>
+ );
+ render(<Demo />, scratch);
+
+ let html = sortAttributes(
+ String(scratch.innerHTML).replace(
+ ' xmlns="http://www.w3.org/2000/svg"',
+ ''
+ )
+ );
+ expect(html).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 with the correct namespace URI', () => {
+ render(<svg />, scratch);
+
+ let namespace = scratch.querySelector('svg').namespaceURI;
+
+ expect(namespace).to.equal('http://www.w3.org/2000/svg');
+ });
+
+ it('should use attributes for className', () => {
+ const Demo = ({ c }) => (
+ <svg viewBox="0 0 360 360" {...(c ? { class: 'foo_' + c } : {})}>
+ <path
+ class={c && 'bar_' + c}
+ 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>
+ );
+ render(<Demo c="1" />, scratch);
+ let root = scratch.firstChild;
+ sinon.spy(root, 'removeAttribute');
+ render(<Demo />, scratch);
+ expect(root.removeAttribute).to.have.been.calledOnce.and.calledWith(
+ 'class'
+ );
+
+ root.removeAttribute.restore();
+
+ render(<div />, scratch);
+ render(<Demo />, scratch);
+ root = scratch.firstChild;
+ sinon.spy(root, 'setAttribute');
+ render(<Demo c="2" />, scratch);
+ expect(root.setAttribute).to.have.been.calledOnce.and.calledWith(
+ 'class',
+ 'foo_2'
+ );
+
+ root.setAttribute.restore();
+ });
+
+ it('should still support class attribute', () => {
+ render(<svg viewBox="0 0 1 1" class="foo bar" />, scratch);
+
+ expect(scratch.innerHTML).to.contain(` class="foo bar"`);
+ });
+
+ it('should still support className attribute', () => {
+ render(<svg viewBox="0 0 1 1" className="foo bar" />, scratch);
+
+ expect(scratch.innerHTML).to.contain(` class="foo bar"`);
+ });
+
+ it('should switch back to HTML for <foreignObject>', () => {
+ render(
+ <svg>
+ <g>
+ <foreignObject>
+ <a href="#foo">test</a>
+ </foreignObject>
+ </g>
+ </svg>,
+ scratch
+ );
+
+ expect(scratch.getElementsByTagName('a'))
+ .to.have.property('0')
+ .that.is.a('HTMLAnchorElement');
+ });
+
+ it('should render foreignObject as an svg element', () => {
+ render(
+ <svg>
+ <g>
+ <foreignObject>
+ <a href="#foo">test</a>
+ </foreignObject>
+ </g>
+ </svg>,
+ scratch
+ );
+
+ expect(scratch.querySelector('foreignObject').localName).to.equal(
+ 'foreignObject'
+ );
+ });
+
+ it('should transition from DOM to SVG and back', () => {
+ render(
+ <div>
+ <svg
+ id="svg1923"
+ width="700"
+ xmlns="http://www.w3.org/2000/svg"
+ height="700"
+ >
+ <circle cy="333" cx="333" r="333" />
+ <circle cy="333" cx="333" r="333" fill="#fede58" />
+ </svg>
+ </div>,
+ scratch
+ );
+
+ expect(scratch.firstChild).to.be.an('HTMLDivElement');
+ expect(scratch.firstChild.firstChild).to.be.an('SVGSVGElement');
+ });
+});
diff --git a/preact/test/browser/toChildArray.test.js b/preact/test/browser/toChildArray.test.js
new file mode 100644
index 0000000..d1cb950
--- /dev/null
+++ b/preact/test/browser/toChildArray.test.js
@@ -0,0 +1,207 @@
+import { createElement, render, toChildArray } from 'preact';
+import {
+ setupScratch,
+ teardown,
+ getMixedArray,
+ mixedArrayHTML
+} from '../_util/helpers';
+
+/** @jsx createElement */
+
+describe('toChildArray', () => {
+ /** @type {HTMLDivElement} */
+ let scratch;
+
+ let children;
+
+ let Foo = props => {
+ children = toChildArray(props.children);
+ return <div>{children}</div>;
+ };
+
+ let Bar = () => <span>Bar</span>;
+
+ beforeEach(() => {
+ scratch = setupScratch();
+ children = undefined;
+ });
+
+ afterEach(() => {
+ teardown(scratch);
+ });
+
+ it('returns an empty array with no child', () => {
+ render(<Foo />, scratch);
+
+ expect(children).to.be.an('array');
+ expect(children).to.have.lengthOf(0);
+ expect(scratch.innerHTML).to.equal('<div></div>');
+ });
+
+ it('returns an empty array with null as a child', () => {
+ render(<Foo>{null}</Foo>, scratch);
+
+ expect(children).to.be.an('array');
+ expect(children).to.have.lengthOf(0);
+ expect(scratch.innerHTML).to.equal('<div></div>');
+ });
+
+ it('returns an empty array with false as a child', () => {
+ render(<Foo>{false}</Foo>, scratch);
+
+ expect(children).to.be.an('array');
+ expect(children).to.have.lengthOf(0);
+ expect(scratch.innerHTML).to.equal('<div></div>');
+ });
+
+ it('returns an empty array with true as a child', () => {
+ render(<Foo>{true}</Foo>, scratch);
+
+ expect(children).to.be.an('array');
+ expect(children).to.have.lengthOf(0);
+ expect(scratch.innerHTML).to.equal('<div></div>');
+ });
+
+ it('should skip a function child', () => {
+ const child = num => num.toFixed(2);
+ render(<Foo>{child}</Foo>, scratch);
+ expect(children).to.be.an('array');
+ expect(scratch.innerHTML).to.equal('<div></div>');
+ });
+
+ it('returns an array containing a VNode with a text child', () => {
+ render(<Foo>text</Foo>, scratch);
+
+ expect(children).to.be.an('array');
+ expect(children).to.have.lengthOf(1);
+ expect(children[0]).to.equal('text');
+ expect(scratch.innerHTML).to.equal('<div>text</div>');
+ });
+
+ it('returns an array containing a VNode with a number child', () => {
+ render(<Foo>{1}</Foo>, scratch);
+
+ expect(children).to.be.an('array');
+ expect(children).to.have.lengthOf(1);
+ expect(children[0]).to.equal(1);
+ expect(scratch.innerHTML).to.equal('<div>1</div>');
+ });
+
+ it('returns an array containing a VNode with a DOM node child', () => {
+ render(
+ <Foo>
+ <span />
+ </Foo>,
+ scratch
+ );
+
+ expect(children).to.be.an('array');
+ expect(children).to.have.lengthOf(1);
+ expect(children[0].type).to.equal('span');
+ expect(scratch.innerHTML).to.equal('<div><span></span></div>');
+ });
+
+ it('returns an array containing a VNode with a Component child', () => {
+ render(
+ <Foo>
+ <Bar />
+ </Foo>,
+ scratch
+ );
+
+ expect(children).to.be.an('array');
+ expect(children).to.have.lengthOf(1);
+ expect(children[0].type).to.equal(Bar);
+ expect(scratch.innerHTML).to.equal('<div><span>Bar</span></div>');
+ });
+
+ it('returns an array with multiple children', () => {
+ render(
+ <Foo>
+ 0<span />
+ <input />
+ <div />1
+ </Foo>,
+ scratch
+ );
+
+ expect(children).to.be.an('array');
+ expect(children[0]).to.equal('0');
+ expect(children[1].type).to.equal('span');
+ expect(children[2].type).to.equal('input');
+ expect(children[3].type).to.equal('div');
+ expect(children[4]).to.equal('1');
+ expect(scratch.innerHTML).to.equal(
+ `<div>0<span></span><input><div></div>1</div>`
+ );
+ });
+
+ it('returns an array with non-renderables removed with a mixed array as children', () => {
+ const mixedArray = getMixedArray();
+ render(<Foo>{mixedArray}</Foo>, scratch);
+
+ expect(children).to.be.an('array');
+ expect(children).to.have.lengthOf(8); // Length of flattened mixedArray with non-renderables removed
+ expect(scratch.innerHTML).to.equal(`<div>${mixedArrayHTML}</div>`);
+
+ function filterAndReduceChildren(acc, child) {
+ if (Array.isArray(child)) {
+ return child.reduce(filterAndReduceChildren, acc);
+ }
+
+ if (
+ child != null &&
+ typeof child != 'boolean' &&
+ typeof child != 'function'
+ ) {
+ acc.push(child);
+ }
+
+ return acc;
+ }
+
+ let renderableArray = filterAndReduceChildren([], mixedArray);
+
+ expect(children).to.have.lengthOf(renderableArray.length);
+
+ for (let i = 0; i < renderableArray.length; i++) {
+ let originalChild = renderableArray[i];
+ let actualChild = children[i];
+
+ if (
+ typeof originalChild == 'string' ||
+ typeof originalChild == 'number'
+ ) {
+ expect(actualChild).to.equal(originalChild);
+ } else {
+ expect(actualChild.type).to.equal(originalChild.type);
+ }
+ }
+ });
+
+ it('flattens sibling and nested arrays', () => {
+ const list1 = [0, 1];
+ const list2 = [2, 3];
+ const list3 = [4, 5];
+ const list4 = [6, 7];
+ const list5 = [8, 9];
+
+ const flatList = [...list1, ...list2, ...list3, ...list4, ...list5];
+
+ render(
+ <Foo>
+ {[list1, list2]}
+ {[list3, list4]}
+ {list5}
+ </Foo>,
+ scratch
+ );
+
+ expect(children).to.be.an('array');
+ expect(scratch.innerHTML).to.equal('<div>0123456789</div>');
+
+ for (let i = 0; i < flatList.length; i++) {
+ expect(children[i]).to.equal(flatList[i]);
+ }
+ });
+});
diff --git a/preact/test/extensions.d.ts b/preact/test/extensions.d.ts
new file mode 100644
index 0000000..c810c3b
--- /dev/null
+++ b/preact/test/extensions.d.ts
@@ -0,0 +1,5 @@
+declare module Chai {
+ export interface Assertion {
+ equalNode(node: Node | null, message?: string): void;
+ }
+}
diff --git a/preact/test/fixtures/preact.js b/preact/test/fixtures/preact.js
new file mode 100644
index 0000000..c76e635
--- /dev/null
+++ b/preact/test/fixtures/preact.js
@@ -0,0 +1,626 @@
+!(function() {
+ 'use strict';
+ function h(nodeName, attributes) {
+ var lastSimple,
+ child,
+ simple,
+ i,
+ children = EMPTY_CHILDREN;
+ for (i = arguments.length; i-- > 2; ) stack.push(arguments[i]);
+ if (attributes && null != attributes.children) {
+ if (!stack.length) stack.push(attributes.children);
+ delete attributes.children;
+ }
+ while (stack.length)
+ if ((child = stack.pop()) && void 0 !== child.pop)
+ for (i = child.length; i--; ) stack.push(child[i]);
+ else {
+ if ('boolean' == typeof child) child = null;
+ if ((simple = 'function' != typeof nodeName))
+ if (null == child) child = '';
+ else if ('number' == typeof child) child = String(child);
+ else if ('string' != typeof child) simple = !1;
+ if (simple && lastSimple) children[children.length - 1] += child;
+ else if (children === EMPTY_CHILDREN) children = [child];
+ else children.push(child);
+ lastSimple = simple;
+ }
+ var p = new VNode();
+ p.nodeName = nodeName;
+ p.children = children;
+ p.attributes = null == attributes ? void 0 : attributes;
+ p.key = null == attributes ? void 0 : attributes.key;
+ if (void 0 !== options.vnode) options.vnode(p);
+ return p;
+ }
+ function extend(obj, props) {
+ for (var i in props) obj[i] = props[i];
+ return obj;
+ }
+ function applyRef(ref, value) {
+ if ('function' == typeof ref) ref(value);
+ else if (null != ref) ref.current = value;
+ }
+ function cloneElement(vnode, props) {
+ return h(
+ vnode.nodeName,
+ extend(extend({}, vnode.attributes), props),
+ arguments.length > 2 ? [].slice.call(arguments, 2) : vnode.children
+ );
+ }
+ function enqueueRender(component) {
+ if (!component.__d && (component.__d = !0) && 1 == items.push(component))
+ (options.debounceRendering || defer)(rerender);
+ }
+ function rerender() {
+ var p;
+ while ((p = items.pop())) if (p.__d) renderComponent(p);
+ }
+ function isSameNodeType(node, vnode, hydrating) {
+ if ('string' == typeof vnode || 'number' == typeof vnode)
+ return void 0 !== node.splitText;
+ if ('string' == typeof vnode.nodeName)
+ return !node._componentConstructor && isNamedNode(node, vnode.nodeName);
+ else return hydrating || node._componentConstructor === vnode.nodeName;
+ }
+ function isNamedNode(node, nodeName) {
+ return (
+ node.__n === nodeName ||
+ node.nodeName.toLowerCase() === nodeName.toLowerCase()
+ );
+ }
+ function getNodeProps(vnode) {
+ var props = extend({}, vnode.attributes);
+ props.children = vnode.children;
+ var defaultProps = vnode.nodeName.defaultProps;
+ if (void 0 !== defaultProps)
+ for (var i in defaultProps)
+ if (void 0 === props[i]) props[i] = defaultProps[i];
+ return props;
+ }
+ function createNode(nodeName, isSvg) {
+ var node = isSvg
+ ? document.createElementNS('http://www.w3.org/2000/svg', nodeName)
+ : document.createElement(nodeName);
+ node.__n = nodeName;
+ return node;
+ }
+ function removeNode(node) {
+ var parentNode = node.parentNode;
+ if (parentNode) parentNode.removeChild(node);
+ }
+ function setAccessor(node, name, old, value, isSvg) {
+ if ('className' === name) name = 'class';
+ if ('key' === name);
+ else if ('ref' === name) {
+ applyRef(old, null);
+ applyRef(value, node);
+ } else if ('class' === name && !isSvg) node.className = value || '';
+ else if ('style' === name) {
+ if (!value || 'string' == typeof value || 'string' == typeof old)
+ node.style.cssText = value || '';
+ if (value && 'object' == typeof value) {
+ if ('string' != typeof old)
+ for (var i in old) if (!(i in value)) node.style[i] = '';
+ for (var i in value)
+ node.style[i] =
+ 'number' == typeof value[i] && !1 === IS_NON_DIMENSIONAL.test(i)
+ ? value[i] + 'px'
+ : value[i];
+ }
+ } else if ('dangerouslySetInnerHTML' === name) {
+ if (value) node.innerHTML = value.__html || '';
+ } else if ('o' == name[0] && 'n' == name[1]) {
+ var useCapture = name !== (name = name.replace(/Capture$/, ''));
+ name = name.toLowerCase().substring(2);
+ if (value) {
+ if (!old) node.addEventListener(name, eventProxy, useCapture);
+ } else node.removeEventListener(name, eventProxy, useCapture);
+ (node.__l || (node.__l = {}))[name] = value;
+ } else if ('list' !== name && 'type' !== name && !isSvg && name in node) {
+ try {
+ node[name] = null == value ? '' : value;
+ } catch (e) {}
+ if ((null == value || !1 === value) && 'spellcheck' != name)
+ node.removeAttribute(name);
+ } else {
+ var ns = isSvg && name !== (name = name.replace(/^xlink:?/, ''));
+ if (null == value || !1 === value)
+ if (ns)
+ node.removeAttributeNS(
+ 'http://www.w3.org/1999/xlink',
+ name.toLowerCase()
+ );
+ else node.removeAttribute(name);
+ else if ('function' != typeof value)
+ if (ns)
+ node.setAttributeNS(
+ 'http://www.w3.org/1999/xlink',
+ name.toLowerCase(),
+ value
+ );
+ else node.setAttribute(name, value);
+ }
+ }
+ function eventProxy(e) {
+ return this.__l[e.type]((options.event && options.event(e)) || e);
+ }
+ function flushMounts() {
+ var c;
+ while ((c = mounts.pop())) {
+ if (options.afterMount) options.afterMount(c);
+ if (c.componentDidMount) c.componentDidMount();
+ }
+ }
+ function diff(dom, vnode, context, mountAll, parent, componentRoot) {
+ if (!diffLevel++) {
+ isSvgMode = null != parent && void 0 !== parent.ownerSVGElement;
+ hydrating = null != dom && !('__preactattr_' in dom);
+ }
+ var ret = idiff(dom, vnode, context, mountAll, componentRoot);
+ if (parent && ret.parentNode !== parent) parent.appendChild(ret);
+ if (!--diffLevel) {
+ hydrating = !1;
+ if (!componentRoot) flushMounts();
+ }
+ return ret;
+ }
+ function idiff(dom, vnode, context, mountAll, componentRoot) {
+ var out = dom,
+ prevSvgMode = isSvgMode;
+ if (null == vnode || 'boolean' == typeof vnode) vnode = '';
+ if ('string' == typeof vnode || 'number' == typeof vnode) {
+ if (
+ dom &&
+ void 0 !== dom.splitText &&
+ dom.parentNode &&
+ (!dom._component || componentRoot)
+ ) {
+ if (dom.nodeValue != vnode) dom.nodeValue = vnode;
+ } else {
+ out = document.createTextNode(vnode);
+ if (dom) {
+ if (dom.parentNode) dom.parentNode.replaceChild(out, dom);
+ recollectNodeTree(dom, !0);
+ }
+ }
+ out.__preactattr_ = !0;
+ return out;
+ }
+ var vnodeName = vnode.nodeName;
+ if ('function' == typeof vnodeName)
+ return buildComponentFromVNode(dom, vnode, context, mountAll);
+ isSvgMode =
+ 'svg' === vnodeName ? !0 : 'foreignObject' === vnodeName ? !1 : isSvgMode;
+ vnodeName = String(vnodeName);
+ if (!dom || !isNamedNode(dom, vnodeName)) {
+ out = createNode(vnodeName, isSvgMode);
+ if (dom) {
+ while (dom.firstChild) out.appendChild(dom.firstChild);
+ if (dom.parentNode) dom.parentNode.replaceChild(out, dom);
+ recollectNodeTree(dom, !0);
+ }
+ }
+ var fc = out.firstChild,
+ props = out.__preactattr_,
+ vchildren = vnode.children;
+ if (null == props) {
+ props = out.__preactattr_ = {};
+ for (var a = out.attributes, i = a.length; i--; )
+ props[a[i].name] = a[i].value;
+ }
+ if (
+ !hydrating &&
+ vchildren &&
+ 1 === vchildren.length &&
+ 'string' == typeof vchildren[0] &&
+ null != fc &&
+ void 0 !== fc.splitText &&
+ null == fc.nextSibling
+ ) {
+ if (fc.nodeValue != vchildren[0]) fc.nodeValue = vchildren[0];
+ } else if ((vchildren && vchildren.length) || null != fc)
+ innerDiffNode(
+ out,
+ vchildren,
+ context,
+ mountAll,
+ hydrating || null != props.dangerouslySetInnerHTML
+ );
+ diffAttributes(out, vnode.attributes, props);
+ isSvgMode = prevSvgMode;
+ return out;
+ }
+ function innerDiffNode(dom, vchildren, context, mountAll, isHydrating) {
+ var j,
+ c,
+ f,
+ vchild,
+ child,
+ originalChildren = dom.childNodes,
+ children = [],
+ keyed = {},
+ keyedLen = 0,
+ min = 0,
+ len = originalChildren.length,
+ childrenLen = 0,
+ vlen = vchildren ? vchildren.length : 0;
+ if (0 !== len)
+ for (var i = 0; i < len; i++) {
+ var _child = originalChildren[i],
+ props = _child.__preactattr_,
+ key =
+ vlen && props
+ ? _child._component
+ ? _child._component.__k
+ : props.key
+ : null;
+ if (null != key) {
+ keyedLen++;
+ keyed[key] = _child;
+ } else if (
+ props ||
+ (void 0 !== _child.splitText
+ ? isHydrating
+ ? _child.nodeValue.trim()
+ : !0
+ : isHydrating)
+ )
+ children[childrenLen++] = _child;
+ }
+ if (0 !== vlen)
+ for (var i = 0; i < vlen; i++) {
+ vchild = vchildren[i];
+ child = null;
+ var key = vchild.key;
+ if (null != key) {
+ if (keyedLen && void 0 !== keyed[key]) {
+ child = keyed[key];
+ keyed[key] = void 0;
+ keyedLen--;
+ }
+ } else if (min < childrenLen)
+ for (j = min; j < childrenLen; j++)
+ if (
+ void 0 !== children[j] &&
+ isSameNodeType((c = children[j]), vchild, isHydrating)
+ ) {
+ child = c;
+ children[j] = void 0;
+ if (j === childrenLen - 1) childrenLen--;
+ if (j === min) min++;
+ break;
+ }
+ child = idiff(child, vchild, context, mountAll);
+ f = originalChildren[i];
+ if (child && child !== dom && child !== f)
+ if (null == f) dom.appendChild(child);
+ else if (child === f.nextSibling) removeNode(f);
+ else dom.insertBefore(child, f);
+ }
+ if (keyedLen)
+ for (var i in keyed)
+ if (void 0 !== keyed[i]) recollectNodeTree(keyed[i], !1);
+ while (min <= childrenLen)
+ if (void 0 !== (child = children[childrenLen--]))
+ recollectNodeTree(child, !1);
+ }
+ function recollectNodeTree(node, unmountOnly) {
+ var component = node._component;
+ if (component) unmountComponent(component);
+ else {
+ if (null != node.__preactattr_) applyRef(node.__preactattr_.ref, null);
+ if (!1 === unmountOnly || null == node.__preactattr_) removeNode(node);
+ removeChildren(node);
+ }
+ }
+ function removeChildren(node) {
+ node = node.lastChild;
+ while (node) {
+ var next = node.previousSibling;
+ recollectNodeTree(node, !0);
+ node = next;
+ }
+ }
+ function diffAttributes(dom, attrs, old) {
+ var name;
+ for (name in old)
+ if ((!attrs || null == attrs[name]) && null != old[name])
+ setAccessor(dom, name, old[name], (old[name] = void 0), isSvgMode);
+ for (name in attrs)
+ if (
+ !(
+ 'children' === name ||
+ 'innerHTML' === name ||
+ (name in old &&
+ attrs[name] ===
+ ('value' === name || 'checked' === name ? dom[name] : old[name]))
+ )
+ )
+ setAccessor(dom, name, old[name], (old[name] = attrs[name]), isSvgMode);
+ }
+ function createComponent(Ctor, props, context) {
+ var inst,
+ i = recyclerComponents.length;
+ if (Ctor.prototype && Ctor.prototype.render) {
+ inst = new Ctor(props, context);
+ Component.call(inst, props, context);
+ } else {
+ inst = new Component(props, context);
+ inst.constructor = Ctor;
+ inst.render = doRender;
+ }
+ while (i--)
+ if (recyclerComponents[i].constructor === Ctor) {
+ inst.__b = recyclerComponents[i].__b;
+ recyclerComponents.splice(i, 1);
+ return inst;
+ }
+ return inst;
+ }
+ function doRender(props, state, context) {
+ return this.constructor(props, context);
+ }
+ function setComponentProps(component, props, renderMode, context, mountAll) {
+ if (!component.__x) {
+ component.__x = !0;
+ component.__r = props.ref;
+ component.__k = props.key;
+ delete props.ref;
+ delete props.key;
+ if (void 0 === component.constructor.getDerivedStateFromProps)
+ if (!component.base || mountAll) {
+ if (component.componentWillMount) component.componentWillMount();
+ } else if (component.componentWillReceiveProps)
+ component.componentWillReceiveProps(props, context);
+ if (context && context !== component.context) {
+ if (!component.__c) component.__c = component.context;
+ component.context = context;
+ }
+ if (!component.__p) component.__p = component.props;
+ component.props = props;
+ component.__x = !1;
+ if (0 !== renderMode)
+ if (
+ 1 === renderMode ||
+ !1 !== options.syncComponentUpdates ||
+ !component.base
+ )
+ renderComponent(component, 1, mountAll);
+ else enqueueRender(component);
+ applyRef(component.__r, component);
+ }
+ }
+ function renderComponent(component, renderMode, mountAll, isChild) {
+ if (!component.__x) {
+ var rendered,
+ inst,
+ cbase,
+ props = component.props,
+ state = component.state,
+ context = component.context,
+ previousProps = component.__p || props,
+ previousState = component.__s || state,
+ previousContext = component.__c || context,
+ isUpdate = component.base,
+ nextBase = component.__b,
+ initialBase = isUpdate || nextBase,
+ initialChildComponent = component._component,
+ skip = !1,
+ snapshot = previousContext;
+ if (component.constructor.getDerivedStateFromProps) {
+ state = extend(
+ extend({}, state),
+ component.constructor.getDerivedStateFromProps(props, state)
+ );
+ component.state = state;
+ }
+ if (isUpdate) {
+ component.props = previousProps;
+ component.state = previousState;
+ component.context = previousContext;
+ if (
+ 2 !== renderMode &&
+ component.shouldComponentUpdate &&
+ !1 === component.shouldComponentUpdate(props, state, context)
+ )
+ skip = !0;
+ else if (component.componentWillUpdate)
+ component.componentWillUpdate(props, state, context);
+ component.props = props;
+ component.state = state;
+ component.context = context;
+ }
+ component.__p = component.__s = component.__c = component.__b = null;
+ component.__d = !1;
+ if (!skip) {
+ rendered = component.render(props, state, context);
+ if (component.getChildContext)
+ context = extend(extend({}, context), component.getChildContext());
+ if (isUpdate && component.getSnapshotBeforeUpdate)
+ snapshot = component.getSnapshotBeforeUpdate(
+ previousProps,
+ previousState
+ );
+ var toUnmount,
+ base,
+ childComponent = rendered && rendered.nodeName;
+ if ('function' == typeof childComponent) {
+ var childProps = getNodeProps(rendered);
+ inst = initialChildComponent;
+ if (
+ inst &&
+ inst.constructor === childComponent &&
+ childProps.key == inst.__k
+ )
+ setComponentProps(inst, childProps, 1, context, !1);
+ else {
+ toUnmount = inst;
+ component._component = inst = createComponent(
+ childComponent,
+ childProps,
+ context
+ );
+ inst.__b = inst.__b || nextBase;
+ inst.__u = component;
+ setComponentProps(inst, childProps, 0, context, !1);
+ renderComponent(inst, 1, mountAll, !0);
+ }
+ base = inst.base;
+ } else {
+ cbase = initialBase;
+ toUnmount = initialChildComponent;
+ if (toUnmount) cbase = component._component = null;
+ if (initialBase || 1 === renderMode) {
+ if (cbase) cbase._component = null;
+ base = diff(
+ cbase,
+ rendered,
+ context,
+ mountAll || !isUpdate,
+ initialBase && initialBase.parentNode,
+ !0
+ );
+ }
+ }
+ if (
+ initialBase &&
+ base !== initialBase &&
+ inst !== initialChildComponent
+ ) {
+ var baseParent = initialBase.parentNode;
+ if (baseParent && base !== baseParent) {
+ baseParent.replaceChild(base, initialBase);
+ if (!toUnmount) {
+ initialBase._component = null;
+ recollectNodeTree(initialBase, !1);
+ }
+ }
+ }
+ if (toUnmount) unmountComponent(toUnmount);
+ component.base = base;
+ if (base && !isChild) {
+ var componentRef = component,
+ t = component;
+ while ((t = t.__u)) (componentRef = t).base = base;
+ base._component = componentRef;
+ base._componentConstructor = componentRef.constructor;
+ }
+ }
+ if (!isUpdate || mountAll) mounts.unshift(component);
+ else if (!skip) {
+ if (component.componentDidUpdate)
+ component.componentDidUpdate(previousProps, previousState, snapshot);
+ if (options.afterUpdate) options.afterUpdate(component);
+ }
+ while (component.__h.length) component.__h.pop().call(component);
+ if (!diffLevel && !isChild) flushMounts();
+ }
+ }
+ function buildComponentFromVNode(dom, vnode, context, mountAll) {
+ var c = dom && dom._component,
+ originalComponent = c,
+ oldDom = dom,
+ isDirectOwner = c && dom._componentConstructor === vnode.nodeName,
+ isOwner = isDirectOwner,
+ props = getNodeProps(vnode);
+ while (c && !isOwner && (c = c.__u))
+ isOwner = c.constructor === vnode.nodeName;
+ if (c && isOwner && (!mountAll || c._component)) {
+ setComponentProps(c, props, 3, context, mountAll);
+ dom = c.base;
+ } else {
+ if (originalComponent && !isDirectOwner) {
+ unmountComponent(originalComponent);
+ dom = oldDom = null;
+ }
+ c = createComponent(vnode.nodeName, props, context);
+ if (dom && !c.__b) {
+ c.__b = dom;
+ oldDom = null;
+ }
+ setComponentProps(c, props, 1, context, mountAll);
+ dom = c.base;
+ if (oldDom && dom !== oldDom) {
+ oldDom._component = null;
+ recollectNodeTree(oldDom, !1);
+ }
+ }
+ return dom;
+ }
+ function unmountComponent(component) {
+ if (options.beforeUnmount) options.beforeUnmount(component);
+ var base = component.base;
+ component.__x = !0;
+ if (component.componentWillUnmount) component.componentWillUnmount();
+ component.base = null;
+ var inner = component._component;
+ if (inner) unmountComponent(inner);
+ else if (base) {
+ if (base.__preactattr_ && base.__preactattr_.ref)
+ base.__preactattr_.ref(null);
+ component.__b = base;
+ removeNode(base);
+ recyclerComponents.push(component);
+ removeChildren(base);
+ }
+ applyRef(component.__r, null);
+ }
+ function Component(props, context) {
+ this.__d = !0;
+ this.context = context;
+ this.props = props;
+ this.state = this.state || {};
+ this.__h = [];
+ }
+ function render(vnode, parent, merge) {
+ return diff(merge, vnode, {}, !1, parent, !1);
+ }
+ function createRef() {
+ return {};
+ }
+ var VNode = function() {};
+ var options = {};
+ var stack = [];
+ var EMPTY_CHILDREN = [];
+ var defer =
+ 'function' == typeof Promise
+ ? Promise.resolve().then.bind(Promise.resolve())
+ : setTimeout;
+ var IS_NON_DIMENSIONAL = /acit|ex(?:s|g|n|p|$)|rph|ows|mnc|ntw|ine[ch]|zoo|^ord/i;
+ var items = [];
+ var mounts = [];
+ var diffLevel = 0;
+ var isSvgMode = !1;
+ var hydrating = !1;
+ var recyclerComponents = [];
+ extend(Component.prototype, {
+ setState: function(state, callback) {
+ if (!this.__s) this.__s = this.state;
+ this.state = extend(
+ extend({}, this.state),
+ 'function' == typeof state ? state(this.state, this.props) : state
+ );
+ if (callback) this.__h.push(callback);
+ enqueueRender(this);
+ },
+ forceUpdate: function(callback) {
+ if (callback) this.__h.push(callback);
+ renderComponent(this, 2);
+ },
+ render: function() {}
+ });
+ var preact = {
+ h: h,
+ createElement: h,
+ cloneElement: cloneElement,
+ createRef: createRef,
+ Component: Component,
+ render: render,
+ rerender: rerender,
+ options: options
+ };
+ if ('undefined' != typeof module) module.exports = preact;
+ else self.preact = preact;
+})();
+//# sourceMappingURL=preact.js.map
diff --git a/preact/test/node/index.test.js b/preact/test/node/index.test.js
new file mode 100644
index 0000000..fef7547
--- /dev/null
+++ b/preact/test/node/index.test.js
@@ -0,0 +1,15 @@
+import { expect } from 'chai';
+import * as preact from '../../';
+
+describe('build artifact', () => {
+ // #1075 Check that the build artifact has the correct exports
+ it('should have exported properties', () => {
+ expect(preact).to.be.an('object');
+ expect(preact).to.have.property('createElement');
+ expect(preact).to.have.property('h');
+ expect(preact).to.have.property('Component');
+ expect(preact).to.have.property('render');
+ expect(preact).to.have.property('hydrate');
+ // expect(preact).to.have.property('options');
+ });
+});
diff --git a/preact/test/polyfills.js b/preact/test/polyfills.js
new file mode 100644
index 0000000..e2f542b
--- /dev/null
+++ b/preact/test/polyfills.js
@@ -0,0 +1,260 @@
+// ES2015 APIs used by developer tools integration
+import 'core-js/es6/map';
+import 'core-js/es6/promise';
+import 'core-js/fn/array/fill';
+import 'core-js/fn/array/from';
+import 'core-js/fn/array/find';
+import 'core-js/fn/array/includes';
+import 'core-js/fn/string/includes';
+import 'core-js/fn/object/assign';
+import 'core-js/fn/string/starts-with';
+import 'core-js/fn/string/code-point-at';
+import 'core-js/fn/string/from-code-point';
+import 'core-js/fn/string/repeat';
+import * as kl from 'kolorist';
+
+// Something that's loaded before this file polyfills Symbol object.
+// We need to verify that it works in IE without that.
+if (/Trident/.test(window.navigator.userAgent)) {
+ window.Symbol = undefined;
+}
+
+// Fix Function#name on browsers that do not support it (IE).
+// Taken from: https://stackoverflow.com/a/17056530/755391
+if (!function f() {}.name) {
+ Object.defineProperty(Function.prototype, 'name', {
+ get() {
+ let name = (this.toString().match(/^function\s*([^\s(]+)/) || [])[1];
+ // For better performance only parse once, and then cache the
+ // result through a new accessor for repeated access.
+ Object.defineProperty(this, 'name', { value: name });
+ return name;
+ }
+ });
+}
+
+/* global chai */
+chai.use((chai, util) => {
+ const Assertion = chai.Assertion;
+
+ Assertion.addMethod('equalNode', function(expectedNode, message) {
+ const obj = this._obj;
+ message = message || 'equalNode';
+
+ if (expectedNode == null) {
+ this.assert(
+ obj == null,
+ `${message}: expected node to "== null" but got #{act} instead.`,
+ `${message}: expected node to not "!= null".`,
+ expectedNode,
+ obj
+ );
+ } else {
+ new Assertion(obj).to.be.instanceof(Node, message);
+ this.assert(
+ obj.tagName === expectedNode.tagName,
+ `${message}: expected node to have tagName #{exp} but got #{act} instead.`,
+ `${message}: expected node to not have tagName #{act}.`,
+ expectedNode.tagName,
+ obj.tagName
+ );
+ this.assert(
+ obj === expectedNode,
+ `${message}: expected #{this} to be #{exp} but got #{act}`,
+ `${message}: expected # {this} not to be #{exp}`,
+ expectedNode,
+ obj
+ );
+ }
+ });
+});
+
+//
+// The following code overwrites karma's internal logging feature to
+// support a much prettier and humand readable represantation of
+// console logs in our terminal. This includes indentation, coloring
+// and support for Map and Set objects.
+//
+function patchConsole(method) {
+ const original = window.console[method];
+ window.console[method] = (...args) => {
+ // @ts-ignore
+ // eslint-disable-next-line no-undef
+ __karma__.log(method, ['__LOG_CUSTOM:' + serializeConsoleArgs(args)]);
+ original.apply(window.console, args);
+ };
+}
+
+patchConsole('log');
+patchConsole('warn');
+patchConsole('error');
+patchConsole('info');
+
+/**
+ * @param {any[]} args
+ * @returns {[string]}
+ */
+function serializeConsoleArgs(args) {
+ const flat = args.map(arg => serialize(arg, 'flat', 0, new Set()));
+ // We don't have access to the users terminal width, so we'll try to
+ // format everything into one line if possible and assume a terminal
+ // width of 80 chars
+ if (kl.stripColors(flat.join(', ')).length <= 80) {
+ return [flat.join(', ')];
+ }
+
+ const serialized = args.map(arg => serialize(arg, 'default', 0, new Set()));
+ return ['\n' + serialized.join(',\n') + '\n'];
+}
+
+/**
+ * @param {number} n
+ * @returns {string}
+ */
+function applyIndent(n) {
+ if (n <= 0) return '';
+ return ' '.repeat(n);
+}
+
+/**
+ * @param {any} value
+ * @param {"flat" | "default"} mode
+ * @param {number} indent
+ * @param {Set<any>} seen
+ * @returns {string}
+ */
+function serialize(value, mode, indent, seen) {
+ if (seen.has(value)) {
+ return kl.cyan('[Circular]');
+ }
+
+ if (value === null) {
+ return kl.bold('null');
+ } else if (Array.isArray(value)) {
+ seen.add(value);
+ const values = value.map(v => serialize(v, mode, indent + 1, seen));
+ if (mode === 'flat') {
+ return `[ ${values.join(', ')} ]`;
+ }
+
+ const space = applyIndent(indent);
+ const pretty = values.map(v => applyIndent(indent + 1) + v).join(',\n');
+ return `[\n${pretty}\n${space}]`;
+ } else if (value instanceof Set) {
+ const values = [];
+ value.forEach(v => {
+ values.push(serialize(v, mode, indent, seen));
+ });
+
+ if (mode === 'flat') {
+ return `Set(${value.size}) { ${values.join(', ')} }`;
+ }
+
+ const pretty = values.map(v => applyIndent(indent + 1) + v).join(',\n');
+ return `Set(${value.size}) {\n${pretty}\n${applyIndent(indent)}}`;
+ } else if (value instanceof Map) {
+ const values = [];
+ value.forEach((v, k) => {
+ values.push([
+ serialize(v, 'flat', indent, seen),
+ serialize(k, 'flat', indent, seen)
+ ]);
+ });
+
+ if (mode === 'flat') {
+ const pretty = values.map(v => `${v[0]} => ${v[1]}`).join(', ');
+ return `Map(${value.size}) { ${pretty} }`;
+ }
+
+ const pretty = values
+ .map(v => {
+ return applyIndent(indent + 1) + `${v[0]} => ${v[1]}`;
+ })
+ .join(', ');
+ return `Map(${value.size}) {\n${pretty}\n${applyIndent(indent)}}`;
+ }
+
+ switch (typeof value) {
+ case 'undefined':
+ return kl.dim('undefined');
+
+ case 'bigint':
+ case 'number':
+ case 'boolean':
+ return kl.yellow(String(value));
+ case 'string': {
+ // By default node's built in logging doesn't wrap top level
+ // strings with quotes
+ if (indent === 0) {
+ return String(value);
+ }
+ const quote = /[^\\]"/.test(value) ? '"' : "'";
+ return kl.green(String(quote + value + quote));
+ }
+ case 'symbol':
+ return kl.green(value.toString());
+ case 'function':
+ return kl.cyan(`[Function: ${value.name || 'anonymous'}]`);
+ }
+
+ if (value instanceof Element) {
+ return value.outerHTML;
+ }
+
+ seen.add(value);
+
+ const props = Object.keys(value).map(key => {
+ const v = serialize(value[key], mode, indent + 1, seen);
+ return `${key}: ${v}`;
+ });
+
+ if (props.length === 0) {
+ return '{}';
+ } else if (mode === 'flat') {
+ const pretty = props.join(', ');
+ return `{ ${pretty} }`;
+ }
+
+ const pretty = props.map(p => applyIndent(indent + 1) + p).join(',\n');
+ return `{\n${pretty}\n${applyIndent(indent)}}`;
+}
+
+// Use these lines to test pretty formatting:
+//
+// const obj = { foo: 123 };
+// obj.obj = obj;
+// console.log(obj);
+// console.log([1, 2]);
+// console.log(new Set([1, 2]));
+// console.log(new Map([[1, 2]]));
+// console.log({
+// foo: { bar: 123, bob: { a: 1 } }
+// });
+// console.log(
+// 'hey',
+// null,
+// undefined,
+// [1, 2, ['a']],
+// () => {},
+// {
+// type: 'div',
+// props: {},
+// key: undefined,
+// ref: undefined,
+// __k: null,
+// __: null,
+// __b: 0,
+// __e: null,
+// __d: undefined,
+// __c: null,
+// __h: null,
+// constructor: undefined,
+// __v: 1
+// },
+// {
+// foo: { bar: 123, bob: { a: 1, b: new Set([1, 2]), c: new Map([[1, 2]]) } }
+// },
+// new Set([1, 2]),
+// new Map([[1, 2]])
+// );
+// console.log(document.createElement('div'));
diff --git a/preact/test/shared/createContext.test.js b/preact/test/shared/createContext.test.js
new file mode 100644
index 0000000..1c64c3c
--- /dev/null
+++ b/preact/test/shared/createContext.test.js
@@ -0,0 +1,24 @@
+import { createElement, createContext } from '../../';
+import { expect } from 'chai';
+
+/** @jsx createElement */
+/* eslint-env browser, mocha */
+
+describe('createContext', () => {
+ it('should return a Provider and a Consumer', () => {
+ const context = createContext();
+ expect(context).to.have.property('Provider');
+ expect(context).to.have.property('Consumer');
+ });
+
+ it('should return a valid Provider Component', () => {
+ const { Provider } = createContext();
+ const contextValue = { value: 'test' };
+ const children = [<div>child1</div>, <div>child2</div>];
+
+ const providerComponent = <Provider {...contextValue}>{children}</Provider>;
+ //expect(providerComponent).to.have.property('tag', 'Provider');
+ expect(providerComponent.props.value).to.equal(contextValue.value);
+ expect(providerComponent.props.children).to.equal(children);
+ });
+});
diff --git a/preact/test/shared/createElement.test.js b/preact/test/shared/createElement.test.js
new file mode 100644
index 0000000..a7231b6
--- /dev/null
+++ b/preact/test/shared/createElement.test.js
@@ -0,0 +1,299 @@
+import { createElement } from '../../';
+import { expect } from 'chai';
+
+const h = createElement;
+/** @jsx createElement */
+/*eslint-env browser, mocha */
+
+// const buildVNode = (nodeName, attributes, children=[]) => ({
+// nodeName,
+// children,
+// attributes,
+// key: attributes && attributes.key
+// });
+
+describe('createElement(jsx)', () => {
+ it('should return a VNode', () => {
+ let r;
+ expect(() => (r = h('foo'))).not.to.throw();
+ expect(r).to.be.an('object');
+ // expect(r).to.be.an.instanceof(VNode);
+ expect(r).to.have.property('type', 'foo');
+ expect(r)
+ .to.have.property('props')
+ .that.eql({});
+ // expect(r).to.have.deep.property('props.children').that.eql(null);
+ });
+
+ it('should set VNode#type property', () => {
+ expect(<div />).to.have.property('type', 'div');
+ function Test() {
+ return <div />;
+ }
+ expect(<Test />).to.have.property('type', Test);
+ });
+
+ it('should set VNode.constructor property to prevent json injection', () => {
+ const vnode = <span />;
+ expect(vnode.constructor).to.equal(undefined);
+ });
+
+ it('should set VNode#props property', () => {
+ const props = {};
+ expect(h('div', props).props).to.deep.equal(props);
+ });
+
+ it('should set VNode#key property', () => {
+ expect(<div />).to.have.property('key').that.does.not.exist;
+ expect(<div a="a" />).to.have.property('key').that.does.not.exist;
+ expect(<div key="1" />).to.have.property('key', '1');
+ });
+
+ it('should not set VNode#props.key property', () => {
+ expect(<div />).to.not.have.nested.property('props.key');
+ expect(<div key="1" />).to.not.have.nested.property('props.key');
+ expect(<div key={0} />).to.not.have.nested.property('props.key');
+ expect(<div key={''} />).to.not.have.nested.property('props.key');
+ });
+
+ it('should set VNode#ref property', () => {
+ expect(<div />).to.have.property('ref').that.does.not.exist;
+ expect(<div a="a" />).to.have.property('ref').that.does.not.exist;
+ const emptyFunction = () => {};
+ expect(<div ref={emptyFunction} />).to.have.property('ref', emptyFunction);
+ });
+
+ it('should not set VNode#props.ref property', () => {
+ expect(<div />).to.not.have.nested.property('props.ref');
+ expect(<div ref={() => {}} />).to.not.have.nested.property('props.ref');
+ });
+
+ it('should have ordered VNode properties', () => {
+ expect(Object.keys(<div />).filter(key => !/^_/.test(key))).to.deep.equal([
+ 'type',
+ 'props',
+ 'key',
+ 'ref',
+ 'constructor'
+ ]);
+ });
+
+ it('should preserve raw props', () => {
+ let props = { foo: 'bar', baz: 10, func: () => {} },
+ r = h('foo', props);
+ expect(r)
+ .to.be.an('object')
+ .with.property('props')
+ .that.deep.equals(props);
+ });
+
+ it('should support element children', () => {
+ let kid1 = h('bar');
+ let kid2 = h('baz');
+ let r = h('foo', null, kid1, kid2);
+
+ expect(r)
+ .to.be.an('object')
+ .with.nested.deep.property('props.children', [kid1, kid2]);
+ });
+
+ it('should support multiple element children, given as arg list', () => {
+ let kid1 = h('bar');
+ let kid3 = h('test');
+ let kid2 = h('baz', null, kid3);
+
+ let r = h('foo', null, kid1, kid2);
+
+ expect(r)
+ .to.be.an('object')
+ .with.nested.deep.property('props.children', [kid1, kid2]);
+ });
+
+ it('should handle multiple element children, given as an array', () => {
+ let kid1 = h('bar');
+ let kid3 = h('test');
+ let kid2 = h('baz', null, kid3);
+
+ let r = h('foo', null, [kid1, kid2]);
+
+ expect(r)
+ .to.be.an('object')
+ .with.nested.deep.property('props.children', [kid1, kid2]);
+ });
+
+ it('should support nested children', () => {
+ const m = x => {
+ const result = h(x);
+ delete result._original;
+ return result;
+ };
+ expect(h('foo', null, m('a'), [m('b'), m('c')], m('d')))
+ .to.have.nested.property('props.children')
+ .that.eql([m('a'), [m('b'), m('c')], m('d')]);
+
+ expect(h('foo', null, [m('a'), [m('b'), m('c')], m('d')]))
+ .to.have.nested.property('props.children')
+ .that.eql([m('a'), [m('b'), m('c')], m('d')]);
+
+ expect(h('foo', { children: [m('a'), [m('b'), m('c')], m('d')] }))
+ .to.have.nested.property('props.children')
+ .that.eql([m('a'), [m('b'), m('c')], m('d')]);
+
+ expect(h('foo', { children: [[m('a'), [m('b'), m('c')], m('d')]] }))
+ .to.have.nested.property('props.children')
+ .that.eql([[m('a'), [m('b'), m('c')], m('d')]]);
+
+ expect(h('foo', { children: m('a') }))
+ .to.have.nested.property('props.children')
+ .that.eql(m('a'));
+
+ expect(h('foo', { children: 'a' }))
+ .to.have.nested.property('props.children')
+ .that.eql('a');
+ });
+
+ it('should support text children', () => {
+ let r = h('foo', null, 'textstuff');
+
+ expect(r)
+ .to.be.an('object')
+ .with.nested.property('props.children')
+ .that.equals('textstuff');
+ });
+
+ it('should override children if null is provided as an argument', () => {
+ let r = h('foo', { foo: 'bar', children: 'baz' }, null);
+
+ expect(r)
+ .to.be.an('object')
+ .to.deep.nested.include({
+ 'props.foo': 'bar',
+ 'props.children': null
+ });
+ });
+
+ it('should NOT set children prop when unspecified', () => {
+ let r = h('foo', { foo: 'bar' });
+
+ expect(r)
+ .to.be.an('object')
+ .to.have.nested.property('props.foo')
+ .not.to.have.nested.property('props.children');
+ });
+
+ it('should NOT merge adjacent text children', () => {
+ const bar = h('bar');
+ const barClone = h('bar');
+ const baz = h('baz');
+ const bazClone = h('baz');
+ const baz2 = h('baz');
+ const baz2Clone = h('baz');
+
+ delete bar._original;
+ delete barClone._original;
+ delete baz._original;
+ delete bazClone._original;
+ delete baz2._original;
+ delete baz2Clone._original;
+
+ let r = h(
+ 'foo',
+ null,
+ 'one',
+ 'two',
+ bar,
+ 'three',
+ baz,
+ baz2,
+ 'four',
+ null,
+ 'five',
+ 'six'
+ );
+
+ expect(r)
+ .to.be.an('object')
+ .with.nested.property('props.children')
+ .that.deep.equals([
+ 'one',
+ 'two',
+ barClone,
+ 'three',
+ bazClone,
+ baz2Clone,
+ 'four',
+ null,
+ 'five',
+ 'six'
+ ]);
+ });
+
+ it('should not merge nested adjacent text children', () => {
+ let r = h(
+ 'foo',
+ null,
+ 'one',
+ ['two', null, 'three'],
+ null,
+ ['four', null, 'five', null],
+ 'six',
+ null
+ );
+
+ expect(r)
+ .to.be.an('object')
+ .with.nested.property('props.children')
+ .that.deep.equals([
+ 'one',
+ ['two', null, 'three'],
+ null,
+ ['four', null, 'five', null],
+ 'six',
+ null
+ ]);
+ });
+
+ it('should not merge children that are boolean values', () => {
+ let r = h('foo', null, 'one', true, 'two', false, 'three');
+
+ expect(r)
+ .to.be.an('object')
+ .with.nested.property('props.children')
+ .that.deep.equals(['one', true, 'two', false, 'three']);
+ });
+
+ it('should not merge children of components', () => {
+ let Component = ({ children }) => children;
+ let r = h(Component, null, 'x', 'y');
+
+ expect(r)
+ .to.be.an('object')
+ .with.nested.property('props.children')
+ .that.deep.equals(['x', 'y']);
+ });
+
+ it('should respect defaultProps', () => {
+ const Component = ({ children }) => children;
+ Component.defaultProps = { foo: 'bar' };
+ expect(h(Component).props).to.deep.equal({ foo: 'bar' });
+ });
+
+ it('should override defaultProps', () => {
+ const Component = ({ children }) => children;
+ Component.defaultProps = { foo: 'default' };
+ expect(h(Component, { foo: 'bar' }).props).to.deep.equal({ foo: 'bar' });
+ });
+
+ it('should ignore props.children if children are manually specified', () => {
+ const element = (
+ <div a children={['a', 'b']}>
+ c
+ </div>
+ );
+ const childrenless = <div a>c</div>;
+ delete element._original;
+ delete childrenless._original;
+
+ expect(element).to.eql(childrenless);
+ });
+});
diff --git a/preact/test/shared/exports.test.js b/preact/test/shared/exports.test.js
new file mode 100644
index 0000000..b075af5
--- /dev/null
+++ b/preact/test/shared/exports.test.js
@@ -0,0 +1,32 @@
+import {
+ createElement,
+ h,
+ createContext,
+ Component,
+ Fragment,
+ render,
+ hydrate,
+ cloneElement,
+ options,
+ createRef,
+ toChildArray,
+ isValidElement
+} from '../../';
+import { expect } from 'chai';
+
+describe('preact', () => {
+ it('should be available as named exports', () => {
+ expect(h).to.be.a('function');
+ expect(createElement).to.be.a('function');
+ expect(Component).to.be.a('function');
+ expect(Fragment).to.exist;
+ expect(render).to.be.a('function');
+ expect(hydrate).to.be.a('function');
+ expect(cloneElement).to.be.a('function');
+ expect(createContext).to.be.a('function');
+ expect(options).to.exist.and.be.an('object');
+ expect(createRef).to.be.a('function');
+ expect(isValidElement).to.be.a('function');
+ expect(toChildArray).to.be.a('function');
+ });
+});
diff --git a/preact/test/shared/isValidElement.test.js b/preact/test/shared/isValidElement.test.js
new file mode 100644
index 0000000..66c49b6
--- /dev/null
+++ b/preact/test/shared/isValidElement.test.js
@@ -0,0 +1,5 @@
+import { createElement, isValidElement, Component } from '../../';
+import { expect } from 'chai';
+import { isValidElementTests } from './isValidElementTests';
+
+isValidElementTests(expect, isValidElement, createElement, Component);
diff --git a/preact/test/shared/isValidElementTests.js b/preact/test/shared/isValidElementTests.js
new file mode 100644
index 0000000..d0e86b5
--- /dev/null
+++ b/preact/test/shared/isValidElementTests.js
@@ -0,0 +1,37 @@
+/** @jsx createElement */
+
+export function isValidElementTests(
+ expect,
+ isValidElement,
+ createElement,
+ Component
+) {
+ describe('isValidElement', () => {
+ it('should check if the argument is a valid vnode', () => {
+ // Failure cases
+ expect(isValidElement(123)).to.equal(false);
+ expect(isValidElement(0)).to.equal(false);
+ expect(isValidElement('')).to.equal(false);
+ expect(isValidElement('abc')).to.equal(false);
+ expect(isValidElement(null)).to.equal(false);
+ expect(isValidElement(undefined)).to.equal(false);
+ expect(isValidElement(true)).to.equal(false);
+ expect(isValidElement(false)).to.equal(false);
+ expect(isValidElement([])).to.equal(false);
+ expect(isValidElement([123])).to.equal(false);
+ expect(isValidElement([null])).to.equal(false);
+
+ // Success cases
+ expect(isValidElement(<div />)).to.equal(true);
+
+ const Foo = () => 123;
+ expect(isValidElement(<Foo />)).to.equal(true);
+ class Bar extends Component {
+ render() {
+ return <div />;
+ }
+ }
+ expect(isValidElement(<Bar />)).to.equal(true);
+ });
+ });
+}
diff --git a/preact/test/ts/Component-test.tsx b/preact/test/ts/Component-test.tsx
new file mode 100644
index 0000000..b037219
--- /dev/null
+++ b/preact/test/ts/Component-test.tsx
@@ -0,0 +1,183 @@
+import 'mocha';
+import { expect } from 'chai';
+import { createElement, Component, RenderableProps, Fragment } from '../../';
+
+// Test `this` binding on event handlers
+function onHandler(this: HTMLInputElement, event: any) {
+ return this.value;
+}
+const foo = <input onChange={onHandler} />;
+
+export class ContextComponent extends Component<{ foo: string }> {
+ getChildContext() {
+ return { something: 2 };
+ }
+
+ render() {
+ return null;
+ }
+}
+
+export interface SimpleComponentProps {
+ initialName: string | null;
+}
+
+export interface SimpleState {
+ name: string | null;
+}
+
+export class SimpleComponent extends Component<
+ SimpleComponentProps,
+ SimpleState
+> {
+ constructor(props: SimpleComponentProps) {
+ super(props);
+ this.state = {
+ name: props.initialName
+ };
+ }
+
+ render() {
+ if (!this.state.name) {
+ return null;
+ }
+ const { initialName, children } = this.props;
+ return (
+ <div>
+ <span>
+ {initialName} / {this.state.name}
+ </span>
+ {children}
+ </div>
+ );
+ }
+}
+
+class DestructuringRenderPropsComponent extends Component<
+ SimpleComponentProps,
+ SimpleState
+> {
+ constructor(props: SimpleComponentProps) {
+ super(props);
+ this.state = {
+ name: props.initialName
+ };
+ }
+
+ render({ initialName, children }: RenderableProps<SimpleComponentProps>) {
+ if (!this.state.name) {
+ return null;
+ }
+ return (
+ <span>
+ {this.props.initialName} / {this.state.name}
+ </span>
+ );
+ }
+}
+
+interface RandomChildrenComponentProps {
+ num?: number;
+ val?: string;
+ span?: boolean;
+}
+
+class RandomChildrenComponent extends Component<RandomChildrenComponentProps> {
+ render() {
+ const { num, val, span } = this.props;
+ if (num) {
+ return num;
+ }
+ if (val) {
+ return val;
+ }
+ if (span) {
+ return <span>hi</span>;
+ }
+ return null;
+ }
+}
+
+class StaticComponent extends Component<SimpleComponentProps, SimpleState> {
+ static getDerivedStateFromProps(
+ props: SimpleComponentProps,
+ state: SimpleState
+ ): Partial<SimpleState> {
+ return {
+ ...props,
+ ...state
+ };
+ }
+
+ static getDerivedStateFromError(err: Error) {
+ return {
+ name: err.message
+ };
+ }
+
+ render() {
+ return null;
+ }
+}
+
+function MapperItem(props: { foo: number }) {
+ return <div />;
+}
+
+function Mapper() {
+ return [1, 2, 3].map(x => <MapperItem foo={x} key={x} />);
+}
+
+describe('Component', () => {
+ const component = new SimpleComponent({ initialName: 'da name' });
+
+ it('has state', () => {
+ expect(component.state.name).to.eq('da name');
+ });
+
+ it('has props', () => {
+ expect(component.props.initialName).to.eq('da name');
+ });
+
+ it('has no base when not mounted', () => {
+ expect(component.base).to.not.exist;
+ });
+
+ describe('setState', () => {
+ // No need to execute these tests. because we only need to check if
+ // the types are working. Executing them would require the DOM.
+ // TODO: Run TS tests in our standard karma setup
+ it.skip('can be used with an object', () => {
+ component.setState({ name: 'another name' });
+ });
+
+ it.skip('can be used with a function', () => {
+ const updater = (state: any, props: any) => ({
+ name: `${state.name} - ${props.initialName}`
+ });
+ component.setState(updater);
+ });
+ });
+
+ describe('render', () => {
+ it('can return null', () => {
+ const comp = new SimpleComponent({ initialName: null });
+ const actual = comp.render();
+
+ expect(actual).to.eq(null);
+ });
+ });
+
+ describe('Fragment', () => {
+ it('should render nested Fragments', () => {
+ var vnode = (
+ <Fragment>
+ <Fragment>foo</Fragment>
+ bar
+ </Fragment>
+ );
+
+ expect(vnode.type).to.be.equal(Fragment);
+ });
+ });
+});
diff --git a/preact/test/ts/VNode-test.tsx b/preact/test/ts/VNode-test.tsx
new file mode 100644
index 0000000..7225901
--- /dev/null
+++ b/preact/test/ts/VNode-test.tsx
@@ -0,0 +1,197 @@
+import 'mocha';
+import { expect } from 'chai';
+import {
+ createElement,
+ Component,
+ toChildArray,
+ FunctionalComponent,
+ ComponentConstructor,
+ ComponentFactory,
+ VNode,
+ ComponentChildren,
+ cloneElement
+} from '../../';
+
+function getDisplayType(vnode: VNode | string | number) {
+ if (typeof vnode === 'string' || typeof vnode == 'number') {
+ return vnode.toString();
+ } else if (typeof vnode.type == 'string') {
+ return vnode.type;
+ } else {
+ return vnode.type.displayName;
+ }
+}
+
+class SimpleComponent extends Component<{}, {}> {
+ render() {
+ return <div>{this.props.children}</div>;
+ }
+}
+
+const SimpleFunctionalComponent = () => <div />;
+
+const a: ComponentFactory = SimpleComponent;
+const b: ComponentFactory = SimpleFunctionalComponent;
+
+describe('VNode TS types', () => {
+ it('is returned by h', () => {
+ const actual = <div className="wow" />;
+ expect(actual).to.include.all.keys('type', 'props', 'key');
+ });
+
+ it('has a nodeName of string when html element', () => {
+ const div = <div>Hi!</div>;
+ expect(div.type).to.equal('div');
+ });
+
+ it('has a nodeName equal to the construction function when SFC', () => {
+ const sfc = <SimpleFunctionalComponent />;
+ expect(sfc.type).to.be.instanceOf(Function);
+ const constructor = sfc.type as FunctionalComponent<any>;
+ expect(constructor.name).to.eq('SimpleFunctionalComponent');
+ });
+
+ it('has a nodeName equal to the constructor of a component', () => {
+ const sfc = <SimpleComponent />;
+ expect(sfc.type).to.be.instanceOf(Function);
+ const constructor = sfc.type as ComponentConstructor<any>;
+ expect(constructor.name).to.eq('SimpleComponent');
+ });
+
+ it('has children which is an array of string or other vnodes', () => {
+ const comp = (
+ <SimpleComponent>
+ <SimpleComponent>child1</SimpleComponent>
+ child2
+ </SimpleComponent>
+ );
+
+ expect(comp.props.children).to.be.instanceOf(Array);
+ expect(comp.props.children[1]).to.be.a('string');
+ });
+
+ it('children type should work with toChildArray', () => {
+ const comp: VNode = <SimpleComponent>child1 {1}</SimpleComponent>;
+
+ const children = toChildArray(comp.props.children);
+ expect(children).to.have.lengthOf(2);
+ });
+
+ it('toChildArray should filter out some types', () => {
+ const compChild = <SimpleComponent />;
+ const comp: VNode = (
+ <SimpleComponent>
+ a{null}
+ {true}
+ {false}
+ {2}
+ {undefined}
+ {['b', 'c']}
+ {compChild}
+ </SimpleComponent>
+ );
+
+ const children = toChildArray(comp.props.children);
+ expect(children).to.deep.equal(['a', 2, 'b', 'c', compChild]);
+ });
+
+ it('functions like getDisplayType should work', () => {
+ function TestComp(props: { children?: ComponentChildren }) {
+ return <div>{props.children}</div>;
+ }
+ TestComp.displayName = 'TestComp';
+
+ const compChild = <TestComp />;
+ const comp: VNode = (
+ <SimpleComponent>
+ a{null}
+ {true}
+ {false}
+ {2}
+ {undefined}
+ {['b', 'c']}
+ {compChild}
+ </SimpleComponent>
+ );
+
+ const types = toChildArray(comp.props.children).map(getDisplayType);
+ expect(types).to.deep.equal(['a', '2', 'b', 'c', 'TestComp']);
+ });
+
+ it('component should work with cloneElement', () => {
+ const comp: VNode = (
+ <SimpleComponent>
+ <div>child 1</div>
+ </SimpleComponent>
+ );
+ const clone: VNode = cloneElement(comp);
+
+ expect(comp.type).to.equal(clone.type);
+ expect(comp.props).not.to.equal(clone.props);
+ expect(comp.props).to.deep.equal(clone.props);
+ });
+
+ it('component should work with cloneElement using generics', () => {
+ const comp: VNode<string> = <SimpleComponent></SimpleComponent>;
+ const clone: VNode<string> = cloneElement<string>(comp);
+
+ expect(comp.type).to.equal(clone.type);
+ expect(comp.props).not.to.equal(clone.props);
+ expect(comp.props).to.deep.equal(clone.props);
+ });
+});
+
+class ComponentWithFunctionChild extends Component<{
+ children: (num: number) => string;
+}> {
+ render() {
+ return null;
+ }
+}
+
+<ComponentWithFunctionChild>
+ {num => num.toFixed(2)}
+</ComponentWithFunctionChild>;
+
+class ComponentWithStringChild extends Component<{ children: string }> {
+ render() {
+ return null;
+ }
+}
+
+<ComponentWithStringChild>child</ComponentWithStringChild>;
+
+class ComponentWithNumberChild extends Component<{ children: number }> {
+ render() {
+ return null;
+ }
+}
+
+<ComponentWithNumberChild>{1}</ComponentWithNumberChild>;
+
+class ComponentWithBooleanChild extends Component<{ children: boolean }> {
+ render() {
+ return null;
+ }
+}
+
+<ComponentWithBooleanChild>{false}</ComponentWithBooleanChild>;
+
+class ComponentWithNullChild extends Component<{ children: null }> {
+ render() {
+ return null;
+ }
+}
+
+<ComponentWithNullChild>{null}</ComponentWithNullChild>;
+
+class ComponentWithNumberChildren extends Component<{ children: number[] }> {
+ render() {
+ return null;
+ }
+}
+
+<ComponentWithNumberChildren>
+ {1}
+ {2}
+</ComponentWithNumberChildren>;
diff --git a/preact/test/ts/custom-elements.tsx b/preact/test/ts/custom-elements.tsx
new file mode 100644
index 0000000..0f8d29e
--- /dev/null
+++ b/preact/test/ts/custom-elements.tsx
@@ -0,0 +1,85 @@
+import { createElement, Component, createContext } from '../../';
+
+declare module '../../' {
+ namespace createElement.JSX {
+ interface IntrinsicElements {
+ // Custom element can use JSX EventHandler definitions
+ 'clickable-ce': {
+ optionalAttr?: string;
+ onClick?: MouseEventHandler<HTMLElement>;
+ };
+
+ // Custom Element that extends HTML attributes
+ 'color-picker': HTMLAttributes & {
+ // Required attribute
+ space: 'rgb' | 'hsl' | 'hsv';
+ // Optional attribute
+ alpha?: boolean;
+ };
+
+ // Custom Element with custom interface definition
+ 'custom-whatever': WhateveElAttributes;
+ }
+ }
+}
+
+// Whatever Element definition
+
+interface WhateverElement {
+ instanceProp: string;
+}
+
+interface WhateverElementEvent {
+ eventProp: number;
+}
+
+// preact.JSX.HTMLAttributes also appears to work here but for consistency,
+// let's use createElement.JSX
+interface WhateveElAttributes extends createElement.JSX.HTMLAttributes {
+ someattribute?: string;
+ onsomeevent?: (this: WhateverElement, ev: WhateverElementEvent) => void;
+}
+
+// Ensure context still works
+const { Provider, Consumer } = createContext({ contextValue: '' });
+
+// Sample component that uses custom elements
+
+class SimpleComponent extends Component {
+ componentProp = 'componentProp';
+ render() {
+ // Render inside div to ensure standard JSX elements still work
+ return (
+ <Provider value={{ contextValue: 'value' }}>
+ <div>
+ <clickable-ce
+ onClick={e => {
+ // `this` should be instance of SimpleComponent since this is an
+ // arrow function
+ console.log(this.componentProp);
+
+ // Validate `currentTarget` is HTMLElement
+ console.log('clicked ', e.currentTarget.style.display);
+ }}
+ ></clickable-ce>
+ <color-picker space="rgb" dir="rtl"></color-picker>
+ <custom-whatever
+ dir="auto" // Inherited prop from HTMLAttributes
+ someattribute="string"
+ onsomeevent={function(e) {
+ // Validate `this` and `e` are the right type
+ console.log('clicked', this.instanceProp, e.eventProp);
+ }}
+ ></custom-whatever>
+
+ {/* Ensure context still works */}
+ <Consumer>
+ {({ contextValue }) => contextValue.toLowerCase()}
+ </Consumer>
+ </div>
+ </Provider>
+ );
+ }
+}
+
+const component = <SimpleComponent />;
diff --git a/preact/test/ts/hoc-test.tsx b/preact/test/ts/hoc-test.tsx
new file mode 100644
index 0000000..455d9e0
--- /dev/null
+++ b/preact/test/ts/hoc-test.tsx
@@ -0,0 +1,50 @@
+import { expect } from 'chai';
+import {
+ createElement,
+ ComponentFactory,
+ ComponentConstructor,
+ Component
+} from '../../';
+import { SimpleComponent, SimpleComponentProps } from './Component-test';
+
+export interface highlightedProps {
+ isHighlighted: boolean;
+}
+
+export function highlighted<T>(
+ Wrappable: ComponentFactory<T>
+): ComponentConstructor<T & highlightedProps> {
+ return class extends Component<T & highlightedProps> {
+ constructor(props: T & highlightedProps) {
+ super(props);
+ }
+
+ render() {
+ let className = this.props.isHighlighted ? 'highlighted' : '';
+ return (
+ <div className={className}>
+ <Wrappable {...this.props} />
+ </div>
+ );
+ }
+
+ toString() {
+ return `Highlighted ${Wrappable.name}`;
+ }
+ };
+}
+
+const HighlightedSimpleComponent = highlighted<SimpleComponentProps>(
+ SimpleComponent
+);
+
+describe('hoc', () => {
+ it('wraps the given component', () => {
+ const highlight = new HighlightedSimpleComponent({
+ initialName: 'initial name',
+ isHighlighted: true
+ });
+
+ expect(highlight.toString()).to.eq('Highlighted SimpleComponent');
+ });
+});
diff --git a/preact/test/ts/jsx-namespacce-test.tsx b/preact/test/ts/jsx-namespacce-test.tsx
new file mode 100644
index 0000000..d6e10bd
--- /dev/null
+++ b/preact/test/ts/jsx-namespacce-test.tsx
@@ -0,0 +1,16 @@
+import { createElement, Component } from '../../';
+
+// declare global JSX types that should not be mixed with preact's internal types
+declare global {
+ namespace JSX {
+ interface Element {
+ unknownProperty: string;
+ }
+ }
+}
+
+class SimpleComponent extends Component {
+ render() {
+ return <div>It works</div>;
+ }
+}
diff --git a/preact/test/ts/preact-global-test.tsx b/preact/test/ts/preact-global-test.tsx
new file mode 100644
index 0000000..e6c3286
--- /dev/null
+++ b/preact/test/ts/preact-global-test.tsx
@@ -0,0 +1,6 @@
+import { createElement } from '../../src';
+
+// Test that preact types are available via the global `preact` namespace.
+
+let component: preact.ComponentChild;
+component = <div>Hello World</div>;
diff --git a/preact/test/ts/preact.tsx b/preact/test/ts/preact.tsx
new file mode 100644
index 0000000..9779d41
--- /dev/null
+++ b/preact/test/ts/preact.tsx
@@ -0,0 +1,297 @@
+import {
+ createElement,
+ render,
+ Component,
+ ComponentProps,
+ FunctionalComponent,
+ AnyComponent,
+ h
+} from '../../';
+
+interface DummyProps {
+ initialInput: string;
+}
+
+interface DummyState {
+ input: string;
+}
+
+class DummyComponent extends Component<DummyProps, DummyState> {
+ constructor(props: DummyProps) {
+ super(props);
+ this.state = {
+ input: `x${this.props}x`
+ };
+ }
+
+ private setRef = (el: AnyComponent<any>) => {
+ console.log(el);
+ };
+
+ render({ initialInput }: DummyProps, { input }: DummyState) {
+ return (
+ <div>
+ <DummerComponent initialInput={initialInput} input={input} />
+ {/* Can specify all Preact attributes on a typed FunctionalComponent */}
+ <ComponentWithChildren
+ initialInput={initialInput}
+ input={input}
+ key="1"
+ ref={this.setRef}
+ />
+ </div>
+ );
+ }
+}
+
+interface DummerComponentProps extends DummyProps, DummyState {}
+
+function DummerComponent({ input, initialInput }: DummerComponentProps) {
+ return (
+ <div>
+ Input: {input}, initial: {initialInput}
+ </div>
+ );
+}
+
+render(createElement('div', { title: 'test', key: '1' }), document);
+render(
+ createElement(DummyComponent, { initialInput: 'The input', key: '1' }),
+ document
+);
+render(
+ createElement(DummerComponent, {
+ initialInput: 'The input',
+ input: 'New input',
+ key: '1'
+ }),
+ document
+);
+render(h('div', { title: 'test', key: '1' }), document);
+render(h(DummyComponent, { initialInput: 'The input', key: '1' }), document);
+render(
+ h(DummerComponent, {
+ initialInput: 'The input',
+ input: 'New input',
+ key: '1'
+ }),
+ document
+);
+
+// Accessing children
+const ComponentWithChildren: FunctionalComponent<DummerComponentProps> = ({
+ input,
+ initialInput,
+ children
+}) => {
+ return (
+ <div>
+ <span>{initialInput}</span>
+ <span>{input}</span>
+ <span>{children}</span>
+ </div>
+ );
+};
+
+const UseOfComponentWithChildren = () => {
+ return (
+ <ComponentWithChildren initialInput="initial" input="input">
+ <span>child 1</span>
+ <span>child 2</span>
+ </ComponentWithChildren>
+ );
+};
+
+// using ref and or jsx
+class ComponentUsingRef extends Component<any, any> {
+ private array: string[];
+ private refs: (Element | null)[] = [];
+
+ constructor() {
+ super();
+ this.array = ['1', '2'];
+ }
+
+ render() {
+ this.refs = [];
+ return (
+ <div jsx>
+ {this.array.map(el => (
+ <span ref={this.setRef}>{el}</span>
+ ))}
+
+ {/* Can specify Preact attributes on a component */}
+ <DummyComponent initialInput="1" key="1" ref={this.setRef} />
+ </div>
+ );
+ }
+
+ private setRef = (el: Element | null) => {
+ this.refs.push(el);
+ };
+}
+
+// using lifecycles
+class ComponentWithLifecycle extends Component<DummyProps, DummyState> {
+ render() {
+ return <div>Hi</div>;
+ }
+
+ componentWillMount() {
+ console.log('componentWillMount');
+ }
+
+ componentDidMount() {
+ console.log('componentDidMount');
+ }
+
+ componentWillUnmount() {
+ console.log('componentWillUnmount');
+ }
+
+ componentWillReceiveProps(nextProps: DummyProps, nextCtx: any) {
+ const { initialInput } = nextProps;
+ console.log('componentWillReceiveProps', initialInput, nextCtx);
+ }
+
+ shouldComponentUpdate(
+ nextProps: DummyProps,
+ nextState: DummyState,
+ nextContext: any
+ ) {
+ return false;
+ }
+
+ componentWillUpdate(
+ nextProps: DummyProps,
+ nextState: DummyState,
+ nextContext: any
+ ) {
+ console.log('componentWillUpdate', nextProps, nextState, nextContext);
+ }
+
+ componentDidUpdate(
+ previousProps: DummyProps,
+ previousState: DummyState,
+ previousContext: any
+ ) {
+ console.log(
+ 'componentDidUpdate',
+ previousProps,
+ previousState,
+ previousContext
+ );
+ }
+}
+
+// Default props: JSX.LibraryManagedAttributes
+
+class DefaultProps extends Component<{ text: string; bool: boolean }> {
+ static defaultProps = {
+ text: 'hello'
+ };
+
+ render() {
+ return <div>{this.props.text}</div>;
+ }
+}
+
+const d1 = <DefaultProps bool={false} text="foo" />;
+const d2 = <DefaultProps bool={false} />;
+
+class DefaultPropsWithUnion extends Component<
+ { default: boolean } & (
+ | {
+ type: 'string';
+ str: string;
+ }
+ | {
+ type: 'number';
+ num: number;
+ }
+ )
+> {
+ static defaultProps = {
+ default: true
+ };
+
+ render() {
+ return <div />;
+ }
+}
+
+const d3 = <DefaultPropsWithUnion type="string" str={'foo'} />;
+const d4 = <DefaultPropsWithUnion type="number" num={0xf00} />;
+const d5 = <DefaultPropsWithUnion type="string" str={'foo'} default={false} />;
+const d6 = <DefaultPropsWithUnion type="number" num={0xf00} default={false} />;
+
+class DefaultUnion extends Component<
+ | {
+ type: 'number';
+ num: number;
+ }
+ | {
+ type: 'string';
+ str: string;
+ }
+> {
+ static defaultProps = {
+ type: 'number',
+ num: 1
+ };
+
+ render() {
+ return <div />;
+ }
+}
+
+const d7 = <DefaultUnion />;
+const d8 = <DefaultUnion num={1} />;
+const d9 = <DefaultUnion type="number" />;
+const d10 = <DefaultUnion type="string" str="foo" />;
+
+class ComponentWithDefaultProps extends Component<{ value: string }> {
+ static defaultProps = { value: '' };
+ render() {
+ return <div>{this.props.value}</div>;
+ }
+}
+
+const withDefaultProps = <ComponentWithDefaultProps />;
+
+interface PartialState {
+ foo: string;
+ bar: number;
+}
+
+class ComponentWithPartialSetState extends Component<{}, PartialState> {
+ render({}, { foo, bar }: PartialState) {
+ return (
+ <button onClick={() => this.handleClick('foo')}>
+ {foo}-{bar}
+ </button>
+ );
+ }
+ handleClick = (value: keyof PartialState) => {
+ this.setState({ [value]: 'updated' });
+ };
+}
+
+const withPartialSetState = <ComponentWithPartialSetState />;
+
+let functionalProps: ComponentProps<typeof DummerComponent> = {
+ initialInput: '',
+ input: ''
+};
+
+let classProps: ComponentProps<typeof DummyComponent> = {
+ initialInput: ''
+};
+
+let elementProps: ComponentProps<'button'> = {
+ type: 'button'
+};
+
+// Typing of style property
+const acceptsNumberAsLength = <div style={{ marginTop: 20 }} />;
+const acceptsStringAsLength = <div style={{ marginTop: '20px' }} />;
diff --git a/preact/test/ts/refs.tsx b/preact/test/ts/refs.tsx
new file mode 100644
index 0000000..9edb730
--- /dev/null
+++ b/preact/test/ts/refs.tsx
@@ -0,0 +1,76 @@
+import {
+ createElement,
+ Component,
+ createRef,
+ FunctionalComponent,
+ Fragment,
+ RefObject,
+ RefCallback
+} from '../../';
+
+// Test Fixtures
+const Foo: FunctionalComponent = () => <span>Foo</span>;
+class Bar extends Component {
+ render() {
+ return <span>Bar</span>;
+ }
+}
+
+// Using Refs
+class CallbackRef extends Component {
+ divRef: RefCallback<HTMLDivElement> = div => {
+ if (div !== null) {
+ console.log(div.tagName);
+ }
+ };
+ fooRef: RefCallback<Component> = foo => {
+ if (foo !== null) {
+ console.log(foo.base);
+ }
+ };
+ barRef: RefCallback<Bar> = bar => {
+ if (bar !== null) {
+ console.log(bar.base);
+ }
+ };
+
+ render() {
+ return (
+ <Fragment>
+ <div ref={this.divRef} />
+ <Foo ref={this.fooRef} />
+ <Bar ref={this.barRef} />
+ </Fragment>
+ );
+ }
+}
+
+class CreateRefComponent extends Component {
+ private divRef: RefObject<HTMLDivElement> = createRef();
+ private fooRef: RefObject<Component> = createRef();
+ private barRef: RefObject<Bar> = createRef();
+
+ componentDidMount() {
+ if (this.divRef.current != null) {
+ console.log(this.divRef.current.tagName);
+ }
+
+ if (this.fooRef.current != null) {
+ console.log(this.fooRef.current.base);
+ }
+
+ if (this.barRef.current != null) {
+ console.log(this.barRef.current.base);
+ }
+ }
+
+ render() {
+ return (
+ <Fragment>
+ <div ref={this.divRef} />
+ <Foo ref={this.fooRef} />
+ <Bar ref={this.barRef} />
+ </Fragment>
+ );
+ }
+}
diff --git a/preact/test/ts/tsconfig.json b/preact/test/ts/tsconfig.json
new file mode 100644
index 0000000..36621f3
--- /dev/null
+++ b/preact/test/ts/tsconfig.json
@@ -0,0 +1,15 @@
+{
+ "compilerOptions": {
+ "target": "es6",
+ "module": "es6",
+ "moduleResolution": "node",
+ "lib": ["es6", "dom"],
+ "strict": true,
+ "typeRoots": ["../../"],
+ "types": [],
+ "forceConsistentCasingInFileNames": true,
+ "jsx": "react",
+ "jsxFactory": "createElement"
+ },
+ "include": ["./**/*.ts", "./**/*.tsx"]
+}