Skip to content

[WIP] Hot reloading (only for Hooks) #5958

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 47 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
ae65f65
Add initial Babel plugin impl
Timer Dec 3, 2018
baa9051
Bump React to alpha
gaearon Dec 3, 2018
47ca2c4
MVP
gaearon Dec 3, 2018
1d272a9
Test stubs
gaearon Dec 3, 2018
9c0e3f3
More test stubs
gaearon Dec 3, 2018
4489ebe
More test stubs
gaearon Dec 3, 2018
1e798cf
todos
gaearon Dec 3, 2018
7316fee
Add render hook
gaearon Dec 3, 2018
a423c31
Add basic force update mechanism
gaearon Dec 3, 2018
8d8f206
Just use context
gaearon Dec 4, 2018
4c1133f
Revert to vanilla and fix HOCs
gaearon Dec 4, 2018
25c69dc
More todos
gaearon Dec 4, 2018
f570e93
More stuff to think about
gaearon Dec 4, 2018
26872e8
Reset again
gaearon Dec 4, 2018
13a8ffa
Make fields work
gaearon Dec 4, 2018
759f87c
Make memo work
gaearon Dec 5, 2018
63f7187
Fix lazy and double wrapping
gaearon Dec 5, 2018
6fe5e6f
Fix forwardRef
gaearon Dec 5, 2018
43e5f2e
Bump
gaearon Dec 6, 2018
f499c8a
Todos
gaearon Dec 6, 2018
24bc20b
Support adding/removing hooks and error recovery
gaearon Dec 7, 2018
9b5fb72
Support reordering Hooks
gaearon Dec 7, 2018
cc5a470
Reset if primitive state type changes
gaearon Dec 7, 2018
42d8a1d
Add __commit phase
gaearon Dec 7, 2018
accf852
todo
gaearon Dec 7, 2018
8c3f39d
Propagate acceptance
gaearon Dec 7, 2018
47750b4
Todos
gaearon Dec 7, 2018
b12c3f1
plugin: wrap export default function() in hot assignment
Timer Dec 8, 2018
c745cbc
example: Export App and Hello normally
Timer Dec 8, 2018
bca09ed
add link
gaearon Dec 8, 2018
b10fa81
plugin: remove commented out section
Timer Dec 8, 2018
f0bb887
plugin: refactor
Timer Dec 8, 2018
a4961e7
plugin: wrap module scoped variables
Timer Dec 9, 2018
ffefe1e
plugin: ensure variables are wrapped
Timer Dec 9, 2018
101e5e2
plugin: refactor
Timer Dec 9, 2018
1d7f9a1
plugin: register assignments
Timer Dec 9, 2018
0391778
plugin: add class test
Timer Dec 9, 2018
76325bd
plugin: remove debug
Timer Dec 9, 2018
8c26b3a
example: remove __assign wrapping
Timer Dec 9, 2018
49de62f
plugin: remove debugging
Timer Dec 9, 2018
2b5c6eb
example: unshuffle code
Timer Dec 9, 2018
9d42a3a
plugin: refactor
Timer Dec 9, 2018
0553a4e
plugin: refactor
Timer Dec 9, 2018
22a15eb
plugin: refactor
Timer Dec 9, 2018
3a437e6
plugin: adjust fixtures
Timer Dec 10, 2018
fbaa7e6
*: inject module.hot.accept
Timer Dec 10, 2018
9096348
Add no hot marker
Timer Dec 10, 2018
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
208 changes: 208 additions & 0 deletions packages/babel-plugin-hot-reload/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
'use strict';
const sysPath = require('path');
const fs = require('fs');

function functionReturnsElement(path) {
const { body } = path.body;
const last = body[body.length - 1];
if (typeof last !== 'object' || last.type !== 'ReturnStatement') {
return false;
}
const { type: returnType } = last.argument;
if (returnType !== 'JSXElement') {
return false;
}
return true;
}

