Hydration DOM Fixture (#13521)

* Add home component. Async load fixtures.

This commit adds a homepage to the DOM fixtures that includes browser
testing information and asynchronously loads fixtures.

This should make it easier to find DOM testing information and keep
the payload size in check as we add more components to the fixtures.

* Adds experimental hydration fixture

This commit adds a first pass at a fixture that makes it easier to
debug the process of hydrating static markup. This is not complete:

1. It needs to be verified across multiple browsers
2. It needs to render with the current version of react

Still, it at least demonstrates the idea. A fixture like this will
also be helpful for debugging change events for hydrated inputs, which
presently do not fire if the user changes an input's text before
hydration.

* Tweak select width

* Manually join extra attributes in warning

This prevents a bug where Chrome reports `Array(n)` where `n` is the
size of the array.

* Transform with buble

* Eliminate dependencies

* Pull in react-live for better editing

* Handle encoding errors, pass react version

* Load the correct version of React

* Tweaks

* Revert style change

* Revert warning update

* Properly handle script errors. Fix dom-server CDN loading

* Fix 15.x releases

* Use postMessage to reduce latency, support older browsers

This commit makes a few tweaks to support older browsers and updates
the code transition process to use window.postMessage. This avoids
loading React on every single change.

* Fix fixture renamespacing bug

* Gracefully fallback to textarea in React 14

* Replace buble with babel, react-live with codemirror

* Simplify layout to resolve production code-mirror issues

* Tweak height rules for code-mirror

* Update theme to paraiso

* Format Code.js

* Adjust viewport to fix CodeMirror resize issue in production build

* Eliminate react-code-mirror

* Improve error state. Make full stack collapsable

* Add link to license in codemirror stylesheet

* Make code example more concise

* Replace "Hydrate" with "Auto-hydrate" for clarity

* Remove border below hydration header

* Rename query function in render.js

* Use Function(code) to evaluate hydration fixture

For clarity, and so that the Fixture component does not need to be
assigned to the window, this commit changes the way code is executed
such that it evaluates using a Function constructor.

* Extend hydration fixture to fill width. Design adjustments

This commit extends the hydration fixture such that it takes up the
full screen view. To accomplish this, the container that wraps all
fixtures has been moved to the FixtureSet component, utilized by all
other fixtures.

* Improve error scroll state

* Lazy load CodeMirror together before executing

This commit fixes an issue where CodeMirror wouldn't layout correctly
in production builds because the editor executes before the stylesheet
loaded. CodeMirror needs layout information, and was rendering
off-screen without correct CSS layout measurements.

* Fix indentation on error message

* Do not highlight errors from Babel. Add setPrototypeOf polyfill

This commit fixes an error in Safari 7.1 where Chalk highlighted Babel
errors caused a crash when setPrototypeOf was called within the
library.

This is also an issue on IE9, however this fix does not resolve issues
in that browser.

* Increase resilience to bad errors in Hydration fixture

- Reverts highlighting change. Polyfilling Safari 7.1 is sufficient
- Do not render a details tag in IE9
This commit is contained in:
Nathan Hunzaker 2018-09-10 14:04:14 -07:00 committed by GitHub
parent 54bfab5d6d
commit f6fb03edff
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 628 additions and 33 deletions

View File

@ -10,6 +10,7 @@ coverage
build
public/react.development.js
public/react-dom.development.js
public/react-dom-server.browser.development.js
# misc
.DS_Store

View File

@ -6,7 +6,9 @@
"react-scripts": "^1.0.11"
},
"dependencies": {
"@babel/standalone": "^7.0.0",
"classnames": "^2.2.5",
"codemirror": "^5.40.0",
"core-js": "^2.4.1",
"prop-types": "^15.6.0",
"query-string": "^4.2.3",
@ -16,7 +18,7 @@
},
"scripts": {
"start": "react-scripts start",
"prestart": "cp ../../build/dist/react.development.js public/ && cp ../../build/dist/react-dom.development.js public/",
"prestart": "cp ../../build/dist/react.development.js ../../build/dist/react-dom.development.js ../../build/dist/react-dom-server.browser.development.js public/",
"build": "react-scripts build && cp build/index.html build/200.html",
"test": "react-scripts test --env=jsdom",
"eject": "react-scripts eject"

View File

@ -0,0 +1,86 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Renderer</title>
<style>
*,
*:before,
*:after {
box-sizing: border-box;
}
html,
body {
font-family: sans-serif;
margin: 0;
height: 100%;
}
body {
padding-top: 32px;
}
#status {
font-size: 12px;
left: 8px;
letter-spacing: 0.05em;
line-height: 16px;
margin: -8px 0 0;
max-width: 50%;
overflow: hidden;
position: absolute;
text-align: left;
text-overflow: ellipsis;
top: 50%;
white-space: nowrap;
width: 100%;
}
#output {
margin: 16px;
}
.header {
background: white;
border-bottom: 1px solid #d9d9d9;
padding: 4px;
top: 0;
left: 0;
position: fixed;
width: 100%;
text-align: right;
}
.controls {
display: inline-block;
margin: 0;
}
.button {
background: #eee;
border-radius: 2px;
border: 1px solid #aaa;
font-size: 11px;
padding: 4px 6px;
text-transform: uppercase;
}
</style>
</head>
<body>
<header class="header">
<p id="status">Loading</p>
<menu class="controls">
<button class="button" id="hydrate">Hydrate</button>
<button class="button" id="reload">Reload</button>
</menu>
</header>
<div id="output"></div>
<script src="https://cdn.polyfill.io/v2/polyfill.min.js"></script>
<script src="renderer.js"></script>
</body>
</html>

