diff --git a/packages/react-fresh/src/ReactFreshBabelPlugin.js b/packages/react-fresh/src/ReactFreshBabelPlugin.js
index ac7085e9f3..54a372fe78 100644
--- a/packages/react-fresh/src/ReactFreshBabelPlugin.js
+++ b/packages/react-fresh/src/ReactFreshBabelPlugin.js
@@ -32,25 +32,164 @@ export default function(babel) {
return typeof name === 'string' && name[0] >= 'A' && name[0] <= 'Z';
}
- function isComponentish(node) {
+ function findInnerComponents(inferredName, path, callback) {
+ const node = path.node;
switch (node.type) {
- case 'FunctionDeclaration':
- return node.id !== null && isComponentishName(node.id.name);
- case 'VariableDeclarator':
- return (
- isComponentishName(node.id.name) &&
- node.init !== null &&
- (node.init.type === 'FunctionExpression' ||
- (node.init.type === 'ArrowFunctionExpression' &&
- node.init.body.type !== 'ArrowFunctionExpression'))
+ case 'FunctionDeclaration': {
+ // function Foo() {}
+ // export function Foo() {}
+ // export default function Foo() {}
+ callback(inferredName, node.id, null);
+ return true;
+ }
+ case 'ArrowFunctionExpression': {
+ if (node.body.type === 'ArrowFunctionExpression') {
+ return false;
+ }
+ // let Foo = () => {}
+ // export default hoc1(hoc2(() => {}))
+ callback(inferredName, node, path);
+ return true;
+ }
+ case 'FunctionExpression': {
+ // let Foo = function() {}
+ // const Foo = hoc1(forwardRef(function renderFoo() {}))
+ // export default memo(function() {})
+ callback(inferredName, node, path);
+ return true;
+ }
+ case 'CallExpression': {
+ const argsPath = path.get('arguments');
+ if (argsPath === undefined || argsPath.length === 0) {
+ return false;
+ }
+ const calleePath = path.get('callee');
+ switch (calleePath.node.type) {
+ case 'MemberExpression':
+ case 'Identifier': {
+ const calleeSource = calleePath.getSource();
+ const firstArgPath = argsPath[0];
+ const innerName = inferredName + '$' + calleeSource;
+ const foundInside = findInnerComponents(
+ innerName,
+ firstArgPath,
+ callback,
+ );
+ if (!foundInside) {
+ return false;
+ }
+ // const Foo = hoc1(hoc2(() => {}))
+ // export default memo(React.forwardRef(function() {}))
+ callback(inferredName, node, path);
+ return true;
+ }
+ default: {
+ return false;
+ }
+ }
+ }
+ case 'VariableDeclarator': {
+ const init = node.init;
+ if (init === null) {
+ return false;
+ }
+ const name = node.id.name;
+ if (!isComponentishName(name)) {
+ return false;
+ }
+ if (init.type === 'Identifier' || init.type === 'MemberExpression') {
+ return false;
+ }
+ const initPath = path.get('init');
+ const foundInside = findInnerComponents(
+ inferredName,
+ initPath,
+ callback,
);
- default:
- return false;
+ if (foundInside) {
+ return true;
+ }
+ // See if this identifier is used in JSX. Then it's a component.
+ const binding = path.scope.getBinding(name);
+ if (binding === undefined) {
+ return;
+ }
+ let isLikelyUsedAsType = false;
+ const referencePaths = binding.referencePaths;
+ for (let i = 0; i < referencePaths.length; i++) {
+ const ref = referencePaths[i];
+ if (
+ ref.node.type !== 'JSXIdentifier' &&
+ ref.node.type !== 'Identifier'
+ ) {
+ continue;
+ }
+ const refParent = ref.parent;
+ if (refParent.type === 'JSXOpeningElement') {
+ isLikelyUsedAsType = true;
+ } else if (refParent.type === 'CallExpression') {
+ const callee = refParent.callee;
+ let fnName;
+ switch (callee.type) {
+ case 'Identifier':
+ fnName = callee.name;
+ break;
+ case 'MemberExpression':
+ fnName = callee.property.name;
+ break;
+ }
+ switch (fnName) {
+ case 'createElement':
+ case 'jsx':
+ case 'jsxDEV':
+ case 'jsxs':
+ isLikelyUsedAsType = true;
+ break;
+ }
+ }
+ if (isLikelyUsedAsType) {
+ // const X = ... + later
+ callback(inferredName, init, initPath);
+ return true;
+ }
+ }
+ }
}
+ return false;
}
return {
visitor: {
+ ExportDefaultDeclaration(path) {
+ const node = path.node;
+ const decl = node.declaration;
+ const declPath = path.get('declaration');
+ if (decl.type !== 'CallExpression') {
+ // For now, we only support possible HOC calls here.
+ // Named function declarations are handled in FunctionDeclaration.
+ // Anonymous direct exports like export default function() {}
+ // are currently ignored.
+ return;
+ }
+ // This code path handles nested cases like:
+ // export default memo(() => {})
+ // In those cases it is more plausible people will omit names
+ // so they're worth handling despite possible false positives.
+ // More importantly, it handles the named case:
+ // export default memo(function Named() {})
+ const inferredName = '%default%';
+ const programPath = path.parentPath;
+ findInnerComponents(
+ inferredName,
+ declPath,
+ (persistentID, targetExpr, targetPath) => {
+ const handle = createRegistration(programPath, persistentID);
+ targetPath.replaceWith(
+ t.assignmentExpression('=', handle, targetExpr),
+ );
+ },
+ );
+ },
FunctionDeclaration(path) {
let programPath;
let insertAfterPath;
@@ -60,6 +199,9 @@ export default function(babel) {
programPath = path.parentPath;
break;
case 'ExportNamedDeclaration':
+ insertAfterPath = path.parentPath;
+ programPath = insertAfterPath.parentPath;
+ break;
case 'ExportDefaultDeclaration':
insertAfterPath = path.parentPath;
programPath = insertAfterPath.parentPath;
@@ -67,17 +209,25 @@ export default function(babel) {
default:
return;
}
- const maybeComponent = path.node;
- if (!isComponentish(maybeComponent)) {
+ const id = path.node.id;
+ if (id === null) {
+ // We don't currently handle anonymous default exports.
return;
}
- const functionName = path.node.id.name;
- const handle = createRegistration(programPath, functionName);
- insertAfterPath.insertAfter(
- t.expressionStatement(
- t.assignmentExpression('=', handle, path.node.id),
- ),
- );
+ const inferredName = id.name;
+ if (!isComponentishName(inferredName)) {
+ return;
+ }
+ // export function Named() {}
+ // function Named() {}
+ findInnerComponents(inferredName, path, (persistentID, targetExpr) => {
+ const handle = createRegistration(programPath, persistentID);
+ insertAfterPath.insertAfter(
+ t.expressionStatement(
+ t.assignmentExpression('=', handle, targetExpr),
+ ),
+ );
+ });
},
VariableDeclaration(path) {
let programPath;
@@ -92,20 +242,21 @@ export default function(babel) {
default:
return;
}
- const declPath = path.get('declarations');
- if (declPath.length !== 1) {
+ const declPaths = path.get('declarations');
+ if (declPaths.length !== 1) {
return;
}
- const firstDeclPath = declPath[0];
- const maybeComponent = firstDeclPath.node;
- if (!isComponentish(maybeComponent)) {
- return;
- }
- const functionName = maybeComponent.id.name;
- const initPath = firstDeclPath.get('init');
- const handle = createRegistration(programPath, functionName);
- initPath.replaceWith(
- t.assignmentExpression('=', handle, initPath.node),
+ const declPath = declPaths[0];
+ const inferredName = declPath.node.id.name;
+ findInnerComponents(
+ inferredName,
+ declPath,
+ (persistentID, targetExpr, targetPath) => {
+ const handle = createRegistration(programPath, persistentID);
+ targetPath.replaceWith(
+ t.assignmentExpression('=', handle, targetExpr),
+ );
+ },
);
},
Program: {
diff --git a/packages/react-fresh/src/__tests__/ReactFresh-test.js b/packages/react-fresh/src/__tests__/ReactFresh-test.js
index faaf1ae1ad..1c85697d84 100644
--- a/packages/react-fresh/src/__tests__/ReactFresh-test.js
+++ b/packages/react-fresh/src/__tests__/ReactFresh-test.js
@@ -16,10 +16,10 @@ let ReactDOM;
let ReactFreshRuntime;
let Scheduler;
let act;
-let lastRoot;
describe('ReactFresh', () => {
let container;
+ let lastRoot;
let scheduleHotUpdate;
beforeEach(() => {
diff --git a/packages/react-fresh/src/__tests__/ReactFreshBabelPlugin-test.js b/packages/react-fresh/src/__tests__/ReactFreshBabelPlugin-test.js
index b359c2ed19..cbd655d11c 100644
--- a/packages/react-fresh/src/__tests__/ReactFreshBabelPlugin-test.js
+++ b/packages/react-fresh/src/__tests__/ReactFreshBabelPlugin-test.js
@@ -186,4 +186,117 @@ describe('ReactFreshBabelPlugin', () => {
`),
).toMatchSnapshot();
});
+
+ it('registers likely HOCs with inline functions', () => {
+ expect(
+ transform(`
+ const A = forwardRef(function() {
+ return
Foo
;
+ });
+ const B = memo(React.forwardRef(() => {
+ return Foo
;
+ }));
+ export default React.memo(forwardRef((props, ref) => {
+ return Foo
;
+ }));
+ `),
+ ).toMatchSnapshot();
+ expect(
+ transform(`
+ export default React.memo(forwardRef(function (props, ref) {
+ return Foo
;
+ }));
+ `),
+ ).toMatchSnapshot();
+ expect(
+ transform(`
+ export default React.memo(forwardRef(function Named(props, ref) {
+ return Foo
;
+ }));
+ `),
+ ).toMatchSnapshot();
+ });
+
+ it('ignores higher-order functions that are not HOCs', () => {
+ expect(
+ transform(`
+ const throttledAlert = throttle(function() {
+ alert('Hi');
+ });
+ const TooComplex = (function() { return hello })(() => {});
+ if (cond) {
+ const Foo = thing(() => {});
+ }
+ `),
+ ).toMatchSnapshot();
+ });
+
+ it('registers identifiers used in JSX at definition site', () => {
+ // When in doubt, register variables that were used in JSX.
+ // Foo, Header, and B get registered.
+ // A doesn't get registered because it's not declared locally.
+ // Alias doesn't get registered because its definition is just an identifier.
+ expect(
+ transform(`
+ import A from './A';
+ import Store from './Store';
+
+ Store.subscribe();
+
+ const Header = styled.div\`color: red\`
+ const Factory = funny.factory\`\`;
+
+ let Alias1 = A;
+ let Alias2 = A.Foo;
+ const Dict = {};
+
+ function Foo() {
+ return (
+
+ );
+ }
+
+ const B = hoc(A);
+ const NotAComponent = wow(A);
+ `),
+ ).toMatchSnapshot();
+ });
+
+ it('registers identifiers used in React.createElement at definition site', () => {
+ // When in doubt, register variables that were used in JSX.
+ // Foo, Header, and B get registered.
+ // A doesn't get registered because it's not declared locally.
+ // Alias doesn't get registered because its definition is just an identifier.
+ expect(
+ transform(`
+ import A from './A';
+ import Store from './Store';
+
+ Store.subscribe();
+
+ const Header = styled.div\`color: red\`
+ const Factory = funny.factory\`\`;
+
+ let Alias1 = A;
+ let Alias2 = A.Foo;
+ const Dict = {};
+
+ function Foo() {
+ return [
+ React.createElement(A),
+ React.createElement(B),
+ React.createElement(Alias1),
+ React.createElement(Alias2),
+ jsx(Header),
+ React.createElement(Dict.X),
+ ];
+ }
+
+ React.createContext(Store);
+
+ const B = hoc(A);
+ const NotAComponent = wow(A);
+ `),
+ ).toMatchSnapshot();
+ });
});
diff --git a/packages/react-fresh/src/__tests__/ReactFreshIntegration-test.js b/packages/react-fresh/src/__tests__/ReactFreshIntegration-test.js
new file mode 100644
index 0000000000..a1c025dc2c
--- /dev/null
+++ b/packages/react-fresh/src/__tests__/ReactFreshIntegration-test.js
@@ -0,0 +1,247 @@
+/**
+ * Copyright (c) Facebook, Inc. and its affiliates.
+ *
+ * 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
+ */
+
+/* eslint-disable no-for-of-loops/no-for-of-loops */
+
+'use strict';
+
+let React;
+let ReactDOM;
+let ReactFreshRuntime;
+let act;
+
+let babel = require('babel-core');
+let freshPlugin = require('react-fresh/babel');
+
+describe('ReactFreshIntegration', () => {
+ let container;
+ let lastRoot;
+ let scheduleHotUpdate;
+
+ beforeEach(() => {
+ global.__REACT_DEVTOOLS_GLOBAL_HOOK__ = {
+ supportsFiber: true,
+ inject: injected => {
+ scheduleHotUpdate = injected.scheduleHotUpdate;
+ },
+ onCommitFiberRoot: (id, root) => {
+ lastRoot = root;
+ },
+ onCommitFiberUnmount: () => {},
+ };
+
+ jest.resetModules();
+ React = require('react');
+ ReactDOM = require('react-dom');
+ ReactFreshRuntime = require('react-fresh/runtime');
+ act = require('react-dom/test-utils').act;
+ container = document.createElement('div');
+ document.body.appendChild(container);
+ });
+
+ afterEach(() => {
+ document.body.removeChild(container);
+ });
+
+ function execute(source) {
+ const compiled = babel.transform(source, {
+ babelrc: false,
+ presets: ['react'],
+ plugins: [freshPlugin, 'transform-es2015-modules-commonjs'],
+ }).code;
+ const exportsObj = {};
+ // eslint-disable-next-line no-new-func
+ new Function('React', 'exports', '__register__', compiled)(
+ React,
+ exportsObj,
+ __register__,
+ );
+ return exportsObj.default;
+ }
+
+ function render(source) {
+ const Component = execute(source);
+ act(() => {
+ ReactDOM.render(, container);
+ });
+ }
+
+ function patch(source) {
+ execute(source);
+ const hotUpdate = ReactFreshRuntime.prepareUpdate();
+ scheduleHotUpdate(lastRoot, hotUpdate);
+ }
+
+ function __register__(type, id) {
+ ReactFreshRuntime.register(type, id);
+ }
+
+ it('reloads function declarations', () => {
+ if (__DEV__) {
+ render(`
+ function Parent() {
+ return ;
+ };
+
+ function Child({prop}) {
+ return {prop}1
;
+ };
+
+ export default Parent;
+ `);
+ const el = container.firstChild;
+ expect(el.textContent).toBe('A1');
+ patch(`
+ function Parent() {
+ return ;
+ };
+
+ function Child({prop}) {
+ return {prop}2
;
+ };
+
+ export default Parent;
+ `);
+ expect(container.firstChild).toBe(el);
+ expect(el.textContent).toBe('B2');
+ }
+ });
+
+ it('reloads arrow functions', () => {
+ if (__DEV__) {
+ render(`
+ const Parent = () => {
+ return ;
+ };
+
+ const Child = ({prop}) => {
+ return {prop}1
;
+ };
+
+ export default Parent;
+ `);
+ const el = container.firstChild;
+ expect(el.textContent).toBe('A1');
+ patch(`
+ const Parent = () => {
+ return ;
+ };
+
+ const Child = ({prop}) => {
+ return {prop}2
;
+ };
+
+ export default Parent;
+ `);
+ expect(container.firstChild).toBe(el);
+ expect(el.textContent).toBe('B2');
+ }
+ });
+
+ it('reloads a combination of memo and forwardRef', () => {
+ if (__DEV__) {
+ render(`
+ const {memo} = React;
+
+ const Parent = memo(React.forwardRef(function (props, ref) {
+ return ;
+ }));
+
+ const Child = React.memo(({prop}) => {
+ return {prop}1
;
+ });
+
+ export default React.memo(Parent);
+ `);
+ const el = container.firstChild;
+ expect(el.textContent).toBe('A1');
+ patch(`
+ const {memo} = React;
+
+ const Parent = memo(React.forwardRef(function (props, ref) {
+ return ;
+ }));
+
+ const Child = React.memo(({prop}) => {
+ return {prop}2
;
+ });
+
+ export default React.memo(Parent);
+ `);
+ expect(container.firstChild).toBe(el);
+ expect(el.textContent).toBe('B2');
+ }
+ });
+
+ it('reloads default export with named memo', () => {
+ if (__DEV__) {
+ render(`
+ const {memo} = React;
+
+ const Child = React.memo(({prop}) => {
+ return {prop}1
;
+ });
+
+ export default memo(React.forwardRef(function Parent(props, ref) {
+ return ;
+ }));
+ `);
+ const el = container.firstChild;
+ expect(el.textContent).toBe('A1');
+ patch(`
+ const {memo} = React;
+
+ const Child = React.memo(({prop}) => {
+ return {prop}2
;
+ });
+
+ export default memo(React.forwardRef(function Parent(props, ref) {
+ return ;
+ }));
+ `);
+ expect(container.firstChild).toBe(el);
+ expect(el.textContent).toBe('B2');
+ }
+ });
+
+ it('reloads HOCs if they return functions', () => {
+ if (__DEV__) {
+ render(`
+ function hoc(letter) {
+ return function() {
+ return {letter}1
;
+ }
+ }
+
+ export default function Parent() {
+ return ;
+ }
+
+ const Child = hoc('A');
+ `);
+ const el = container.firstChild;
+ expect(el.textContent).toBe('A1');
+ patch(`
+ function hoc(letter) {
+ return function() {
+ return {letter}2
;
+ }
+ }
+
+ export default function Parent() {
+ return React.createElement(Child);
+ }
+
+ const Child = hoc('B');
+ `);
+ expect(container.firstChild).toBe(el);
+ expect(el.textContent).toBe('B2');
+ }
+ });
+});
diff --git a/packages/react-fresh/src/__tests__/__snapshots__/ReactFreshBabelPlugin-test.js.snap b/packages/react-fresh/src/__tests__/__snapshots__/ReactFreshBabelPlugin-test.js.snap
index 273b8c8bd0..622d311311 100644
--- a/packages/react-fresh/src/__tests__/__snapshots__/ReactFreshBabelPlugin-test.js.snap
+++ b/packages/react-fresh/src/__tests__/__snapshots__/ReactFreshBabelPlugin-test.js.snap
@@ -33,6 +33,19 @@ let D = bar && (() => {
});"
`;
+exports[`ReactFreshBabelPlugin ignores higher-order functions that are not HOCs 1`] = `
+"
+const throttledAlert = throttle(function () {
+ alert('Hi');
+});
+const TooComplex = function () {
+ return hello;
+}(() => {});
+if (cond) {
+ const Foo = thing(() => {});
+}"
+`;
+
exports[`ReactFreshBabelPlugin ignores unnamed function declarations 1`] = `
"
export default function () {}"
@@ -45,6 +58,131 @@ function hello() {
}"
`;
+exports[`ReactFreshBabelPlugin registers identifiers used in JSX at definition site 1`] = `
+"
+import A from './A';
+import Store from './Store';
+
+Store.subscribe();
+
+const Header = _c = styled.div\`color: red\`;
+const Factory = funny.factory\`\`;
+
+let Alias1 = A;
+let Alias2 = A.Foo;
+const Dict = {};
+
+function Foo() {
+ return ;
+}
+
+_c2 = Foo;
+const B = _c3 = hoc(A);
+const NotAComponent = wow(A);
+
+var _c, _c2, _c3;
+
+__register__(_c, 'Header');
+
+__register__(_c2, 'Foo');
+
+__register__(_c3, 'B');"
+`;
+
+exports[`ReactFreshBabelPlugin registers identifiers used in React.createElement at definition site 1`] = `
+"
+import A from './A';
+import Store from './Store';
+
+Store.subscribe();
+
+const Header = _c = styled.div\`color: red\`;
+const Factory = funny.factory\`\`;
+
+let Alias1 = A;
+let Alias2 = A.Foo;
+const Dict = {};
+
+function Foo() {
+ return [React.createElement(A), React.createElement(B), React.createElement(Alias1), React.createElement(Alias2), jsx(Header), React.createElement(Dict.X)];
+}
+
+_c2 = Foo;
+React.createContext(Store);
+
+const B = _c3 = hoc(A);
+const NotAComponent = wow(A);
+
+var _c, _c2, _c3;
+
+__register__(_c, 'Header');
+
+__register__(_c2, 'Foo');
+
+__register__(_c3, 'B');"
+`;
+
+exports[`ReactFreshBabelPlugin registers likely HOCs with inline functions 1`] = `
+"
+const A = _c2 = forwardRef(_c = function () {
+ return Foo
;
+});
+const B = _c5 = memo(_c4 = React.forwardRef(_c3 = () => {
+ return Foo
;
+}));
+export default _c8 = React.memo(_c7 = forwardRef(_c6 = (props, ref) => {
+ return Foo
;
+}));
+
+var _c, _c2, _c3, _c4, _c5, _c6, _c7, _c8;
+
+__register__(_c, \\"A$forwardRef\\");
+
+__register__(_c2, \\"A\\");
+
+__register__(_c3, \\"B$memo$React.forwardRef\\");
+
+__register__(_c4, \\"B$memo\\");
+
+__register__(_c5, \\"B\\");
+
+__register__(_c6, \\"%default%$React.memo$forwardRef\\");
+
+__register__(_c7, \\"%default%$React.memo\\");
+
+__register__(_c8, \\"%default%\\");"
+`;
+
+exports[`ReactFreshBabelPlugin registers likely HOCs with inline functions 2`] = `
+"
+export default _c3 = React.memo(_c2 = forwardRef(_c = function (props, ref) {
+ return Foo
;
+}));
+
+var _c, _c2, _c3;
+
+__register__(_c, \\"%default%$React.memo$forwardRef\\");
+
+__register__(_c2, \\"%default%$React.memo\\");
+
+__register__(_c3, \\"%default%\\");"
+`;
+
+exports[`ReactFreshBabelPlugin registers likely HOCs with inline functions 3`] = `
+"
+export default _c3 = React.memo(_c2 = forwardRef(_c = function Named(props, ref) {
+ return Foo
;
+}));
+
+var _c, _c2, _c3;
+
+__register__(_c, \\"%default%$React.memo$forwardRef\\");
+
+__register__(_c2, \\"%default%$React.memo\\");
+
+__register__(_c3, \\"%default%\\");"
+`;
+
exports[`ReactFreshBabelPlugin registers top-level exported function declarations 1`] = `
"
export function Hello() {