function hotAssign(types, name, func) {
return [
types.variableDeclaration('var', [
types.variableDeclarator(
types.identifier(name),
hotRegister(types, name, func)
),
]),
types.exportDefaultDeclaration({ type: 'Identifier', name: name }),
];
}

function isHotCall(call) {
return (
call &&
call.type === 'CallExpression' &&
call.callee.type === 'MemberExpression' &&
call.callee.object.name === 'window' &&
call.callee.property.name === '__assign'
);
}

function isIdentifierCandidate(identifier) {
return (
identifier.type === 'Identifier' &&
identifier.name[0] >= 'A' &&
identifier.name[0] <= 'Z'
);
}

function isVariableCandidate(declaration) {
return (
isIdentifierCandidate(declaration.id) &&
declaration.init &&
!isHotCall(declaration.init)
);
}

function isAssignmentCandidate(assignment) {
return isIdentifierCandidate(assignment.left) && assignment.operator === '=';
}

function hotRegister(t, name, content) {
if (t.isFunctionDeclaration(content)) {
content.type = 'FunctionExpression'; // TODO: why do we have to do this hack?
}
return t.callExpression(
t.memberExpression(t.identifier('window'), t.identifier('__assign')),
[t.identifier('module'), t.stringLiteral(name), content]
);
}

function hotDeclare(types, path) {
path.replaceWith(
types.variableDeclarator(
types.identifier(path.node.id.name),
hotRegister(types, path.node.id.name, path.node.init)
)
);
}

function isFile(path) {
try {
const stats = fs.lstatSync(path);
return stats.isFile();
} catch (err) {
if (err.code === 'ENOENT') {
return false;
}
throw err;
}
}

function naiveResolve(path, exts = ['js', 'jsx', 'ts', 'tsx']) {
return [path, ...exts.map(ext => path + '.' + ext)].find(isFile) || null;
}

function shouldReloadFile(file) {
// TODO: this is really naive
const contents = fs.readFileSync(file, 'utf8');
return contents.includes('React');
}