View File

@ -0,0 +1,141 @@
/**
* Supports render.html, a piece of the hydration fixture. See /hydration
*/
'use strict';
(function() {
var Fixture = null;
var output = document.getElementById('output');
var status = document.getElementById('status');
var hydrate = document.getElementById('hydrate');
var reload = document.getElementById('reload');
var renders = 0;
var failed = false;
function getQueryParam(key) {
var pattern = new RegExp(key + '=([^&]+)(&|$)');
var matches = window.location.search.match(pattern);
if (matches) {
return decodeURIComponent(matches[1]);
}
handleError(new Error('No key found for' + key));
}
function getBooleanQueryParam(key) {
return getQueryParam(key) === 'true';
}
function setStatus(label) {
status.innerHTML = label;
}
function prerender() {
setStatus('Generating markup');
output.innerHTML = ReactDOMServer.renderToString(
React.createElement(Fixture)
);
setStatus('Markup only (No React)');
}
function render() {
setStatus('Hydrating');
if (ReactDOM.hydrate) {
ReactDOM.hydrate(React.createElement(Fixture), output);
} else {
ReactDOM.render(React.createElement(Fixture), output);
}
setStatus(renders > 0 ? 'Re-rendered (' + renders + 'x)' : 'Hydrated');
renders += 1;
hydrate.innerHTML = 'Re-render';
}
function handleError(error) {
console.log(error);
failed = true;
setStatus('Javascript Error');
output.innerHTML = error;
}
function loadScript(src) {
return new Promise(function(resolve, reject) {
var script = document.createElement('script');
script.async = true;
script.src = src;
script.onload = resolve;
script.onerror = function(error) {
reject(new Error('Unable to load ' + src));
};
document.body.appendChild(script);
});
}
function injectFixture(src) {
Fixture = new Function(src + '\nreturn Fixture;')();
if (typeof Fixture === 'undefined') {
setStatus('Failed');
output.innerHTML = 'Please name your root component "Fixture"';
} else {
prerender();
if (getBooleanQueryParam('hydrate')) {
render();
}
}
}
function reloadFixture(code) {
renders = 0;
ReactDOM.unmountComponentAtNode(output);
injectFixture(code);
}
window.onerror = handleError;
reload.onclick = function() {
window.location.reload();
};
hydrate.onclick = render;
loadScript(getQueryParam('reactPath'))
.then(function() {
return getBooleanQueryParam('needsReactDOM')
? loadScript(getQueryParam('reactDOMPath'))
: null;
})
.then(function() {
return loadScript(getQueryParam('reactDOMServerPath'));
})
.then(function() {
if (failed) {
return;
}
window.addEventListener('message', function(event) {
var data = JSON.parse(event.data);
switch (data.type) {
case 'code':
reloadFixture(data.payload);
break;
default:
throw new Error(
'Renderer Error: Unrecognized message "' + data.type + '"'
);
}
});
window.parent.postMessage(JSON.stringify({type: 'ready'}), '*');
})
.catch(handleError);
})();

