From 99fad8d0dada150f6d1c714d8787d66aeb344eb3 Mon Sep 17 00:00:00 2001 From: Alan Pierce Date: Mon, 24 Dec 2018 07:33:16 -0800 Subject: [PATCH] Implement react-hot-loader transform Fixes #228 Some details: * An eval snippet needed to be added to each class, which I decided to do unconditionally for simplicity. * A previous change already comptued the top-level declared variables, so I could just use those. * There was a bug where parameters in arrow function types were seen as top-level variables, so I changed it so types are never considered variable declarations. * In order to register the default export, we need to extract it to a variable, which required modifying both import transformers to handle that as a special case. * The ReactHotLoaderTransformer doesn't actually participate in normal transform, it just adds the snippets to the start and end. Cases not handled yet that could be handled in the future: * Avoid treating `require` statements as top-level declarations. * Skip react and react-hot-loader files themselves (Sucrase shouldn't be running on them anyway). I tested this end-to-end on a small app to make sure hot reloading works, including for bound methods. --- README.md | 6 +- src/index.ts | 2 +- src/parser/traverser/lval.ts | 3 + src/transformers/CJSImportTransformer.ts | 16 +- src/transformers/ESMImportTransformer.ts | 33 ++- src/transformers/ReactHotLoaderTransformer.ts | 66 ++++++ src/transformers/RootTransformer.ts | 26 ++- test/index-test.ts | 2 +- test/prefixes.ts | 3 + test/react-hot-loader-test.ts | 203 ++++++++++++++++++ test/util.ts | 2 +- tsconfig.json | 1 + 12 files changed, 356 insertions(+), 7 deletions(-) create mode 100644 src/transformers/ReactHotLoaderTransformer.ts create mode 100644 test/react-hot-loader-test.ts diff --git a/README.md b/README.md index 4d7d6672..46f71ab3 100644 --- a/README.md +++ b/README.md @@ -53,8 +53,12 @@ are four main transforms that you may want to enable: `const enum`s that need cross-file compilation. * **flow**: Removes Flow type annotations. Does not check types. * **imports**: Transforms ES Modules (`import`/`export`) to CommonJS - (`require`/`module.exports`) using the same approach as Babel 6 and TypeScript + (`require`/`module.exports`) using the same approach as Babel and TypeScript with `--esModuleInterop`. Also includes dynamic `import`. +* **react-hot-loader**: Performs the equivalent of the `react-hot-loader/babel` + transform in the [react-hot-loader](https://github.com/gaearon/react-hot-loader) + project. This enables advanced hot reloading use cases such as editing of + bound methods. The following proposed JS features are built-in and always transformed: * [Class fields](https://github.com/tc39/proposal-class-fields): `class C { x = 1; }`. diff --git a/src/index.ts b/src/index.ts index 6e70b842..890c50f6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,7 +9,7 @@ import RootTransformer from "./transformers/RootTransformer"; import formatTokens from "./util/formatTokens"; import getTSImportedNames from "./util/getTSImportedNames"; -export type Transform = "jsx" | "typescript" | "flow" | "imports"; +export type Transform = "jsx" | "typescript" | "flow" | "imports" | "react-hot-loader"; export interface SourceMapOptions { /** diff --git a/src/parser/traverser/lval.ts b/src/parser/traverser/lval.ts index 7f84fe67..684f7598 100644 --- a/src/parser/traverser/lval.ts +++ b/src/parser/traverser/lval.ts @@ -34,6 +34,9 @@ export function parseBindingIdentifier(isBlockScope: boolean): void { } export function markPriorBindingIdentifier(isBlockScope: boolean): void { + if (state.isType) { + return; + } if (state.scopeDepth === 0) { state.tokens[state.tokens.length - 1].identifierRole = IdentifierRole.TopLevelDeclaration; } else { diff --git a/src/transformers/CJSImportTransformer.ts b/src/transformers/CJSImportTransformer.ts index 40bd34ce..bceee7ba 100644 --- a/src/transformers/CJSImportTransformer.ts +++ b/src/transformers/CJSImportTransformer.ts @@ -1,8 +1,10 @@ import CJSImportProcessor from "../CJSImportProcessor"; +import NameManager from "../NameManager"; import {IdentifierRole, isDeclaration, isObjectShorthandDeclaration} from "../parser/tokenizer"; import {ContextualKeyword} from "../parser/tokenizer/keywords"; import {TokenType as tt} from "../parser/tokenizer/types"; import TokenProcessor from "../TokenProcessor"; +import ReactHotLoaderTransformer from "./ReactHotLoaderTransformer"; import RootTransformer from "./RootTransformer"; import Transformer from "./Transformer"; @@ -18,6 +20,8 @@ export default class CJSImportTransformer extends Transformer { readonly rootTransformer: RootTransformer, readonly tokens: TokenProcessor, readonly importProcessor: CJSImportProcessor, + readonly nameManager: NameManager, + readonly reactHotLoaderTransformer: ReactHotLoaderTransformer | null, readonly enableLegacyBabel5ModuleInterop: boolean, ) { super(); @@ -296,7 +300,7 @@ export default class CJSImportTransformer extends Transformer { private processExportDefault(): void { if ( this.tokens.matches4(tt._export, tt._default, tt._function, tt.name) || - // export default aysnc function + // export default async function this.tokens.matches5(tt._export, tt._default, tt.name, tt._function, tt.name) ) { this.tokens.removeInitialToken(); @@ -318,7 +322,17 @@ export default class CJSImportTransformer extends Transformer { this.tokens.appendCode(` exports.default = ${name};`); } else if (this.tokens.matches3(tt._export, tt._default, tt.at)) { throw new Error("Export default statements with decorators are not yet supported."); + } else if (this.reactHotLoaderTransformer) { + // This is a plain "export default E" statement and we need to assign E to a variable. + // Change "export default E" to "let _default; exports.default = _default = E" + const defaultVarName = this.nameManager.claimFreeName("_default"); + this.tokens.replaceToken(`let ${defaultVarName}; exports.`); + this.tokens.copyToken(); + this.tokens.appendCode(` = ${defaultVarName} =`); + this.reactHotLoaderTransformer.setExtractedDefaultExportName(defaultVarName); } else { + // This is a plain "export default E" statement, no additional requirements. + // Change "export default E" to "exports.default = E" this.tokens.replaceToken("exports."); this.tokens.copyToken(); this.tokens.appendCode(" ="); diff --git a/src/transformers/ESMImportTransformer.ts b/src/transformers/ESMImportTransformer.ts index 2b91d9aa..dedea4a6 100644 --- a/src/transformers/ESMImportTransformer.ts +++ b/src/transformers/ESMImportTransformer.ts @@ -1,7 +1,9 @@ +import NameManager from "../NameManager"; import {ContextualKeyword} from "../parser/tokenizer/keywords"; import {TokenType as tt} from "../parser/tokenizer/types"; import TokenProcessor from "../TokenProcessor"; import {getNonTypeIdentifiers} from "../util/getNonTypeIdentifiers"; +import ReactHotLoaderTransformer from "./ReactHotLoaderTransformer"; import Transformer from "./Transformer"; /** @@ -11,7 +13,12 @@ import Transformer from "./Transformer"; export default class ESMImportTransformer extends Transformer { private nonTypeIdentifiers: Set; - constructor(readonly tokens: TokenProcessor, readonly isTypeScriptTransformEnabled: boolean) { + constructor( + readonly tokens: TokenProcessor, + readonly nameManager: NameManager, + readonly reactHotLoaderTransformer: ReactHotLoaderTransformer | null, + readonly isTypeScriptTransformEnabled: boolean, + ) { super(); this.nonTypeIdentifiers = isTypeScriptTransformEnabled ? getNonTypeIdentifiers(tokens) @@ -31,6 +38,9 @@ export default class ESMImportTransformer extends Transformer { if (this.tokens.matches1(tt._import)) { return this.processImport(); } + if (this.tokens.matches2(tt._export, tt._default)) { + return this.processExportDefault(); + } return false; } @@ -182,4 +192,25 @@ export default class ESMImportTransformer extends Transformer { private isTypeName(name: string): boolean { return this.isTypeScriptTransformEnabled && !this.nonTypeIdentifiers.has(name); } + + private processExportDefault(): boolean { + const alreadyHasName = + this.tokens.matches4(tt._export, tt._default, tt._function, tt.name) || + // export default async function + this.tokens.matches5(tt._export, tt._default, tt.name, tt._function, tt.name) || + this.tokens.matches4(tt._export, tt._default, tt._class, tt.name) || + this.tokens.matches5(tt._export, tt._default, tt._abstract, tt._class, tt.name); + + if (!alreadyHasName && this.reactHotLoaderTransformer) { + // This is a plain "export default E" statement and we need to assign E to a variable. + // Change "export default E" to "let _default; export default _default = E" + const defaultVarName = this.nameManager.claimFreeName("_default"); + this.tokens.replaceToken(`let ${defaultVarName}; export`); + this.tokens.copyToken(); + this.tokens.appendCode(` ${defaultVarName} =`); + this.reactHotLoaderTransformer.setExtractedDefaultExportName(defaultVarName); + return true; + } + return false; + } } diff --git a/src/transformers/ReactHotLoaderTransformer.ts b/src/transformers/ReactHotLoaderTransformer.ts new file mode 100644 index 00000000..f5461f35 --- /dev/null +++ b/src/transformers/ReactHotLoaderTransformer.ts @@ -0,0 +1,66 @@ +import {IdentifierRole} from "../parser/tokenizer"; +import TokenProcessor from "../TokenProcessor"; +import Transformer from "./Transformer"; + +export default class ReactHotLoaderTransformer extends Transformer { + private extractedDefaultExportName: string | null = null; + + constructor(readonly tokens: TokenProcessor, readonly filePath: string) { + super(); + } + + setExtractedDefaultExportName(extractedDefaultExportName: string): void { + this.extractedDefaultExportName = extractedDefaultExportName; + } + + getPrefixCode(): string { + return ` + (function () { + var enterModule = require('react-hot-loader').enterModule; + enterModule && enterModule(module); + })();` + .replace(/\s+/g, " ") + .trim(); + } + + getSuffixCode(): string { + const topLevelNames = new Set(); + for (const token of this.tokens.tokens) { + if ( + token.identifierRole === IdentifierRole.TopLevelDeclaration || + token.identifierRole === IdentifierRole.ObjectShorthandTopLevelDeclaration + ) { + topLevelNames.add(this.tokens.identifierNameForToken(token)); + } + } + const namesToRegister = Array.from(topLevelNames).map((name) => ({ + variableName: name, + uniqueLocalName: name, + })); + if (this.extractedDefaultExportName) { + namesToRegister.push({ + variableName: this.extractedDefaultExportName, + uniqueLocalName: "default", + }); + } + return ` +(function () { + var reactHotLoader = require('react-hot-loader').default; + var leaveModule = require('react-hot-loader').leaveModule; + if (!reactHotLoader) { + return; + } +${namesToRegister + .map( + ({variableName, uniqueLocalName}) => + ` reactHotLoader.register(${variableName}, "${uniqueLocalName}", "${this.filePath}");`, + ) + .join("\n")} + leaveModule(module); +})();`; + } + + process(): boolean { + return false; + } +} diff --git a/src/transformers/RootTransformer.ts b/src/transformers/RootTransformer.ts index f9328e3c..6a5e64cc 100644 --- a/src/transformers/RootTransformer.ts +++ b/src/transformers/RootTransformer.ts @@ -10,6 +10,7 @@ import JSXTransformer from "./JSXTransformer"; import NumericSeparatorTransformer from "./NumericSeparatorTransformer"; import OptionalCatchBindingTransformer from "./OptionalCatchBindingTransformer"; import ReactDisplayNameTransformer from "./ReactDisplayNameTransformer"; +import ReactHotLoaderTransformer from "./ReactHotLoaderTransformer"; import Transformer from "./Transformer"; import TypeScriptTransformer from "./TypeScriptTransformer"; @@ -19,6 +20,7 @@ export default class RootTransformer { private tokens: TokenProcessor; private generatedVariables: Array = []; private isImportsTransformEnabled: boolean; + private isReactHotLoaderTransformEnabled: boolean; constructor( sucraseContext: SucraseContext, @@ -30,6 +32,7 @@ export default class RootTransformer { const {tokenProcessor, importProcessor} = sucraseContext; this.tokens = tokenProcessor; this.isImportsTransformEnabled = transforms.includes("imports"); + this.isReactHotLoaderTransformEnabled = transforms.includes("react-hot-loader"); this.transformers.push(new NumericSeparatorTransformer(tokenProcessor)); this.transformers.push(new OptionalCatchBindingTransformer(tokenProcessor, this.nameManager)); @@ -42,6 +45,15 @@ export default class RootTransformer { ); } + let reactHotLoaderTransformer = null; + if (transforms.includes("react-hot-loader")) { + if (!options.filePath) { + throw new Error("filePath is required when using the react-hot-loader transform."); + } + reactHotLoaderTransformer = new ReactHotLoaderTransformer(tokenProcessor, options.filePath); + this.transformers.push(reactHotLoaderTransformer); + } + // Note that we always want to enable the imports transformer, even when the import transform // itself isn't enabled, since we need to do type-only import pruning for both Flow and // TypeScript. @@ -54,12 +66,19 @@ export default class RootTransformer { this, tokenProcessor, importProcessor, + this.nameManager, + reactHotLoaderTransformer, enableLegacyBabel5ModuleInterop, ), ); } else { this.transformers.push( - new ESMImportTransformer(tokenProcessor, transforms.includes("typescript")), + new ESMImportTransformer( + tokenProcessor, + this.nameManager, + reactHotLoaderTransformer, + transforms.includes("typescript"), + ), ); } @@ -210,6 +229,11 @@ export default class RootTransformer { throw new Error("Expected non-null context ID on class."); } this.tokens.copyExpectedToken(tt.braceL); + if (this.isReactHotLoaderTransformEnabled) { + this.tokens.appendCode( + "__reactstandin__regenerateByEval(key, code) {this[key] = eval(code);}", + ); + } const needsConstructorInit = constructorInitializerStatements.length + instanceInitializerNames.length > 0; diff --git a/test/index-test.ts b/test/index-test.ts index d4b3d387..a057f313 100644 --- a/test/index-test.ts +++ b/test/index-test.ts @@ -4,7 +4,7 @@ import {getFormattedTokens} from "../src"; describe("getFormattedTokens", () => { it("formats a simple program", () => { - assert.equal( + assert.strictEqual( getFormattedTokens( `\ if (foo) { diff --git a/test/prefixes.ts b/test/prefixes.ts index 9e89de67..5c2590b1 100644 --- a/test/prefixes.ts +++ b/test/prefixes.ts @@ -7,3 +7,6 @@ if (obj != null) { for (var key in obj) { \ if (Object.prototype.hasOwnProperty.call(obj, key)) newObj[key] = obj[key]; } } \ newObj.default = obj; return newObj; } }`; export const ESMODULE_PREFIX = 'Object.defineProperty(exports, "__esModule", {value: true});'; +export const RHL_PREFIX = `(function () { \ +var enterModule = require('react-hot-loader').enterModule; enterModule && enterModule(module); \ +})();`; diff --git a/test/react-hot-loader-test.ts b/test/react-hot-loader-test.ts new file mode 100644 index 00000000..c8474cbe --- /dev/null +++ b/test/react-hot-loader-test.ts @@ -0,0 +1,203 @@ +import {Transform} from "../src"; +import {ESMODULE_PREFIX, IMPORT_DEFAULT_PREFIX, RHL_PREFIX} from "./prefixes"; +import {assertResult} from "./util"; + +function assertCJSResult( + code: string, + expectedResult: string, + extraTransforms: Array = [], +): void { + assertResult(code, expectedResult, { + transforms: ["jsx", "imports", "react-hot-loader", ...extraTransforms], + filePath: "sample.tsx", + }); +} + +function assertESMResult( + code: string, + expectedResult: string, + extraTransforms: Array = [], +): void { + assertResult(code, expectedResult, { + transforms: ["jsx", "react-hot-loader", ...extraTransforms], + filePath: "sample.tsx", + }); +} + +describe("transform react-hot-loader", () => { + it("transforms common cases in CJS mode", () => { + assertCJSResult( + ` + import React from 'react'; + + const x = 3; + + function blah() { + } + + class Foo extends React.Component { + _handleSomething = () => { + return 3; + }; + + render() { + return ; + } + } + + class SomeOtherClass { + test() {} + } + + export default 12; + `, + `"use strict";const _jsxFileName = "sample.tsx";${RHL_PREFIX}${IMPORT_DEFAULT_PREFIX}${ESMODULE_PREFIX} + var _react = require('react'); var _react2 = _interopRequireDefault(_react); + + const x = 3; + + function blah() { + } + + class Foo extends _react2.default.Component {__reactstandin__regenerateByEval(key, code) {this[key] = eval(code);}constructor(...args) { super(...args); Foo.prototype.__init.call(this); } + __init() {this._handleSomething = () => { + return 3; + }} + + render() { + return _react2.default.createElement('span', { onChange: this._handleSomething, __self: this, __source: {fileName: _jsxFileName, lineNumber: 15}} ); + } + } + + class SomeOtherClass {__reactstandin__regenerateByEval(key, code) {this[key] = eval(code);} + test() {} + } + + let _default; exports. default = _default = 12; + +(function () { + var reactHotLoader = require('react-hot-loader').default; + var leaveModule = require('react-hot-loader').leaveModule; + if (!reactHotLoader) { + return; + } + reactHotLoader.register(x, "x", "sample.tsx"); + reactHotLoader.register(blah, "blah", "sample.tsx"); + reactHotLoader.register(Foo, "Foo", "sample.tsx"); + reactHotLoader.register(SomeOtherClass, "SomeOtherClass", "sample.tsx"); + reactHotLoader.register(_default, "default", "sample.tsx"); + leaveModule(module); +})();`, + ); + }); + + it("transforms common cases in ESM mode", () => { + assertESMResult( + ` + import React from 'react'; + + const x = 3; + + function blah() { + } + + class Foo extends React.Component { + _handleSomething = () => { + return 3; + }; + + render() { + return ; + } + } + + class SomeOtherClass { + test() {} + } + + export default 12; + `, + `const _jsxFileName = "sample.tsx";${RHL_PREFIX} + import React from 'react'; + + const x = 3; + + function blah() { + } + + class Foo extends React.Component {__reactstandin__regenerateByEval(key, code) {this[key] = eval(code);}constructor(...args) { super(...args); Foo.prototype.__init.call(this); } + __init() {this._handleSomething = () => { + return 3; + }} + + render() { + return React.createElement('span', { onChange: this._handleSomething, __self: this, __source: {fileName: _jsxFileName, lineNumber: 15}} ); + } + } + + class SomeOtherClass {__reactstandin__regenerateByEval(key, code) {this[key] = eval(code);} + test() {} + } + + let _default; export default _default = 12; + +(function () { + var reactHotLoader = require('react-hot-loader').default; + var leaveModule = require('react-hot-loader').leaveModule; + if (!reactHotLoader) { + return; + } + reactHotLoader.register(x, "x", "sample.tsx"); + reactHotLoader.register(blah, "blah", "sample.tsx"); + reactHotLoader.register(Foo, "Foo", "sample.tsx"); + reactHotLoader.register(SomeOtherClass, "SomeOtherClass", "sample.tsx"); + reactHotLoader.register(_default, "default", "sample.tsx"); + leaveModule(module); +})();`, + ); + }); + + it("does not treat function type params as top-level declarations", () => { + assertESMResult( + ` + type Reducer = (u: U, t: T) => U; + const f = (x: number) => x + 1; + `, + `${RHL_PREFIX} + + const f = (x) => x + 1; + +(function () { + var reactHotLoader = require('react-hot-loader').default; + var leaveModule = require('react-hot-loader').leaveModule; + if (!reactHotLoader) { + return; + } + reactHotLoader.register(f, "f", "sample.tsx"); + leaveModule(module); +})();`, + ["typescript"], + ); + }); + + it("does not extract default to a variable when it already has a name", () => { + assertESMResult( + ` + export default function add() {} + `, + `${RHL_PREFIX} + export default function add() {} + +(function () { + var reactHotLoader = require('react-hot-loader').default; + var leaveModule = require('react-hot-loader').leaveModule; + if (!reactHotLoader) { + return; + } + reactHotLoader.register(add, "add", "sample.tsx"); + leaveModule(module); +})();`, + ["typescript"], + ); + }); +}); diff --git a/test/util.ts b/test/util.ts index 81175759..608570b1 100644 --- a/test/util.ts +++ b/test/util.ts @@ -7,7 +7,7 @@ export function assertResult( expectedResult: string, options: Options = {transforms: ["jsx", "imports"]}, ): void { - assert.equal(transform(code, options).code, expectedResult); + assert.strictEqual(transform(code, options).code, expectedResult); } export function devProps(lineNumber: number): string { diff --git a/tsconfig.json b/tsconfig.json index 96d6a2b7..be03c7c2 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -34,5 +34,6 @@ ], "exclude": [ "benchmark/sample", + "example-runner/example-repos" ] }