diff --git a/packages/react-fresh/src/ReactFreshBabelPlugin.js b/packages/react-fresh/src/ReactFreshBabelPlugin.js index 109deb149a..ac7085e9f3 100644 --- a/packages/react-fresh/src/ReactFreshBabelPlugin.js +++ b/packages/react-fresh/src/ReactFreshBabelPlugin.js @@ -7,9 +7,128 @@ 'use strict'; -// TODO export default function(babel) { + const {types: t, template} = babel; + + const registrationsByProgramPath = new Map(); + function createRegistration(programPath, persistentID) { + const handle = programPath.scope.generateUidIdentifier('c'); + if (!registrationsByProgramPath.has(programPath)) { + registrationsByProgramPath.set(programPath, []); + } + const registrations = registrationsByProgramPath.get(programPath); + registrations.push({ + handle, + persistentID, + }); + return handle; + } + + const buildRegistrationCall = template(` + __register__(HANDLE, PERSISTENT_ID); + `); + + function isComponentishName(name) { + return typeof name === 'string' && name[0] >= 'A' && name[0] <= 'Z'; + } + + function isComponentish(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')) + ); + default: + return false; + } + } + return { - visitor: {}, + visitor: { + FunctionDeclaration(path) { + let programPath; + let insertAfterPath; + switch (path.parent.type) { + case 'Program': + insertAfterPath = path; + programPath = path.parentPath; + break; + case 'ExportNamedDeclaration': + case 'ExportDefaultDeclaration': + insertAfterPath = path.parentPath; + programPath = insertAfterPath.parentPath; + break; + default: + return; + } + const maybeComponent = path.node; + if (!isComponentish(maybeComponent)) { + return; + } + const functionName = path.node.id.name; + const handle = createRegistration(programPath, functionName); + insertAfterPath.insertAfter( + t.expressionStatement( + t.assignmentExpression('=', handle, path.node.id), + ), + ); + }, + VariableDeclaration(path) { + let programPath; + switch (path.parent.type) { + case 'Program': + programPath = path.parentPath; + break; + case 'ExportNamedDeclaration': + case 'ExportDefaultDeclaration': + programPath = path.parentPath.parentPath; + break; + default: + return; + } + const declPath = path.get('declarations'); + if (declPath.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), + ); + }, + Program: { + exit(path) { + const registrations = registrationsByProgramPath.get(path); + if (registrations === undefined) { + return; + } + registrationsByProgramPath.delete(path); + const declarators = []; + path.pushContainer('body', t.variableDeclaration('var', declarators)); + registrations.forEach(({handle, persistentID}) => { + path.pushContainer( + 'body', + buildRegistrationCall({ + HANDLE: handle, + PERSISTENT_ID: t.stringLiteral(persistentID), + }), + ); + declarators.push(t.variableDeclarator(handle)); + }); + }, + }, + }, }; } diff --git a/packages/react-fresh/src/__tests__/ReactFreshBabelPlugin-test.js b/packages/react-fresh/src/__tests__/ReactFreshBabelPlugin-test.js index c44cd08af9..b359c2ed19 100644 --- a/packages/react-fresh/src/__tests__/ReactFreshBabelPlugin-test.js +++ b/packages/react-fresh/src/__tests__/ReactFreshBabelPlugin-test.js @@ -12,12 +12,178 @@ let freshPlugin = require('react-fresh/babel'); function transform(input, options = {}) { return babel.transform(input, { - plugins: [[freshPlugin]], + babelrc: false, + plugins: ['syntax-jsx', freshPlugin], }).code; } describe('ReactFreshBabelPlugin', () => { - it('hello world', () => { - expect(transform(`hello()`)).toMatchSnapshot(); + it('registers top-level function declarations', () => { + // Hello and Bar should be registered, handleClick shouldn't. + expect( + transform(` + function Hello() { + function handleClick() {} + return

Hi