View File

@ -8,9 +8,7 @@ function App() {
return (
<div>
<Header />
<div className="container">
<Fixtures />
</div>
<Fixtures />
</div>
);
}

View File

@ -11,7 +11,7 @@ class FixtureSet extends React.Component {
const {title, description, children} = this.props;
return (
<div>
<div className="container">
<h1>{title}</h1>
{description && <p>{description}</p>}

View File

@ -48,7 +48,8 @@ class Header extends React.Component {
<select
value={window.location.pathname}
onChange={this.handleFixtureChange}>
<option value="/">Select a Fixture</option>
<option value="/">Home</option>
<option value="/hydration">Hydration</option>
<option value="/range-inputs">Range Inputs</option>
<option value="/text-inputs">Text Inputs</option>
<option value="/number-inputs">Number Input</option>

View File

@ -2,7 +2,7 @@ const React = window.React;
export default function Home() {
return (
<main>
<main className="container">
<h1>DOM Test Fixtures</h1>
<p>
Use this site to test browser quirks and other behavior that can not be

View File

@ -0,0 +1,85 @@
const React = window.React;
export class CodeEditor extends React.Component {
shouldComponentUpdate() {
return false;
}
componentDidMount() {
// Important: CodeMirror incorrectly lays out the editor
// if it executes before CSS has loaded
// https://github.com/graphql/graphiql/issues/33#issuecomment-318188555
Promise.all([
import('codemirror'),
import('codemirror/mode/jsx/jsx'),
import('codemirror/lib/codemirror.css'),
import('./codemirror-paraiso-dark.css'),
]).then(([CodeMirror]) => this.install(CodeMirror));
}
install(CodeMirror) {
if (!this.textarea) {
return;
}
const {onChange} = this.props;
this.editor = CodeMirror.fromTextArea(this.textarea, {
mode: 'jsx',
theme: 'paraiso-dark',
lineNumbers: true,
});
this.editor.on('change', function(doc) {
onChange(doc.getValue());
});
}
componentWillUnmount() {
if (this.editor) {
this.editor.toTextArea();
}
}
render() {
return (
<textarea
ref={ref => (this.textarea = ref)}
defaultValue={this.props.code}
autoComplete="off"
hidden={true}
/>
);
}
}
/**
* Prevent IE9 from raising an error on an unrecognized element:
* See https://github.com/facebook/react/issues/13610
*/
const supportsDetails = !(
document.createElement('details') instanceof HTMLUnknownElement
);
export class CodeError extends React.Component {
render() {
const {error, className} = this.props;
if (!error) {
return null;
}
if (supportsDetails) {
const [summary, ...body] = error.message.split(/\n+/g);
return (
<details className={className}>
<summary>{summary}</summary>
{body.join('\n')}
</details>
);
}
return <div className={className}>{error.message}</div>;
}
}

View File

@ -0,0 +1,18 @@
/**
* Babel works across all browsers, however it requires many polyfills.
*/
import 'core-js/es6/weak-map';
import 'core-js/es6/weak-set';
import 'core-js/es6/number';
import 'core-js/es6/string';
import 'core-js/es6/array';
import 'core-js/modules/es6.object.set-prototype-of';
import {transform} from '@babel/standalone';
const presets = ['es2015', 'stage-3', 'react'];
export function compile(raw) {
return transform(raw, {presets}).code;
}

View File

@ -0,0 +1,38 @@
/**
* Name: Paraíso (Dark)
* Author: Jan T. Sott
* License: Creative Commons Attribution-ShareAlike 4.0 Unported License.
* https://creativecommons.org/licenses/by-sa/4.0/deed.en_US
*
* Color scheme by Jan T. Sott (https://github.com/idleberg/Paraiso-CodeMirror)
* Inspired by the art of Rubens LP (http://www.rubenslp.com.br)
*/
.cm-s-paraiso-dark.CodeMirror { background: #2f1e2e; color: #b9b6b0; }
.cm-s-paraiso-dark div.CodeMirror-selected { background: #41323f; }
.cm-s-paraiso-dark .CodeMirror-line::selection, .cm-s-paraiso-dark .CodeMirror-line > span::selection, .cm-s-paraiso-dark .CodeMirror-line > span > span::selection { background: rgba(65, 50, 63, .99); }
.cm-s-paraiso-dark .CodeMirror-line::-moz-selection, .cm-s-paraiso-dark .CodeMirror-line > span::-moz-selection, .cm-s-paraiso-dark .CodeMirror-line > span > span::-moz-selection { background: rgba(65, 50, 63, .99); }
.cm-s-paraiso-dark .CodeMirror-gutters { background: #2f1e2e; border-right: 0px; }
.cm-s-paraiso-dark .CodeMirror-guttermarker { color: #ef6155; }
.cm-s-paraiso-dark .CodeMirror-guttermarker-subtle { color: #776e71; }
.cm-s-paraiso-dark .CodeMirror-linenumber { color: #776e71; }
.cm-s-paraiso-dark .CodeMirror-cursor { border-left: 1px solid #8d8687; }
.cm-s-paraiso-dark span.cm-comment { color: #e96ba8; }
.cm-s-paraiso-dark span.cm-atom { color: #815ba4; }
.cm-s-paraiso-dark span.cm-number { color: #815ba4; }
.cm-s-paraiso-dark span.cm-property, .cm-s-paraiso-dark span.cm-attribute { color: #48b685; }
.cm-s-paraiso-dark span.cm-keyword { color: #ef6155; }
.cm-s-paraiso-dark span.cm-string { color: #fec418; }
.cm-s-paraiso-dark span.cm-variable { color: #48b685; }
.cm-s-paraiso-dark span.cm-variable-2 { color: #06b6ef; }
.cm-s-paraiso-dark span.cm-def { color: #f99b15; }
.cm-s-paraiso-dark span.cm-bracket { color: #b9b6b0; }
.cm-s-paraiso-dark span.cm-tag { color: #ef6155; }
.cm-s-paraiso-dark span.cm-link { color: #815ba4; }
.cm-s-paraiso-dark span.cm-error { background: #ef6155; color: #8d8687; }
.cm-s-paraiso-dark .CodeMirror-activeline-background { background: #4D344A; }
.cm-s-paraiso-dark .CodeMirror-matchingbracket { text-decoration: underline; color: white !important; }

View File

@ -0,0 +1,22 @@
export const SAMPLE_CODE = `
class Fixture extends React.Component {
state = {
value: 'asdf'
}
onChange(event) {
this.setState({ value: event.target.value });
}
render() {
const { value } = this.state;
return (
<form>
<input value={value} onChange={this.onChange.bind(this)} />
<p>Value: {value}</p>
</form>
);
}
}
`.trim();

View File

@ -0,0 +1,68 @@
.hydration {
background: #2f1e2e;
margin: 0;
position: relative;
height: calc(100vh - 40px); /* height of header */
overflow: auto;
padding-top: 32px;
}
.hydration-options {
background: #171717;
border-top: 1px dashed rgba(215, 235, 255, 0.12);
color: #def5ff;
height: 32px;
line-height: 28px;
padding: 0 8px;
width: 100%;
position: absolute;
top: 0;
left: 0;
}
.hydration-options label {
font-size: 13px;
}
.hydration-options input[type=checkbox] {
display: inline-block;
margin-right: 4px;
vertical-align: middle;
}
.hydration .CodeMirror {
font-size: 13px;
padding-top: 8px;
padding-bottom: 68px;
height: calc(100vh - 72px);
width: 55%;
}
.hydration-sandbox {
background: white;
border-radius: 2px;
border: 0;
box-shadow: 0 1px 6px rgba(0, 0, 0, 0.54);
height: calc(100% - 34px);
position: absolute;
right: 16px;
top: 16px;
width: calc(45% - 24px);
}
.hydration-code-error {
background: #df3f3f;
border-radius: 2px;
bottom: 18px;
color: white;
font-family: monospace;
font-size: 13px;
left: 16px;
line-height: 1.25;
overflow: auto;
padding: 12px;
position: fixed;
white-space: pre;
max-width: calc(55% - 26px);
z-index: 10;
}

View File

@ -0,0 +1,109 @@
import './hydration.css';
import {SAMPLE_CODE} from './data';
import {CodeEditor, CodeError} from './Code';
import {compile} from './code-transformer';
import {reactPaths} from '../../../react-loader';
import qs from 'query-string';
const React = window.React;
class Hydration extends React.Component {
state = {
error: null,
code: SAMPLE_CODE,
hydrate: true,
};
ready = false;
componentDidMount() {
window.addEventListener('message', this.handleMessage);
}
componentWillUnmount() {
window.removeEventListener('message', this.handleMessage);
}
handleMessage = event => {
var data = JSON.parse(event.data);
switch (data.type) {
case 'ready':
this.ready = true;
this.injectCode();
break;
default:
throw new Error(
'Editor Error: Unrecognized message "' + data.type + '"'
);
}
};
injectCode = () => {
try {
this.send({
type: 'code',
payload: compile(this.state.code),
});
this.setState({error: null});
} catch (error) {
this.setState({error});
}
};
send = message => {
if (this.ready) {
this.frame.contentWindow.postMessage(JSON.stringify(message), '*');
}
};
setFrame = frame => {
this.frame = frame;
};
setCode = code => {
this.setState({code}, this.injectCode);
};
setCheckbox = event => {
this.setState({
[event.target.name]: event.target.checked,
});
};
render() {
const {code, error, hydrate} = this.state;
const src = '/renderer.html?' + qs.stringify({hydrate, ...reactPaths()});
return (
<div className="hydration">
<header className="hydration-options">
<label htmlFor="hydrate">
<input
id="hydrate"
name="hydrate"
type="checkbox"
checked={hydrate}
onChange={this.setCheckbox}
/>
Auto-Hydrate
</label>
</header>
<CodeEditor code={code} onChange={this.setCode} />
<CodeError error={error} className="hydration-code-error" />
<iframe
ref={this.setFrame}
className="hydration-sandbox"
title="Hydration Preview"
src={src}
/>
</div>
);
}
}
export default Hydration;

View File

@ -1,3 +1,5 @@
import FixtureSet from '../../FixtureSet';
const React = window.React;
class RangeInputs extends React.Component {
@ -7,22 +9,26 @@ class RangeInputs extends React.Component {
};
render() {
return (
<form>
<fieldset>
<legend>Controlled</legend>
<input
type="range"
value={this.state.value}
onChange={this.onChange}
/>
<span className="hint">Value: {this.state.value}</span>
</fieldset>
<FixtureSet
title="Range Inputs"
description="Note: Range inputs are not supported in IE9.">
<form>
<fieldset>
<legend>Controlled</legend>
<input
type="range"
value={this.state.value}
onChange={this.onChange}
/>
<span className="hint">Value: {this.state.value}</span>
</fieldset>
<fieldset>
<legend>Uncontrolled</legend>
<input type="range" defaultValue={0.5} />
</fieldset>
</form>
<fieldset>
<legend>Uncontrolled</legend>
<input type="range" defaultValue={0.5} />
</fieldset>
</form>
</FixtureSet>
);
}
}

View File

@ -1,3 +1,4 @@
import 'core-js/es6/symbol';
import 'core-js/es6/promise';
import 'core-js/es6/set';
import 'core-js/es6/map';

View File

@ -36,9 +36,10 @@ function loadScript(src) {
});
}
export default function loadReact() {
let REACT_PATH = 'react.development.js';
let DOM_PATH = 'react-dom.development.js';
export function reactPaths() {
let reactPath = 'react.development.js';
let reactDOMPath = 'react-dom.development.js';
let reactDOMServerPath = 'react-dom-server.browser.development.js';
let query = parseQuery(window.location.search);
let version = query.version || 'local';
@ -49,25 +50,37 @@ export default function loadReact() {
// 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
if (major >= 16 && !(minor === 0 && preReleaseStage === 'alpha')) {
REACT_PATH =
reactPath =
'https://unpkg.com/react@' + version + '/umd/react.development.js';
DOM_PATH =
reactDOMPath =
'https://unpkg.com/react-dom@' +
version +
'/umd/react-dom.development.js';
reactDOMServerPath =
'https://unpkg.com/react-dom@' +
version +
'/umd/react-dom-server.browser.development';
} else {
REACT_PATH = 'https://unpkg.com/react@' + version + '/dist/react.js';
DOM_PATH =
reactPath = 'https://unpkg.com/react@' + version + '/dist/react.js';
reactDOMPath =
'https://unpkg.com/react-dom@' + version + '/dist/react-dom.js';
reactDOMServerPath =
'https://unpkg.com/react-dom@' + version + '/dist/react-dom-server.js';
}
}
const needsReactDOM = version === 'local' || parseFloat(version, 10) > 0.13;
let request = loadScript(REACT_PATH);
return {reactPath, reactDOMPath, reactDOMServerPath, needsReactDOM};
}
export default function loadReact() {
const {reactPath, reactDOMPath, needsReactDOM} = reactPaths();
let request = loadScript(reactPath);
if (needsReactDOM) {
request = request.then(() => loadScript(DOM_PATH));
request = request.then(() => loadScript(reactDOMPath));
} else {
// Aliasing React to ReactDOM for compatibility.
request = request.then(() => {

View File

@ -79,7 +79,6 @@ textarea {
.header {
background: #171717;
box-shadow: inset 0 -1px 3px #000;
overflow: hidden;
padding: 8px;
}
@ -102,7 +101,6 @@ textarea {
.header__inner {
display: table;
margin: 0 auto;
max-width: 1000px;
overflow: hidden;
text-align: center;
width: 100%;

View File

@ -2,6 +2,10 @@
# yarn lockfile v1
"@babel/standalone@^7.0.0":
version "7.0.0"
resolved "https://registry.yarnpkg.com/@babel/standalone/-/standalone-7.0.0.tgz#856446641620c1c5f0ca775621d478324ebd1f52"
abab@^1.0.3:
version "1.0.3"
resolved "https://registry.yarnpkg.com/abab/-/abab-1.0.3.tgz#b81de5f7274ec4e756d797cd834f303642724e5d"
@ -1556,6 +1560,10 @@ code-point-at@^1.0.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/code-point-at/-/code-point-at-1.1.0.tgz#0d070b4d043a5bea33a2f1a40e2edb3d9a4ccf77"
codemirror@^5.40.0:
version "5.40.0"
resolved "https://registry.yarnpkg.com/codemirror/-/codemirror-5.40.0.tgz#2f5ed47366e514f41349ba0fe34daaa39be4e257"
color-convert@^1.3.0:
version "1.8.2"
resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.8.2.tgz#be868184d7c8631766d54e7078e2672d7c7e3339"