diff options
Diffstat (limited to 'preact/test')
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, '&'); +} + +/** + * 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 & 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 & 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"] +} |