From 281de73e71117e6236d95053cc7d87e42f6b6129 Mon Sep 17 00:00:00 2001 From: Dan Abramov Date: Wed, 8 Aug 2018 19:28:58 +0100 Subject: [PATCH] Add failing tests for resilience to Google Translate --- .../ReactDOMMutationResilience-test.js | 556 ++++++++++++++++++ 1 file changed, 556 insertions(+) create mode 100644 packages/react-dom/src/__tests__/ReactDOMMutationResilience-test.js diff --git a/packages/react-dom/src/__tests__/ReactDOMMutationResilience-test.js b/packages/react-dom/src/__tests__/ReactDOMMutationResilience-test.js new file mode 100644 index 0000000000000..1bc289a62ec06 --- /dev/null +++ b/packages/react-dom/src/__tests__/ReactDOMMutationResilience-test.js @@ -0,0 +1,556 @@ +/** + * Copyright (c) 2013-present, Facebook, Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @emails react-core + */ + +'use strict'; + +describe('ReactDOMMutationResilience', () => { + let React; + let ReactDOM; + + beforeEach(() => { + jest.resetModules(); + React = require('react'); + ReactDOM = require('react-dom'); + }); + + // Simulates what Google Translate (and likely others) do + // https://github.com/facebook/react/issues/11538 + const replaceTextNodesWithDOMElements = container => { + const walker = document.createTreeWalker( + container, + NodeFilter.SHOW_TEXT, + null, + false, + ); + let node; + let nodes = []; + while ((node = walker.nextNode())) { + nodes.push(node); + } + while ((node = nodes.pop())) { + const fontEl = document.createElement('font'); + fontEl.style.verticalAlign = 'middle'; + fontEl.textContent = node.textContent.toUpperCase(); + node.replaceWith(fontEl); + // For debugging the test suite: + node.__replacedElement = fontEl; + } + }; + + const simulateRender = children => { + let expectedResultAfterRender = []; + React.Children.forEach(children, child => { + if (child == null) { + return; + } + expectedResultAfterRender.push( + typeof child === 'number' ? child.toString() : child, + ); + }); + return expectedResultAfterRender; + }; + + const simulateMutation = (initialChildren, nextChildren) => { + const container = document.createElement('div'); + ReactDOM.render(
{initialChildren}
, container); + + const textNodeWalker = document.createTreeWalker( + container, + NodeFilter.SHOW_TEXT, + null, + false, + ); + let node; + while ((node = textNodeWalker.nextNode())) { + (function() { + let initV = node.nodeValue; + Object.defineProperty(node, 'nodeValue', { + get() { + return initV; + }, + set(v) {}, + }); + })(); + } + ReactDOM.render(
{nextChildren}
, container); + + let result = []; + for (let i = 0; i < container.firstChild.childNodes.length; i++) { + node = container.firstChild.childNodes[i]; + if (node.nodeType === 3) { + result.push(node.textContent); + } else { + result.push(React.createElement(node.tagName.toLowerCase())); + } + } + return result; + }; + + const testAllPermutations = testCases => { + for (let i = 0; i < testCases.length; i++) { + const renderWithChildren = testCases[i]; + + for (let j = 0; j < testCases.length; j++) { + const updateWithChildren = testCases[j]; + const container = document.createElement('div'); + + if (!Array.isArray(updateWithChildren)) { + // Don't check updates to a primitive because + // they work through setTextContent(). + continue; + } + + // Initial render + const expectedResultAfterRender = simulateRender(renderWithChildren); + ReactDOM.render(
{renderWithChildren}
, container); + expectNormalizedChildren(container, expectedResultAfterRender); + // First mutation + replaceTextNodesWithDOMElements(container); + expectNormalizedChildren(container, expectedResultAfterRender); + + // Update + const expectedResultAfterUpdateConsideringMutation = simulateMutation( + renderWithChildren, + updateWithChildren, + ); + ReactDOM.render(
{updateWithChildren}
, container); + expectNormalizedChildren( + container, + expectedResultAfterUpdateConsideringMutation, + ); + } + } + }; + + const expectNormalizedChildren = function(container, children) { + const outerNode = container.firstChild; + let node; + if (typeof children === 'string') { + node = outerNode.firstChild; + + if (children === '') { + expect(node != null).toBe(false); + } else { + expect(node != null).toBe(true); + // Ignore the simulated mutation: + const normalizedTextContext = node.textContent.toLowerCase(); + expect(normalizedTextContext).toBe('' + children); + } + } else { + let mountIndex = 0; + + if (children.length === 1 && children[0] === '') { + expect(outerNode.textContent).toBe(''); + return; + } + + for (let i = 0; i < children.length; i++) { + const child = children[i]; + + if (typeof child === 'string') { + node = outerNode.childNodes[mountIndex]; + // Ignore the simulated mutation: + const normalizedTextContext = node.textContent.toLowerCase(); + expect(normalizedTextContext).toBe('' + child); + mountIndex++; + } else { + const elementDOMNode = outerNode.childNodes[mountIndex]; + expect(elementDOMNode.tagName).toBe('DIV'); + mountIndex++; + } + } + } + }; + + it('recovers when updating a single text child', () => { + const container = document.createElement('div'); + ReactDOM.render(
{'aaa'}
, container); + expect(container.textContent).toBe('aaa'); + replaceTextNodesWithDOMElements(container); + expect(container.textContent).toBe('AAA'); + + ReactDOM.render(
{'bbb'}
, container); + expect(container.textContent).toBe('bbb'); + replaceTextNodesWithDOMElements(container); + expect(container.textContent).toBe('BBB'); + + ReactDOM.render(
{'ccc'}
, container); + expect(container.textContent).toBe('ccc'); + replaceTextNodesWithDOMElements(container); + expect(container.textContent).toBe('CCC'); + + ReactDOM.unmountComponentAtNode(container); + expect(container.textContent).toBe(''); + }); + + it('recovers replacing a single text child', () => { + const container = document.createElement('div'); + ReactDOM.render(
{'aaa'}
, container); + expect(container.textContent).toBe('aaa'); + replaceTextNodesWithDOMElements(container); + expect(container.textContent).toBe('AAA'); + + ReactDOM.render( +
+ {'bbb'} +
, + container, + ); + expect(container.textContent).toBe('bbb'); + replaceTextNodesWithDOMElements(container); + expect(container.textContent).toBe('BBB'); + + ReactDOM.render(
{'ccc'}
, container); + expect(container.textContent).toBe('ccc'); + replaceTextNodesWithDOMElements(container); + expect(container.textContent).toBe('CCC'); + + ReactDOM.unmountComponentAtNode(container); + expect(container.textContent).toBe(''); + }); + + it('recovers removing a single text child', () => { + const container = document.createElement('div'); + ReactDOM.render(
{'aaa'}
, container); + expect(container.textContent).toBe('aaa'); + replaceTextNodesWithDOMElements(container); + expect(container.textContent).toBe('AAA'); + + ReactDOM.render(
, container); + expect(container.textContent).toBe(''); + replaceTextNodesWithDOMElements(container); + expect(container.textContent).toBe(''); + + ReactDOM.unmountComponentAtNode(container); + expect(container.textContent).toBe(''); + }); + + it('recovers when updating a single text child out of many', () => { + const container = document.createElement('div'); + ReactDOM.render( +
+ {'aaa'} + {'mmm'} + {'xxx'} +
, + container, + ); + expect(container.textContent).toBe('aaammmxxx'); + replaceTextNodesWithDOMElements(container); + expect(container.textContent).toBe('AAAMMMXXX'); + + ReactDOM.render( +
+ {'aaa'} + {'nnn'} + {'xxx'} +
, + container, + ); + // Our reference to the second child is detached + // so it's no longer expected to update correctly. + // Nevertheless we continue without errors. + expect(container.textContent).toBe('AAAMMMXXX'); + replaceTextNodesWithDOMElements(container); + expect(container.textContent).toBe('AAAMMMXXX'); + + ReactDOM.unmountComponentAtNode(container); + expect(container.textContent).toBe(''); + }); + + it('recovers replacing a single text child out of many', () => { + const container = document.createElement('div'); + ReactDOM.render( +
+ {'aaa'} + {'mmm'} + {'xxx'} +
, + container, + ); + expect(container.textContent).toBe('aaammmxxx'); + replaceTextNodesWithDOMElements(container); + expect(container.textContent).toBe('AAAMMMXXX'); + + ReactDOM.render( +
+ {'aaa'} + {'nnn'} + {'xxx'} +
, + container, + ); + expect(container.textContent).toBe('AAAnnnXXX'); + replaceTextNodesWithDOMElements(container); + expect(container.textContent).toBe('AAANNNXXX'); + + ReactDOM.unmountComponentAtNode(container); + expect(container.textContent).toBe(''); + }); + + it('recovers removing a single text child out of many', () => { + const container = document.createElement('div'); + ReactDOM.render( +
+ {'aaa'} + {'mmm'} + {'xxx'} +
, + container, + ); + expect(container.textContent).toBe('aaammmxxx'); + replaceTextNodesWithDOMElements(container); + expect(container.textContent).toBe('AAAMMMXXX'); + + ReactDOM.render( +
+ {'aaa'} + {null} + {'xxx'} +
, + container, + ); + expect(container.textContent).toBe('AAAXXX'); + replaceTextNodesWithDOMElements(container); + expect(container.textContent).toBe('AAAXXX'); + + ReactDOM.render(
, container); + expect(container.textContent).toBe(''); + replaceTextNodesWithDOMElements(container); + expect(container.textContent).toBe(''); + + ReactDOM.unmountComponentAtNode(container); + expect(container.textContent).toBe(''); + }); + + it('recovers when appending a child before text', () => { + const container = document.createElement('div'); + ReactDOM.render( +
+ {null} + {'xxx'} +
, + container, + ); + expect(container.textContent).toBe('xxx'); + replaceTextNodesWithDOMElements(container); + expect(container.textContent).toBe('XXX'); + + ReactDOM.render( +
+ {'aaa'} + {'yyy'} +
, + container, + ); + // Our reference to the second child is detached + // so it's no longer expected to update correctly. + // Nevertheless we continue without errors. + expect(container.textContent).toBe('aaaXXX'); + replaceTextNodesWithDOMElements(container); + expect(container.textContent).toBe('AAAXXX'); + + ReactDOM.render( +
+ {'bbb'} + {'zzz'} +
, + container, + ); + expect(container.textContent).toBe('bbbXXX'); + replaceTextNodesWithDOMElements(container); + expect(container.textContent).toBe('BBBXXX'); + + ReactDOM.unmountComponentAtNode(container); + expect(container.textContent).toBe(''); + }); + + it('recovers when appending a child before text and element', () => { + const container = document.createElement('div'); + ReactDOM.render( +
+ {'aaa'} + {null} + {'mmm'} + {'xxx'} +
, + container, + ); + expect(container.textContent).toBe('aaammmxxx'); + replaceTextNodesWithDOMElements(container); + expect(container.textContent).toBe('AAAMMMXXX'); + + ReactDOM.render( +
+ {'bbb'} + {'fff'} + {'nnn'} + {'yyy'} +
, + container, + ); + // Our references to the third child is detached + // so it's no longer expected to update correctly. + // Nevertheless we continue without errors. + expect(container.textContent).toBe('bbbfffMMMyyy'); + replaceTextNodesWithDOMElements(container); + expect(container.textContent).toBe('BBBFFFMMMYYY'); + + ReactDOM.render( +
+ {'ccc'} + {'ggg'} + {'ooo'} + {'zzz'} +
, + container, + ); + // Our references to the second and third children are detached + // so they're no longer expected to update correctly. + // Nevertheless we continue without errors. + expect(container.textContent).toBe('cccFFFMMMzzz'); + replaceTextNodesWithDOMElements(container); + expect(container.textContent).toBe('CCCFFFMMMZZZ'); + + ReactDOM.unmountComponentAtNode(container); + expect(container.textContent).toBe(''); + }); + + it('recovers when appending a child after text', () => { + const container = document.createElement('div'); + ReactDOM.render(
{'aaa'}
, container); + expect(container.textContent).toBe('aaa'); + replaceTextNodesWithDOMElements(container); + expect(container.textContent).toBe('AAA'); + + ReactDOM.render( +
+ {'bbb'} + {'xxx'} +
, + container, + ); + expect(container.textContent).toBe('bbbxxx'); + replaceTextNodesWithDOMElements(container); + expect(container.textContent).toBe('BBBXXX'); + + ReactDOM.render( +
+ {'ccc'} + {'zzz'} +
, + container, + ); + // Our reference to the first child is detached + // so it's no longer expected to update correctly. + // Nevertheless we continue without errors. + expect(container.textContent).toBe('BBBzzz'); + replaceTextNodesWithDOMElements(container); + expect(container.textContent).toBe('BBBZZZ'); + + ReactDOM.unmountComponentAtNode(container); + expect(container.textContent).toBe(''); + }); + + it('should correctly handle all possible children for render and update', () => { + expect(() => { + // prettier-ignore + testAllPermutations([ + // basic values + undefined, + null, + false, + true, + 0, + 1.2, + '', + 'foo', + + [], + [undefined], + [null], + [false], + [true], + [0], + [1.2], + [''], + ['foo'], + [
], + + // two adjacent values + [true, 0], + [0, 0], + [1.2, 0], + [0, ''], + ['foo', 0], + [0,
], + + [true, 1.2], + [1.2, 0], + [1.2, 1.2], + [1.2, ''], + ['foo', 1.2], + [1.2,
], + + [true, ''], + ['', 0], + [1.2, ''], + ['', ''], + ['foo', ''], + ['',
], + + [true, 'foo'], + ['foo', 0], + [1.2, 'foo'], + ['foo', ''], + ['foo', 'foo'], + ['foo',
], + + // values separated by an element + [true,
, true], + [1.2,
, 1.2], + ['',
, ''], + ['foo',
, 'foo'], + + [true, 1.2,
, ''], + [true, 1.2,
, '', 'foo'], + [1.2, '',
, 'foo', true], + ['', 'foo',
, true, 1.2], + + [true, 1.2, '',
, 'foo', true, 1.2], + ['', 'foo', true,
, 1.2, '', 'foo'], + + // values inside arrays + [[true], [true]], + [[1.2], [1.2]], + [[''], ['']], + [['foo'], ['foo']], + [[
], [
]], + + [[true, 1.2,
], '', 'foo'], + [1.2, '', [
, 'foo', true]], + ['', ['foo',
, true], 1.2], + + [true, [1.2, '',
, 'foo'], true, 1.2], + ['', 'foo', [true,
, 1.2, ''], 'foo'], + + // values inside elements + [
{true}{1.2}{
}
, '', 'foo'], + [1.2, '',
{
}{'foo'}{true}
], + ['',
{'foo'}{
}{true}
, 1.2], + + [true,
{1.2}{''}{
}{'foo'}
, true, 1.2], + ['', 'foo',
{true}{
}{1.2}{''}
, 'foo'], + ]); + }).toWarnDev([ + 'Warning: Each child in an array or iterator should have a unique "key" prop.', + 'Warning: Each child in an array or iterator should have a unique "key" prop.', + ]); + }); +});