; + } + + function Bar() { + return ; + } + `), + ).toMatchSnapshot(); + }); + + it('registers top-level exported function declarations', () => { + expect( + transform(` + export function Hello() { + function handleClick() {} + return

Hi

; + } + + export default function Bar() { + return ; + } + + function Baz() { + return

OK

; + } + + const NotAComp = 'hi'; + export { Baz, NotAComp }; + + export function sum() {} + export const Bad = 42; + `), + ).toMatchSnapshot(); + }); + + it('registers top-level exported named arrow functions', () => { + expect( + transform(` + export const Hello = () => { + function handleClick() {} + return

Hi

; + }; + + export let Bar = (props) => ; + + export default () => { + // This one should be ignored. + // You should name your components. + return ; + }; + `), + ).toMatchSnapshot(); + }); + + it('uses original function declaration if it get reassigned', () => { + // This should register the original version. + // TODO: in the future, we may *also* register the wrapped one. + expect( + transform(` + function Hello() { + return

Hi

; + } + Hello = connect(Hello); + `), + ).toMatchSnapshot(); + }); + + it('only registers pascal case functions', () => { + // Should not get registered. + expect( + transform(` + function hello() { + return 2 * 2; + } + `), + ).toMatchSnapshot(); + }); + + it('registers top-level variable declarations with function expressions', () => { + // Hello and Bar should be registered; handleClick, sum, Baz, and Qux shouldn't. + expect( + transform(` + let Hello = function() { + function handleClick() {} + return

Hi

; + }; + const Bar = function Baz() { + return ; + }; + function sum() {} + let Baz = 10; + var Qux; + `), + ).toMatchSnapshot(); + }); + + it('registers top-level variable declarations with arrow functions', () => { + // Hello, Bar, and Baz should be registered; handleClick and sum shouldn't. + expect( + transform(` + let Hello = () => { + const handleClick = () => {}; + return

Hi

; + } + const Bar = () => { + return ; + }; + var Baz = () =>
; + var sum = () => {}; + `), + ).toMatchSnapshot(); + }); + + it('ignores HOC definitions', () => { + // TODO: we might want to handle HOCs at usage site, however. + // TODO: it would be nice if we could always avoid registering + // a function that is known to return a function or other non-node. + expect( + transform(` + let connect = () => { + function Comp() { + const handleClick = () => {}; + return

Hi

; + } + return Comp; + }; + function withRouter() { + return function Child() { + const handleClick = () => {}; + return

Hi

; + } + }; + `), + ).toMatchSnapshot(); + }); + + it('ignores complex definitions', () => { + expect( + transform(` + let A = foo ? () => { + return

Hi

; + } : null + const B = (function Foo() { + return

Hi

; + })(); + let C = () => () => { + return

Hi

; + }; + let D = bar && (() => { + return

Hi

; + }); + `), + ).toMatchSnapshot(); + }); + + it('ignores unnamed function declarations', () => { + expect( + transform(` + export default function() {} + `), + ).toMatchSnapshot(); }); }); 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 665cb8949a..273b8c8bd0 100644 --- a/packages/react-fresh/src/__tests__/__snapshots__/ReactFreshBabelPlugin-test.js.snap +++ b/packages/react-fresh/src/__tests__/__snapshots__/ReactFreshBabelPlugin-test.js.snap @@ -1,3 +1,175 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`ReactFreshBabelPlugin hello world 1`] = `"hello();"`; +exports[`ReactFreshBabelPlugin ignores HOC definitions 1`] = ` +" +let connect = () => { + function Comp() { + const handleClick = () => {}; + return

Hi

; + } + return Comp; +}; +function withRouter() { + return function Child() { + const handleClick = () => {}; + return

Hi

; + }; +};" +`; + +exports[`ReactFreshBabelPlugin ignores complex definitions 1`] = ` +" +let A = foo ? () => { + return

Hi

; +} : null; +const B = function Foo() { + return

Hi

; +}(); +let C = () => () => { + return

Hi

; +}; +let D = bar && (() => { + return

Hi

; +});" +`; + +exports[`ReactFreshBabelPlugin ignores unnamed function declarations 1`] = ` +" +export default function () {}" +`; + +exports[`ReactFreshBabelPlugin only registers pascal case functions 1`] = ` +" +function hello() { + return 2 * 2; +}" +`; + +exports[`ReactFreshBabelPlugin registers top-level exported function declarations 1`] = ` +" +export function Hello() { + function handleClick() {} + return

Hi

; +} + +_c = Hello; +export default function Bar() { + return ; +} + +_c2 = Bar; +function Baz() { + return

OK

; +} + +_c3 = Baz; +const NotAComp = 'hi'; +export { Baz, NotAComp }; + +export function sum() {} +export const Bad = 42; + +var _c, _c2, _c3; + +__register__(_c, 'Hello'); + +__register__(_c2, 'Bar'); + +__register__(_c3, 'Baz');" +`; + +exports[`ReactFreshBabelPlugin registers top-level exported named arrow functions 1`] = ` +" +export const Hello = _c = () => { + function handleClick() {} + return

Hi

; +}; + +export let Bar = _c2 = props => ; + +export default (() => { + // This one should be ignored. + // You should name your components. + return ; +}); + +var _c, _c2; + +__register__(_c, \\"Hello\\"); + +__register__(_c2, \\"Bar\\");" +`; + +exports[`ReactFreshBabelPlugin registers top-level function declarations 1`] = ` +" +function Hello() { + function handleClick() {} + return

Hi

; +} + +_c = Hello; +function Bar() { + return ; +} +_c2 = Bar; + +var _c, _c2; + +__register__(_c, \\"Hello\\"); + +__register__(_c2, \\"Bar\\");" +`; + +exports[`ReactFreshBabelPlugin registers top-level variable declarations with arrow functions 1`] = ` +" +let Hello = _c = () => { + const handleClick = () => {}; + return

Hi

; +}; +const Bar = _c2 = () => { + return ; +}; +var Baz = _c3 = () =>
; +var sum = () => {}; + +var _c, _c2, _c3; + +__register__(_c, \\"Hello\\"); + +__register__(_c2, \\"Bar\\"); + +__register__(_c3, \\"Baz\\");" +`; + +exports[`ReactFreshBabelPlugin registers top-level variable declarations with function expressions 1`] = ` +" +let Hello = _c = function () { + function handleClick() {} + return

Hi

; +}; +const Bar = _c2 = function Baz() { + return ; +}; +function sum() {} +let Baz = 10; +var Qux; + +var _c, _c2; + +__register__(_c, \\"Hello\\"); + +__register__(_c2, \\"Bar\\");" +`; + +exports[`ReactFreshBabelPlugin uses original function declaration if it get reassigned 1`] = ` +" +function Hello() { + return

Hi

; +} +_c = Hello; +Hello = connect(Hello); + +var _c; + +__register__(_c, \\"Hello\\");" +`;