Improve hydration fixture, support older versions of React (#14118)

* Hydration Fixture: Only load ReactDOMServer if it exists

Fixes an issue where the hydration fixture would try to load in
ReactDOMServer below version 14. In version 13, string markup methods
exist on the React namespace.

* DOM Fixtures: Use class component for App.js

This was breaking React 0.13.0.

* Hydration Fixture: better findDOMNode compatibility

This commit fixes an issue where the Hydration DOM fixture was
unusable in React 0.13.0 or lower because of newer API usage.

It fixes that by avoiding the use of refs to get the textarea
reference in the code editor component, using various versions of
findDOMNode as required.

* Hydration Fixture: Do not show dropdown for single-line errors

If an error showed for the hydration fixture, a detail element was
used even if no additional lines could display. In that case, this
commit changes the component such that it returns a div.

* Deeper React version support for hydration fixture

This commit adds support for versions 0.4.0 of React and higher for
the hydration fixture.

The DOM test fixtures themselves do not support down to React 0.4.0,
which would be exhaustive. Instead, the Hydration fixture can pick a
version to use for its own purposes. By default, this is the version
of React used by the fixtures.

In the process of doing this, I had to make some updates to the
renderer.html document associated with the hydration fixture, and I've
added some comments to better document the history of API changes.
This commit is contained in:
Nathan Hunzaker
2019-03-13 15:12:49 -07:00
committed by GitHub
parent 1204c78977
commit 679402a66b
9 changed files with 235 additions and 72 deletions
+79 -19
View File
@@ -13,6 +13,30 @@
var renders = 0;
var failed = false;
var needsReactDOM = getBooleanQueryParam('needsReactDOM');
var needsCreateElement = getBooleanQueryParam('needsCreateElement');
function unmountComponent(node) {
// ReactDOM was moved into a separate package in 0.14
if (needsReactDOM) {
ReactDOM.unmountComponentAtNode(node);
} else if (React.unmountComponentAtNode) {
React.unmountComponentAtNode(node);
} else {
// Unmounting for React 0.4 and lower
React.unmountAndReleaseReactRootNode(node);
}
}
function createElement(value) {
// React.createElement replaced function invocation in 0.12
if (needsCreateElement) {
return React.createElement(value);
} else {
return value();
}
}
function getQueryParam(key) {
var pattern = new RegExp(key + '=([^&]+)(&|$)');
var matches = window.location.search.match(pattern);
@@ -35,20 +59,56 @@
function prerender() {
setStatus('Generating markup');
output.innerHTML = ReactDOMServer.renderToString(
React.createElement(Fixture)
);
return Promise.resolve()
.then(function() {
const element = createElement(Fixture);
setStatus('Markup only (No React)');
// Server rendering moved to a separate package along with ReactDOM
// in 0.14.0
if (needsReactDOM) {
return ReactDOMServer.renderToString(element);
}
// React.renderComponentToString was renamed in 0.12
if (React.renderToString) {
return React.renderToString(element);
}
// React.renderComponentToString became synchronous in React 0.9.0
if (React.renderComponentToString.length === 1) {
return React.renderComponentToString(element);
}
// Finally, React 0.4 and lower emits markup in a callback
return new Promise(function(resolve) {
React.renderComponentToString(element, resolve);
});
})
.then(function(string) {
output.innerHTML = string;
setStatus('Markup only (No React)');
})
.catch(handleError);
}
function render() {
setStatus('Hydrating');
if (ReactDOM.hydrate) {
ReactDOM.hydrate(React.createElement(Fixture), output);
var element = createElement(Fixture);
// ReactDOM was split out into another package in 0.14
if (needsReactDOM) {
// Hydration changed to a separate method in React 16
if (ReactDOM.hydrate) {
ReactDOM.hydrate(element, output);
} else {
ReactDOM.render(element, output);
}
} else if (React.render) {
// React.renderComponent was renamed in 0.12
React.render(element, output);
} else {
ReactDOM.render(React.createElement(Fixture), output);
React.renderComponent(element, output);
}
setStatus(renders > 0 ? 'Re-rendered (' + renders + 'x)' : 'Hydrated');
@@ -85,17 +145,17 @@
setStatus('Failed');
output.innerHTML = 'Please name your root component "Fixture"';
} else {
prerender();
if (getBooleanQueryParam('hydrate')) {
render();
}
prerender().then(function() {
if (getBooleanQueryParam('hydrate')) {
render();
}
});
}
}
function reloadFixture(code) {
renders = 0;
ReactDOM.unmountComponentAtNode(output);
unmountComponent(output);
injectFixture(code);
}
@@ -109,12 +169,12 @@
loadScript(getQueryParam('reactPath'))
.then(function() {
return getBooleanQueryParam('needsReactDOM')
? loadScript(getQueryParam('reactDOMPath'))
: null;
})
.then(function() {
return loadScript(getQueryParam('reactDOMServerPath'));
if (needsReactDOM) {
return Promise.all([
loadScript(getQueryParam('reactDOMPath')),
loadScript(getQueryParam('reactDOMServerPath')),
]);
}
})
.then(function() {
if (failed) {
+9 -7
View File
@@ -4,13 +4,15 @@ import '../style.css';
const React = window.React;
function App() {
return (
<div>
<Header />
<Fixtures />
</div>
);
class App extends React.Component {
render() {
return (
<div>
<Header />
<Fixtures />
</div>
);
}
}
export default App;
+16 -21
View File
@@ -1,5 +1,6 @@
import {parse, stringify} from 'query-string';
import getVersionTags from '../tags';
import VersionPicker from './VersionPicker';
const React = window.React;
class Header extends React.Component {
@@ -9,18 +10,12 @@ class Header extends React.Component {
const version = query.version || 'local';
const production = query.production || false;
const versions = [version];
this.state = {version, versions, production};
}
componentWillMount() {
getVersionTags().then(tags => {
let versions = tags.map(tag => tag.name.slice(1));
versions = [`local`, ...versions];
this.setState({versions});
});
}
handleVersionChange(event) {
handleVersionChange(version) {
const query = parse(window.location.search);
query.version = event.target.value;
query.version = version;
if (query.version === 'local') {
delete query.version;
}
@@ -48,7 +43,10 @@ class Header extends React.Component {
width="20"
height="20"
/>
<a href="/">DOM Test Fixtures (v{React.version})</a>
<a href="/">
DOM Test Fixtures (v
{React.version})
</a>
</span>
<div className="header-controls">
@@ -90,17 +88,14 @@ class Header extends React.Component {
<option value="/suspense">Suspense</option>
</select>
</label>
<label htmlFor="react_version">
<label htmlFor="global_version">
<span className="sr-only">Select a version to test</span>
<select
value={this.state.version}
onChange={this.handleVersionChange}>
{this.state.versions.map(version => (
<option key={version} value={version}>
{version}
</option>
))}
</select>
<VersionPicker
id="global_version"
name="global_version"
version={this.state.version}
onChange={this.handleVersionChange}
/>
</label>
</div>
</div>
@@ -0,0 +1,41 @@
import getVersionTags from '../tags';
const React = window.React;
class VersionPicker extends React.Component {
constructor(props, context) {
super(props, context);
const version = props.version || 'local';
const versions = [version];
this.state = {versions};
}
componentWillMount() {
getVersionTags().then(tags => {
let versions = tags.map(tag => tag.name.slice(1));
versions = [`local`, ...versions];
this.setState({versions});
});
}
onChange = event => {
this.props.onChange(event.target.value);
};
render() {
const {version, id, name} = this.props;
const {versions} = this.state;
return (
<select id={id} name={name} value={version} onChange={this.onChange}>
{versions.map(version => (
<option key={version} value={version}>
{version}
</option>
))}
</select>
);
}
}
export default VersionPicker;
@@ -1,3 +1,5 @@
import {findDOMNode} from '../../../find-dom-node';
const React = window.React;
export class CodeEditor extends React.Component {
@@ -6,6 +8,8 @@ export class CodeEditor extends React.Component {
}
componentDidMount() {
this.textarea = findDOMNode(this);
// Important: CodeMirror incorrectly lays out the editor
// if it executes before CSS has loaded
// https://github.com/graphql/graphiql/issues/33#issuecomment-318188555
@@ -44,7 +48,6 @@ export class CodeEditor extends React.Component {
render() {
return (
<textarea
ref={ref => (this.textarea = ref)}
defaultValue={this.props.code}
autoComplete="off"
hidden={true}
@@ -72,6 +75,10 @@ export class CodeError extends React.Component {
if (supportsDetails) {
const [summary, ...body] = error.message.split(/\n+/g);
if (body.length >= 0) {
return <div className={className}>{summary}</div>;
}
return (
<details className={className}>
<summary>{summary}</summary>
@@ -22,6 +22,7 @@
.hydration-options label {
font-size: 13px;
margin-right: 10px;
}
.hydration-options input[type=checkbox] {
@@ -30,6 +31,11 @@
vertical-align: middle;
}
.hydration-options select {
margin-left: 10px;
max-width: 100px;
}
.hydration .CodeMirror {
font-size: 13px;
padding-top: 8px;
@@ -1,4 +1,5 @@
import './hydration.css';
import VersionPicker from '../../VersionPicker';
import {SAMPLE_CODE} from './data';
import {CodeEditor, CodeError} from './Code';
import {compile} from './code-transformer';
@@ -6,12 +7,17 @@ import {reactPaths} from '../../../react-loader';
import qs from 'query-string';
const React = window.React;
// The Hydration fixture can render at a different version than the parent
// app. This allows rendering for versions of React older than the DOM
// test fixtures can support.
const initialVersion = qs.parse(window.location.search).version || 'local';
class Hydration extends React.Component {
state = {
error: null,
code: SAMPLE_CODE,
hydrate: true,
version: initialVersion,
};
ready = false;
@@ -72,9 +78,14 @@ class Hydration extends React.Component {
});
};
setVersion = version => {
this.setState({version});
};
render() {
const {code, error, hydrate} = this.state;
const src = '/renderer.html?' + qs.stringify({hydrate, ...reactPaths()});
const {code, error, hydrate, version} = this.state;
const src =
'/renderer.html?' + qs.stringify({hydrate, ...reactPaths(version)});
return (
<div className="hydration">
@@ -89,6 +100,16 @@ class Hydration extends React.Component {
/>
Auto-Hydrate
</label>
<label htmlFor="hydration_version">
Version:
<VersionPicker
id="hydration_version"
name="hyration_version"
version={version}
onChange={this.setVersion}
/>
</label>
</header>
<CodeEditor code={code} onChange={this.setCode} />
+20
View File
@@ -0,0 +1,20 @@
/**
* Provides a standard way to access a DOM node across all versions of
* React.
*/
import {reactPaths} from './react-loader';
const React = window.React;
const ReactDOM = window.ReactDOM;
export function findDOMNode(target) {
const {needsReactDOM} = reactPaths();
if (needsReactDOM) {
return ReactDOM.findDOMNode(target);
} else {
// eslint-disable-next-line
return React.findDOMNode(target);
}
}
+33 -22
View File
@@ -36,19 +36,29 @@ function loadScript(src) {
});
}
export function reactPaths() {
function getVersion() {
let query = parseQuery(window.location.search);
return query.version || 'local';
}
export function reactPaths(version = getVersion()) {
let query = parseQuery(window.location.search);
let version = query.version || 'local';
let isProduction = query.production === 'true';
let environment = isProduction ? 'production.min' : 'development';
let reactPath = 'react.' + environment + '.js';
let reactDOMPath = 'react-dom.' + environment + '.js';
let reactDOMServerPath = 'react-dom-server.browser.' + environment + '.js';
let reactPath = `react.${environment}.js`;
let reactDOMPath = `react-dom.${environment}.js`;
let reactDOMServerPath = `react-dom-server.browser.${environment}.js`;
let needsCreateElement = true;
let needsReactDOM = true;
if (version !== 'local') {
const {major, minor, prerelease} = semver(version);
if (major === 0) {
needsCreateElement = minor >= 12;
needsReactDOM = minor >= 14;
}
const [preReleaseStage] = prerelease;
// The file structure was updated in 16. This wasn't the case for alphas.
// Load the old module location for anything less than 16 RC
@@ -68,26 +78,27 @@ export function reactPaths() {
reactDOMServerPath =
'https://unpkg.com/react-dom@' +
version +
'/umd/react-dom-server.browser.' +
environment +
'.js';
} else {
let suffix = isProduction ? '.min.js' : '.js';
reactPath = 'https://unpkg.com/react@' + version + '/dist/react' + suffix;
'/umd/react-dom-server.browser' +
environment;
} else if (major > 0 || minor > 11) {
reactPath = 'https://unpkg.com/react@' + version + '/dist/react.js';
reactDOMPath =
'https://unpkg.com/react-dom@' + version + '/dist/react-dom' + suffix;
'https://unpkg.com/react-dom@' + version + '/dist/react-dom.js';
reactDOMServerPath =
'https://unpkg.com/react-dom@' +
version +
'/dist/react-dom-server' +
suffix;
'https://unpkg.com/react-dom@' + version + '/dist/react-dom-server.js';
} else {
reactPath =
'https://cdnjs.cloudflare.com/ajax/libs/react/' + version + '/react.js';
}
}
const needsReactDOM = version === 'local' || parseFloat(version, 10) > 0.13;
return {reactPath, reactDOMPath, reactDOMServerPath, needsReactDOM};
return {
reactPath,
reactDOMPath,
reactDOMServerPath,
needsCreateElement,
needsReactDOM,
};
}
export default function loadReact() {