module.exports = function({ types }) {
return {
name: 'hot-reload',
visitor: {
ImportDeclaration(path) {
if (this.file.code.includes('no-hot')) {
return;
}

if (!types.isStringLiteral(path.node.source)) {
return;
}

const target = path.node.source.value;
if (!target.startsWith('.')) {
return;
}

const file = naiveResolve(
sysPath.resolve(sysPath.dirname(this.file.opts.filename), target)
);
if (file == null) {
return;
}

if (shouldReloadFile(file)) {
path.insertAfter(
types.expressionStatement(
types.callExpression(
types.memberExpression(
types.memberExpression(
types.identifier('module'),
types.identifier('hot')
),
types.identifier('accept')
),
[
types.stringLiteral(target),
types.memberExpression(
types.identifier('window'),
types.identifier('__invalidate')
),
]
)
)
);
}
},
ExportDefaultDeclaration(path) {
if (this.file.code.includes('no-hot')) {
return;
}

const { type } = path.node.declaration;
if (
type !== 'FunctionDeclaration' ||
!functionReturnsElement(path.node.declaration)
) {
return;
}
const {
id: { name },
} = path.node.declaration;
path.replaceWithMultiple(hotAssign(types, name, path.node.declaration));
},
VariableDeclaration(path) {
if (path.parent.type !== 'Program') {
// Only traverse top level variable declaration
return;
}
if (this.file.code.includes('no-hot')) {
return;
}

path.traverse({
VariableDeclarator(path) {
if (isVariableCandidate(path.node)) {
hotDeclare(types, path);
}
},
});
},
ExpressionStatement(path) {
if (path.parent.type !== 'Program') {
// Only traverse top level variable declaration
return;
}
if (this.file.code.includes('no-hot')) {
return;
}

path.traverse({
AssignmentExpression(path) {
if (isAssignmentCandidate(path.node)) {
if (!isHotCall(path.node.right)) {
path.node.right = hotRegister(
types,
path.node.left.name,
path.node.right
);
}
}
},
});
},
},
};
};
18 changes: 18 additions & 0 deletions packages/babel-plugin-hot-reload/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"name": "babel-plugin-hot-reload",
"version": "0.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"license": "BSD-3-Clause",
"dependencies": {},
"devDependencies": {
"@babel/core": "^7.2.0",
"@babel/template": "^7.1.2",
"babel-core": "7.0.0-bridge.0",
"babel-plugin-tester": "^5.5.2",
"jest": "^23.6.0"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
let LazyCC;
LazyCC = 5;

let DoubleLazyCC = LazyCC;
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import React from 'react';

export default class extends React.Component {
render() {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import React from 'react';

export default class extends React.Component {
render() {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import React from 'react';
import Hello from './components/Hello';

export default class App extends React.Component {
render() {
return <Hello />;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import React from 'react';
import Hello from './components/Hello';
import Layout from './components/Layout';
import './App.css';

export default function App({ children }) {
return (
<Layout>
<Hello />
</Layout>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// no-snap
let LazyCC;
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
import { lazy } from 'react';
const LazyCC = lazy(() => import('./CounterClass'));
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`hot-reload assignment: assignment 1`] = `
"
let LazyCC;
LazyCC = 5;

let DoubleLazyCC = LazyCC;

↓ ↓ ↓ ↓ ↓ ↓

let LazyCC;
LazyCC = window.__assign(module, \\"LazyCC\\", 5);

let DoubleLazyCC = window.__assign(module, \\"DoubleLazyCC\\", LazyCC);
"
`;

exports[`hot-reload default export class: default export class 1`] = `
"
import React from 'react';
import Hello from './components/Hello';

export default class App extends React.Component {
render() {
return <Hello />;
}
}

↓ ↓ ↓ ↓ ↓ ↓

import React from 'react';
import Hello from './components/Hello';
module.hot.accept(\\"./components/Hello\\", window.__invalidate);
export default class App extends React.Component {
render() {
return <Hello />;
}

}
"
`;

exports[`hot-reload default export: default export 1`] = `
"
import React from 'react';
import Hello from './components/Hello';
import Layout from './components/Layout';
import './App.css';

export default function App({ children }) {
return (
<Layout>
<Hello />
</Layout>
);
}

↓ ↓ ↓ ↓ ↓ ↓

import React from 'react';
import Hello from './components/Hello';
module.hot.accept(\\"./components/Hello\\", window.__invalidate);
import Layout from './components/Layout';
module.hot.accept(\\"./components/Layout\\", window.__invalidate);
import './App.css';

var App = window.__assign(module, \\"App\\", function App({
children
}) {
return <Layout>
<Hello />
</Layout>;
});

export default App;
"
`;

exports[`hot-reload lazy component: lazy component 1`] = `
"
import { lazy } from 'react';
const LazyCC = lazy(() => import('./CounterClass'));

↓ ↓ ↓ ↓ ↓ ↓

import { lazy } from 'react';

const LazyCC = window.__assign(module, \\"LazyCC\\", lazy(() => import('./CounterClass')));
"
`;
26 changes: 26 additions & 0 deletions packages/babel-plugin-hot-reload/tests/fixtures.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
'use strict';

const pluginTester = require('babel-plugin-tester');
const hotReload = require('..');
const path = require('path');
const fs = require('fs');

pluginTester({
plugin: hotReload,
filename: __filename,
babelOptions: {
parserOpts: { plugins: ['jsx', 'dynamicImport'] },
generatorOpts: {},
babelrc: false,
},
snapshot: true,
tests: fs
.readdirSync(path.join(__dirname, '__fixtures__'))
.map(entry => path.join(__dirname, '__fixtures__', entry))
.filter(entry => fs.lstatSync(entry).isFile() && entry.endsWith('js'))
.map(file => ({
title: path.basename(file, '.js').replace(/-/g, ' '),
snapshot: !fs.readFileSync(file, 'utf8').includes('no-snap'),
fixture: file,
})),
});
Loading