Make mounting on the root of the page work correctly
This was apparently only partially supported. We had issues initially mounting if there was no HTML present and also had issues if we had to update HTML that was already there. This diff fixes all of these cases and has tests to prove it. NOTE: I removed a test that was actually erroneous. My bad.
Pete Hunt committed
Sep 5, 2013 at 13:49 UTC
c8886a0424c426818566c21d5a69c4cb8f5d41e3
8 files changed
+436
-177
src/core/ReactComponentBrowserEnvironment.js
+9
@@ -27,6 +27,7 @@ var ReactReconcileTransaction = require('ReactReconcileTransaction');
27
28
var getReactRootElementInContainer = require('getReactRootElementInContainer');
29
var invariant = require('invariant');
30
+var mutateHTMLNodeWithMarkup = require('mutateHTMLNodeWithMarkup');
31
32
33
var ELEMENT_NODE_TYPE = 1;
@@ -107,6 +108,14 @@ var ReactComponentBrowserEnvironment = {
108
}
109
}
110
111
+ // You can't naively set the innerHTML of the entire document. You need
112
+ // to mutate documentElement which requires doing some crazy tricks. See
113
+ // mutateHTMLNodeWithMarkup()
114
+ if (container.nodeType === DOC_NODE_TYPE) {
115
+ mutateHTMLNodeWithMarkup(container.documentElement, markup);
116
+ return;
117
+ }
118
+
119
// Asynchronously inject markup by ensuring that the container is not in
120
// the document when settings its `innerHTML`.
121
var parent = container.parentNode;
src/core/__tests__/ReactComponent-test.js
-177
@@ -20,194 +20,17 @@
20
"use strict";
21
22
var React;
23
-var ReactMount;
23
var ReactTestUtils;
24
25
var reactComponentExpect;
26
27
describe('ReactComponent', function() {
28
beforeEach(function() {
30
- require('mock-modules').dumpCache();
31
-
29
React = require('React');
33
- ReactMount = require('ReactMount');
30
ReactTestUtils = require('ReactTestUtils');
31
reactComponentExpect = require('reactComponentExpect');
32
});
33
38
- it('should be able to switch root constructors via state', function() {
39
- var Component = React.createClass({
40
- render: function() {
41
- return (
42
- <html>
43
- <head>
44
- <title>Hello World</title>
45
- </head>
46
- <body>
47
- Hello world
48
- </body>
49
- </html>
50
- );
51
- }
52
- });
53
-
54
- var Component2 = React.createClass({
55
- render: function() {
56
- return (
57
- <html>
58
- <head>
59
- <title>Hello World</title>
60
- </head>
61
- <body>
62
- Goodbye world
63
- </body>
64
- </html>
65
- );
66
- }
67
- });
68
-
69
- var Root = React.createClass({
70
- getInitialState: function() {
71
- return {toggled: false};
72
- },
73
- toggle: function() {
74
- this.setState({toggled: !this.state.toggled});
75
- },
76
- render: function() {
77
- if (this.state.toggled) {
78
- return <Component2 />;
79
- }
80
- return <Component />;
81
- }
82
- });
83
-
84
- ReactMount.allowFullPageRender = true;
85
- var component = React.renderComponent(<Root />, document);
86
-
87
- expect(document.body.innerHTML).toBe(' Hello world ');
88
-
89
- // Reactive update via state transition
90
- component.toggle();
91
-
92
- expect(document.body.innerHTML).toBe(' Goodbye world ');
93
-
94
- });
95
-
96
- it('should be able to switch root constructors', function() {
97
- var Component = React.createClass({
98
- render: function() {
99
- return (
100
- <html>
101
- <head>
102
- <title>Hello World</title>
103
- </head>
104
- <body>
105
- Hello world
106
- </body>
107
- </html>
108
- );
109
- }
110
- });
111
-
112
- var Component2 = React.createClass({
113
- render: function() {
114
- return (
115
- <html>
116
- <head>
117
- <title>Hello World</title>
118
- </head>
119
- <body>
120
- Goodbye world
121
- </body>
122
- </html>
123
- );
124
- }
125
- });
126
-
127
- ReactMount.allowFullPageRender = true;
128
- React.renderComponent(<Component />, document);
129
-
130
- expect(document.body.innerHTML).toBe(' Hello world ');
131
-
132
- // Reactive update
133
- React.renderComponent(<Component2 />, document);
134
-
135
- expect(document.body.innerHTML).toBe(' Goodbye world ');
136
-
137
- });
138
-
139
- it('should be able to update a root component', function() {
140
- var Component = React.createClass({
141
- render: function() {
142
- return (
143
- <html>
144
- <head>
145
- <title>Hello World</title>
146
- </head>
147
- <body>
148
- {this.props.text}
149
- </body>
150
- </html>
151
- );
152
- }
153
- });
154
- ReactMount.allowFullPageRender = true;
155
- React.renderComponent(<Component text="Hello world" />, document);
156
-
157
- expect(document.body.innerHTML).toBe('Hello world');
158
-
159
- // Add a sentinel to be sure we don't blow away old nodes
160
- document.getElementsByTagName('title')[0].setAttribute(
161
- 'data-yolo-king',
162
- 'phunt'
163
- );
164
- var oldHead = document.head.innerHTML;
165
- expect(oldHead.indexOf('data-yolo-king') > -1).toBe(true);
166
-
167
- // Reactive update
168
- React.renderComponent(<Component text="Goodbye world" />, document);
169
-
170
- expect(document.body.innerHTML).toBe('Goodbye world');
171
-
172
- // Head has not changed, nodes were not blown away.
173
- expect(document.head.innerHTML).toEqual(oldHead);
174
- });
175
-
176
- it('should be able to mount into document', function() {
177
- var Component = React.createClass({
178
- render: function() {
179
- return (
180
- <html>
181
- <head>
182
- <title>Hello World</title>
183
- </head>
184
- <body>
185
- {this.props.text}
186
- </body>
187
- </html>
188
- );
189
- }
190
- });
191
- ReactMount.allowFullPageRender = true;
192
- React.renderComponent(<Component text="Hello world" />, document);
193
-
194
- expect(document.body.innerHTML).toBe('Hello world');
195
- });
196
-
197
- it('should not throw on full document rendering', function() {
198
- var container = {nodeType: 9};
199
- expect(function() {
200
- React.renderComponent(<div></div>, container);
201
- }).toThrow(
202
- 'Invariant Violation: mountComponentIntoNode(...): Target container is ' +
203
- 'not valid.'
204
- );
205
- ReactMount.allowFullPageRender = true;
206
- expect(function() {
207
- React.renderComponent(<div></div>, container);
208
- }).not.toThrow();
209
- });
210
-
34
it('should throw on invalid render targets', function() {
35
var container = document.createElement('div');
36
// jQuery objects are basically arrays; people often pass them in by mistake
src/core/__tests__/ReactRenderDocument-test.js
new
+191
@@ -0,0 +1,191 @@
1
+/**
2
+ * Copyright 2013 Facebook, Inc.
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * http://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ *
16
+ * @jsx React.DOM
17
+ * @emails react-core
18
+ */
19
+
20
+"use strict";
21
+
22
+var React;
23
+var ReactMount;
24
+
25
+var getTestDocument;
26
+
27
+var testDocument;
28
+
29
+describe('rendering React components at document', function() {
30
+ beforeEach(function() {
31
+ require('mock-modules').dumpCache();
32
+
33
+ React = require('React');
34
+ ReactMount = require('ReactMount');
35
+ getTestDocument = require('getTestDocument');
36
+
37
+ testDocument = getTestDocument();
38
+ });
39
+
40
+ if (!testDocument) {
41
+ // These tests are not applicable in jst, since jsdom is buggy.
42
+ return;
43
+ }
44
+
45
+ it('should be able to switch root constructors via state', function() {
46
+ var Component = React.createClass({
47
+ render: function() {
48
+ return (
49
+ <html>
50
+ <head>
51
+ <title>Hello World</title>
52
+ </head>
53
+ <body>
54
+ Hello world
55
+ </body>
56
+ </html>
57
+ );
58
+ }
59
+ });
60
+
61
+ var Component2 = React.createClass({
62
+ render: function() {
63
+ return (
64
+ <html>
65
+ <head>
66
+ <title>Hello World</title>
67
+ </head>
68
+ <body>
69
+ Goodbye world
70
+ </body>
71
+ </html>
72
+ );
73
+ }
74
+ });
75
+
76
+ var Root = React.createClass({
77
+ getInitialState: function() {
78
+ return {toggled: false};
79
+ },
80
+ toggle: function() {
81
+ this.setState({toggled: !this.state.toggled});
82
+ },
83
+ render: function() {
84
+ if (this.state.toggled) {
85
+ return <Component2 />;
86
+ }
87
+ return <Component />;
88
+ }
89
+ });
90
+
91
+ ReactMount.allowFullPageRender = true;
92
+ var component = React.renderComponent(<Root />, testDocument);
93
+ expect(testDocument.body.innerHTML).toBe(' Hello world ');
94
+
95
+ // Reactive update via state transition
96
+ component.toggle();
97
+
98
+ expect(testDocument.body.innerHTML).toBe(' Goodbye world ');
99
+
100
+ });
101
+
102
+ it('should be able to switch root constructors', function() {
103
+ var Component = React.createClass({
104
+ render: function() {
105
+ return (
106
+ <html>
107
+ <head>
108
+ <title>Hello World</title>
109
+ </head>
110
+ <body>
111
+ Hello world
112
+ </body>
113
+ </html>
114
+ );
115
+ }
116
+ });
117
+
118
+ var Component2 = React.createClass({
119
+ render: function() {
120
+ return (
121
+ <html>
122
+ <head>
123
+ <title>Hello World</title>
124
+ </head>
125
+ <body>
126
+ Goodbye world
127
+ </body>
128
+ </html>
129
+ );
130
+ }
131
+ });
132
+
133
+ ReactMount.allowFullPageRender = true;
134
+ React.renderComponent(<Component />, testDocument);
135
+
136
+ expect(testDocument.body.innerHTML).toBe(' Hello world ');
137
+
138
+ // Reactive update
139
+ React.renderComponent(<Component2 />, testDocument);
140
+
141
+ expect(testDocument.body.innerHTML).toBe(' Goodbye world ');
142
+
143
+ });
144
+
145
+ it('should be able to mount into document', function() {
146
+ var Component = React.createClass({
147
+ render: function() {
148
+ return (
149
+ <html>
150
+ <head>
151
+ <title>Hello World</title>
152
+ </head>
153
+ <body>
154
+ {this.props.text}
155
+ </body>
156
+ </html>
157
+ );
158
+ }
159
+ });
160
+ ReactMount.allowFullPageRender = true;
161
+ React.renderComponent(<Component text="Hello world" />, testDocument);
162
+
163
+ expect(testDocument.body.innerHTML).toBe('Hello world');
164
+ });
165
+
166
+ it('should throw on full document render', function() {
167
+ var container = testDocument;
168
+ expect(function() {
169
+ React.renderComponent(<html />, container);
170
+ }).toThrow(
171
+ 'Invariant Violation: mountComponentIntoNode(...): Target container is ' +
172
+ 'not valid.'
173
+ );
174
+ ReactMount.allowFullPageRender = true;
175
+ expect(function() {
176
+ React.renderComponent(<html />, container);
177
+ }).not.toThrow();
178
+ });
179
+
180
+ it('should throw on full document render of non-html', function() {
181
+ var container = testDocument;
182
+ ReactMount.allowFullPageRender = true;
183
+ expect(function() {
184
+ React.renderComponent(<div />, container);
185
+ }).toThrow(
186
+ 'Invariant Violation: mutateHTMLNodeWithMarkup(): ' +
187
+ 'markup must start with <html'
188
+ );
189
+ });
190
+
191
+});
src/dom/Danger.js
+8
@@ -27,9 +27,11 @@ var createNodesFromMarkup = require('createNodesFromMarkup');
27
var emptyFunction = require('emptyFunction');
28
var getMarkupWrap = require('getMarkupWrap');
29
var invariant = require('invariant');
30
+var mutateHTMLNodeWithMarkup = require('mutateHTMLNodeWithMarkup');
31
32
var COMMENT_NODE_TYPE = 8;
33
34
+
35
/**
36
* Extracts the `nodeName` from a string of markup.
37
*
@@ -199,6 +201,12 @@ var Danger = {
201
'immediately.'
202
);
203
invariant(markup, 'dangerouslyReplaceNodeWithMarkup(...): Missing markup.');
204
+ // createNodesFromMarkup() won't work if the markup is rooted by <html>
205
+ // since it has special semantic meaning. So we use an alternatie strategy.
206
+ if (oldChild.tagName.toLowerCase() === 'html') {
207
+ mutateHTMLNodeWithMarkup(oldChild, markup);
208
+ return;
209
+ }
210
var newChild = createNodesFromMarkup(markup, emptyFunction)[0];
211
oldChild.parentNode.replaceChild(newChild, oldChild);
212
}
src/dom/__tests__/mutateHTMLNodeWithMarkup-test.js
new
+50
@@ -0,0 +1,50 @@
1
+/**
2
+ * Copyright 2013 Facebook, Inc.
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * http://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ *
16
+ * @jsx React.DOM
17
+ * @emails react-core
18
+ */
19
+
20
+"use strict";
21
+
22
+var getTestDocument = require('getTestDocument');
23
+
24
+var mutateHTMLNodeWithMarkup = require('mutateHTMLNodeWithMarkup');
25
+
26
+describe('mutateHTMLNodeWithMarkup', function() {
27
+ it('should mutate the document html', function() {
28
+ var html = '<html><head><title>test</title></head><body>test</body></html>';
29
+ var testDocument = getTestDocument() || document;
30
+
31
+ mutateHTMLNodeWithMarkup(testDocument.documentElement, html);
32
+ expect(testDocument.body.innerHTML).toBe('test');
33
+ });
34
+
35
+ it('should change attributes', function() {
36
+ var html = '<html><head><title>test</title></head><body>test</body></html>';
37
+ var testDocument = getTestDocument() || document;
38
+
39
+ mutateHTMLNodeWithMarkup(testDocument.documentElement, html);
40
+ expect(!!testDocument.documentElement.getAttribute('lang')).toBe(false);
41
+
42
+ var html2 = '<html lang="en"><head><title>test</title></head>' +
43
+ '<body>test</body></html>';
44
+ mutateHTMLNodeWithMarkup(testDocument.documentElement, html2);
45
+ expect(testDocument.documentElement.getAttribute('lang')).toBe('en');
46
+
47
+ mutateHTMLNodeWithMarkup(testDocument.documentElement, html);
48
+ expect(!!testDocument.documentElement.getAttribute('lang')).toBe(false);
49
+ });
50
+});
src/dom/filterAttributes.js
new
+45
@@ -0,0 +1,45 @@
1
+/**
2
+ * Copyright 2013 Facebook, Inc.
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * http://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ *
16
+ * @providesModule filterAttributes
17
+ * @typechecks static-only
18
+ */
19
+
20
+/*jslint evil: true */
21
+
22
+'use strict';
23
+
24
+/**
25
+ * Like filter(), but for a DOM nodes attributes. Returns an array of
26
+ * the filter DOMAttribute objects. Does some perf related this like
27
+ * caching attributes.length.
28
+ *
29
+ * @param {DOMElement} node Node whose attributes you want to filter
30
+ * @return {array} array of DOM attribute objects.
31
+ */
32
+function filterAttributes(node, func, context) {
33
+ var attributes = node.attributes;
34
+ var numAttributes = attributes.length;
35
+ var accumulator = [];
36
+ for (var i = 0; i < numAttributes; i++) {
37
+ var attr = attributes.item(i);
38
+ if (func.call(context, attr)) {
39
+ accumulator.push(attr);
40
+ }
41
+ }
42
+ return accumulator;
43
+}
44
+
45
+module.exports = filterAttributes;
src/dom/mutateHTMLNodeWithMarkup.js
new
+100
@@ -0,0 +1,100 @@
1
+/**
2
+ * Copyright 2013 Facebook, Inc.
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * http://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ *
16
+ * @providesModule mutateHTMLNodeWithMarkup
17
+ * @typechecks static-only
18
+ */
19
+
20
+/*jslint evil: true */
21
+
22
+'use strict';
23
+
24
+var createNodesFromMarkup = require('createNodesFromMarkup');
25
+var filterAttributes = require('filterAttributes');
26
+var invariant = require('invariant');
27
+
28
+/**
29
+ * You can't set the innerHTML of a document. Unless you have
30
+ * this function.
31
+ *
32
+ * @param {DOMElement} node with tagName == 'html'
33
+ * @param {string} markup markup string including <html>.
34
+ */
35
+function mutateHTMLNodeWithMarkup(node, markup) {
36
+ invariant(
37
+ node.tagName.toLowerCase() === 'html',
38
+ 'mutateHTMLNodeWithMarkup(): node must have tagName of "html", got %s',
39
+ node.tagName
40
+ );
41
+
42
+ markup = markup.trim();
43
+ invariant(
44
+ markup.toLowerCase().indexOf('<html') === 0,
45
+ 'mutateHTMLNodeWithMarkup(): markup must start with <html'
46
+ );
47
+
48
+ // First let's extract the various pieces of markup.
49
+ var htmlOpenTagEnd = markup.indexOf('>') + 1;
50
+ var htmlCloseTagStart = markup.lastIndexOf('<');
51
+ var htmlOpenTag = markup.substring(0, htmlOpenTagEnd);
52
+ var innerHTML = markup.substring(htmlOpenTagEnd, htmlCloseTagStart);
53
+
54
+ // Now for the fun stuff. Pass through both sets of attributes and
55
+ // bring them up-to-date. We get the new set by creating a markup
56
+ // fragment.
57
+ var shouldExtractAttributes = htmlOpenTag.indexOf(' ') > -1;
58
+ var attributeHolder = null;
59
+
60
+ if (shouldExtractAttributes) {
61
+ // We extract the attributes by creating a <span> and evaluating
62
+ // the node.
63
+ attributeHolder = createNodesFromMarkup(
64
+ htmlOpenTag.replace('html ', 'span ') + '</span>'
65
+ )[0];
66
+
67
+ // Add all attributes present in attributeHolder
68
+ var attributesToSet = filterAttributes(
69
+ attributeHolder,
70
+ function(attr) {
71
+ return node.getAttributeNS(attr.namespaceURI, attr.name) !== attr.value;
72
+ }
73
+ );
74
+ attributesToSet.forEach(function(attr) {
75
+ node.setAttributeNS(attr.namespaceURI, attr.name, attr.value);
76
+ });
77
+ }
78
+
79
+ // Remove all attributes not present in attributeHolder
80
+ var attributesToRemove = filterAttributes(
81
+ node,
82
+ function(attr) {
83
+ // Remove all attributes if attributeHolder is null or if it does not have
84
+ // the desired attribute.
85
+ return !(
86
+ attributeHolder &&
87
+ attributeHolder.hasAttributeNS(attr.namespaceURI, attr.name)
88
+ );
89
+ }
90
+ );
91
+ attributesToRemove.forEach(function(attr) {
92
+ node.removeAttributeNS(attr.namespaceURI, attr.name);
93
+ });
94
+
95
+ // Finally, set the inner HTML. No tricks needed. Do this last to
96
+ // minimize likelihood of triggering reflows.
97
+ node.innerHTML = innerHTML;
98
+}
99
+
100
+module.exports = mutateHTMLNodeWithMarkup;
src/test/getTestDocument.js
new
+33
@@ -0,0 +1,33 @@
1
+/**
2
+ * Copyright 2013 Facebook, Inc.
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * http://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ *
16
+ * @providesModule getTestDocument
17
+ */
18
+
19
+/**
20
+ * We need to work around the fact that we have two different
21
+ * test implementations: once that breaks if we clobber document
22
+ * (open-source) and one that doesn't support createHTMLDocument()
23
+ * (jst).
24
+ */
25
+function getTestDocument() {
26
+ if (document.implementation &&
27
+ document.implementation.createHTMLDocument) {
28
+ return document.implementation.createHTMLDocument('test doc');
29
+ }
30
+ return null;
31
+}
32
+
33
+module.exports = getTestDocument;