From ff9897d23ec713d4e0c802386dffe4024916749e Mon Sep 17 00:00:00 2001 From: irinakk Date: Wed, 10 Nov 2021 04:22:19 +0800 Subject: [PATCH] [React Refresh] support typescript namespace syntax (#22621) * [React Refresh] support typescript namespace syntax * [React Refresh] handle nested namespace Co-authored-by: Wang Yilin --- package.json | 3 +- .../src/ReactFreshBabelPlugin.js | 77 +++++++++-- .../__tests__/ReactFreshBabelPlugin-test.js | 25 ++++ .../__tests__/ReactFreshIntegration-test.js | 126 +++++++++++++----- .../ReactFreshBabelPlugin-test.js.snap | 28 ++++ yarn.lock | 13 ++ 6 files changed, 227 insertions(+), 45 deletions(-) diff --git a/package.json b/package.json index e4ea1c83be801..fb428f35c04b9 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "@babel/plugin-syntax-dynamic-import": "^7.8.3", "@babel/plugin-syntax-import-meta": "^7.10.4", "@babel/plugin-syntax-jsx": "^7.10.4", + "@babel/plugin-syntax-typescript": "^7.14.5", "@babel/plugin-transform-arrow-functions": "^7.10.4", "@babel/plugin-transform-async-to-generator": "^7.10.4", "@babel/plugin-transform-block-scoped-functions": "^7.10.4", @@ -35,7 +36,6 @@ "@babel/preset-flow": "^7.10.4", "@babel/preset-react": "^7.10.4", "@babel/traverse": "^7.11.0", - "web-streams-polyfill": "^3.1.1", "abort-controller": "^3.0.0", "art": "0.10.1", "babel-eslint": "^10.0.3", @@ -96,6 +96,7 @@ "through2": "^3.0.1", "tmp": "^0.1.0", "typescript": "^3.7.5", + "web-streams-polyfill": "^3.1.1", "webpack": "^4.41.2", "yargs": "^15.3.1" }, diff --git a/packages/react-refresh/src/ReactFreshBabelPlugin.js b/packages/react-refresh/src/ReactFreshBabelPlugin.js index 64013db181a3f..9d1aed0d1d793 100644 --- a/packages/react-refresh/src/ReactFreshBabelPlugin.js +++ b/packages/react-refresh/src/ReactFreshBabelPlugin.js @@ -478,11 +478,16 @@ export default function(babel, opts = {}) { const node = path.node; let programPath; let insertAfterPath; + let modulePrefix = ''; switch (path.parent.type) { case 'Program': insertAfterPath = path; programPath = path.parentPath; break; + case 'TSModuleBlock': + insertAfterPath = path; + programPath = insertAfterPath.parentPath.parentPath; + break; case 'ExportNamedDeclaration': insertAfterPath = path.parentPath; programPath = insertAfterPath.parentPath; @@ -494,6 +499,28 @@ export default function(babel, opts = {}) { default: return; } + + // These types can be nested in typescript namespace + // We need to find the export chain + // Or return if it stays local + if ( + path.parent.type === 'TSModuleBlock' || + path.parent.type === 'ExportNamedDeclaration' + ) { + while (programPath.type !== 'Program') { + if (programPath.type === 'TSModuleDeclaration') { + if ( + programPath.parentPath.type !== 'Program' && + programPath.parentPath.type !== 'ExportNamedDeclaration' + ) { + return; + } + modulePrefix = programPath.node.id.name + '$' + modulePrefix; + } + programPath = programPath.parentPath; + } + } + const id = node.id; if (id === null) { // We don't currently handle anonymous default exports. @@ -512,20 +539,17 @@ export default function(babel, opts = {}) { seenForRegistration.add(node); // Don't mutate the tree above this point. + const innerName = modulePrefix + inferredName; // export function Named() {} // function Named() {} - findInnerComponents( - inferredName, - path, - (persistentID, targetExpr) => { - const handle = createRegistration(programPath, persistentID); - insertAfterPath.insertAfter( - t.expressionStatement( - t.assignmentExpression('=', handle, targetExpr), - ), - ); - }, - ); + findInnerComponents(innerName, path, (persistentID, targetExpr) => { + const handle = createRegistration(programPath, persistentID); + insertAfterPath.insertAfter( + t.expressionStatement( + t.assignmentExpression('=', handle, targetExpr), + ), + ); + }); }, exit(path) { const node = path.node; @@ -679,11 +703,16 @@ export default function(babel, opts = {}) { const node = path.node; let programPath; let insertAfterPath; + let modulePrefix = ''; switch (path.parent.type) { case 'Program': insertAfterPath = path; programPath = path.parentPath; break; + case 'TSModuleBlock': + insertAfterPath = path; + programPath = insertAfterPath.parentPath.parentPath; + break; case 'ExportNamedDeclaration': insertAfterPath = path.parentPath; programPath = insertAfterPath.parentPath; @@ -696,6 +725,27 @@ export default function(babel, opts = {}) { return; } + // These types can be nested in typescript namespace + // We need to find the export chain + // Or return if it stays local + if ( + path.parent.type === 'TSModuleBlock' || + path.parent.type === 'ExportNamedDeclaration' + ) { + while (programPath.type !== 'Program') { + if (programPath.type === 'TSModuleDeclaration') { + if ( + programPath.parentPath.type !== 'Program' && + programPath.parentPath.type !== 'ExportNamedDeclaration' + ) { + return; + } + modulePrefix = programPath.node.id.name + '$' + modulePrefix; + } + programPath = programPath.parentPath; + } + } + // Make sure we're not mutating the same tree twice. // This can happen if another Babel plugin replaces parents. if (seenForRegistration.has(node)) { @@ -710,8 +760,9 @@ export default function(babel, opts = {}) { } const declPath = declPaths[0]; const inferredName = declPath.node.id.name; + const innerName = modulePrefix + inferredName; findInnerComponents( - inferredName, + innerName, declPath, (persistentID, targetExpr, targetPath) => { if (targetPath === null) { diff --git a/packages/react-refresh/src/__tests__/ReactFreshBabelPlugin-test.js b/packages/react-refresh/src/__tests__/ReactFreshBabelPlugin-test.js index f56f16069dd77..d528d46bb8eaf 100644 --- a/packages/react-refresh/src/__tests__/ReactFreshBabelPlugin-test.js +++ b/packages/react-refresh/src/__tests__/ReactFreshBabelPlugin-test.js @@ -536,4 +536,29 @@ describe('ReactFreshBabelPlugin', () => { `), ).toMatchSnapshot(); }); + + it('supports typescript namespace syntax', () => { + expect( + transform( + ` + namespace Foo { + export namespace Bar { + export const A = () => {}; + + function B() {}; + export const B1 = B; + } + + export const C = () => {}; + export function D() {}; + + namespace NotExported { + export const E = () => {}; + } + } + `, + {plugins: [['@babel/plugin-syntax-typescript', {isTSX: true}]]}, + ), + ).toMatchSnapshot(); + }); }); diff --git a/packages/react-refresh/src/__tests__/ReactFreshIntegration-test.js b/packages/react-refresh/src/__tests__/ReactFreshIntegration-test.js index 09edf1d897288..8a98fe097f1b9 100644 --- a/packages/react-refresh/src/__tests__/ReactFreshIntegration-test.js +++ b/packages/react-refresh/src/__tests__/ReactFreshIntegration-test.js @@ -18,6 +18,7 @@ let act; const babel = require('@babel/core'); const freshPlugin = require('react-refresh/babel'); +const ts = require('typescript'); describe('ReactFreshIntegration', () => { let container; @@ -46,42 +47,72 @@ describe('ReactFreshIntegration', () => { } }); + function executeCommon(source, compileDestructuring) { + const compiled = babel.transform(source, { + babelrc: false, + presets: ['@babel/react'], + plugins: [ + [freshPlugin, {skipEnvCheck: true}], + '@babel/plugin-transform-modules-commonjs', + compileDestructuring && '@babel/plugin-transform-destructuring', + ].filter(Boolean), + }).code; + return executeCompiled(compiled); + } + + function executeCompiled(compiled) { + exportsObj = {}; + // eslint-disable-next-line no-new-func + new Function( + 'global', + 'React', + 'exports', + '$RefreshReg$', + '$RefreshSig$', + compiled, + )(global, React, exportsObj, $RefreshReg$, $RefreshSig$); + // Module systems will register exports as a fallback. + // This is useful for cases when e.g. a class is exported, + // and we don't want to propagate the update beyond this module. + $RefreshReg$(exportsObj.default, 'exports.default'); + return exportsObj.default; + } + + function $RefreshReg$(type, id) { + ReactFreshRuntime.register(type, id); + } + + function $RefreshSig$() { + return ReactFreshRuntime.createSignatureFunctionForTransform(); + } + describe('with compiled destructuring', () => { - runTests(true); + runTests(executeCommon, testCommon); }); describe('without compiled destructuring', () => { - runTests(false); + runTests(executeCommon, testCommon); }); - function runTests(compileDestructuring) { - function execute(source) { - const compiled = babel.transform(source, { + describe('with typescript syntax', () => { + runTests(function(source) { + const typescriptSource = babel.transform(source, { babelrc: false, + configFile: false, presets: ['@babel/react'], plugins: [ [freshPlugin, {skipEnvCheck: true}], - '@babel/plugin-transform-modules-commonjs', - compileDestructuring && '@babel/plugin-transform-destructuring', - ].filter(Boolean), + ['@babel/plugin-syntax-typescript', {isTSX: true}], + ], }).code; - exportsObj = {}; - // eslint-disable-next-line no-new-func - new Function( - 'global', - 'React', - 'exports', - '$RefreshReg$', - '$RefreshSig$', - compiled, - )(global, React, exportsObj, $RefreshReg$, $RefreshSig$); - // Module systems will register exports as a fallback. - // This is useful for cases when e.g. a class is exported, - // and we don't want to propagate the update beyond this module. - $RefreshReg$(exportsObj.default, 'exports.default'); - return exportsObj.default; - } + const compiled = ts.transpileModule(typescriptSource, { + module: ts.ModuleKind.CommonJS, + }).outputText; + return executeCompiled(compiled); + }, testTypescript); + }); + function runTests(execute, test) { function render(source) { const Component = execute(source); act(() => { @@ -127,14 +158,10 @@ describe('ReactFreshIntegration', () => { expect(ReactFreshRuntime._getMountedRootCount()).toBe(1); } - function $RefreshReg$(type, id) { - ReactFreshRuntime.register(type, id); - } - - function $RefreshSig$() { - return ReactFreshRuntime.createSignatureFunctionForTransform(); - } + test(render, patch); + } + function testCommon(render, patch) { it('reloads function declarations', () => { if (__DEV__) { render(` @@ -1947,4 +1974,41 @@ describe('ReactFreshIntegration', () => { }); }); } + + function testTypescript(render, patch) { + it('reloads component exported in typescript namespace', () => { + if (__DEV__) { + render(` + namespace Foo { + export namespace Bar { + export const Child = ({prop}) => { + return

{prop}1

+ }; + } + } + + export default function Parent() { + return ; + } + `); + const el = container.firstChild; + expect(el.textContent).toBe('A1'); + patch(` + namespace Foo { + export namespace Bar { + export const Child = ({prop}) => { + return

{prop}2

+ }; + } + } + + export default function Parent() { + return ; + } + `); + expect(container.firstChild).toBe(el); + expect(el.textContent).toBe('B2'); + } + }); + } }); diff --git a/packages/react-refresh/src/__tests__/__snapshots__/ReactFreshBabelPlugin-test.js.snap b/packages/react-refresh/src/__tests__/__snapshots__/ReactFreshBabelPlugin-test.js.snap index 7c1276f817880..7b18834ae495f 100644 --- a/packages/react-refresh/src/__tests__/__snapshots__/ReactFreshBabelPlugin-test.js.snap +++ b/packages/react-refresh/src/__tests__/__snapshots__/ReactFreshBabelPlugin-test.js.snap @@ -618,6 +618,34 @@ $RefreshReg$(_c, "Hello"); $RefreshReg$(_c2, "Bar"); `; +exports[`ReactFreshBabelPlugin supports typescript namespace syntax 1`] = ` +namespace Foo { + export namespace Bar { + export const A = () => {}; + _c = A; + function B() {} + _c2 = B; + ; + export const B1 = B; + } + export const C = () => {}; + _c3 = C; + export function D() {} + _c4 = D; + ; + namespace NotExported { + export const E = () => {}; + } +} + +var _c, _c2, _c3, _c4; + +$RefreshReg$(_c, "Foo$Bar$A"); +$RefreshReg$(_c2, "Foo$Bar$B"); +$RefreshReg$(_c3, "Foo$C"); +$RefreshReg$(_c4, "Foo$D"); +`; + exports[`ReactFreshBabelPlugin uses custom identifiers for $RefreshReg$ and $RefreshSig$ 1`] = ` var _s = import.meta.refreshSig(); diff --git a/yarn.lock b/yarn.lock index 0055fc9a6d2f7..d02e5cf4a5c43 100644 --- a/yarn.lock +++ b/yarn.lock @@ -405,6 +405,11 @@ resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.13.0.tgz#806526ce125aed03373bc416a828321e3a6a33af" integrity sha512-ZPafIPSwzUlAoWT8DKs1W2VyF2gOWthGd5NGFMsBcMMol+ZhK+EQY/e6V96poa6PA/Bh+C9plWN0hXO1uB8AfQ== +"@babel/helper-plugin-utils@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.14.5.tgz#5ac822ce97eec46741ab70a517971e443a70c5a9" + integrity sha512-/37qQCE3K0vvZKwoK4XU/irIJQdIfCJuhU5eKnNxpFDsOkgFaUAwbv+RYw6eYgsC0E4hS7r5KqGULUogqui0fQ== + "@babel/helper-regex@^7.10.4": version "7.10.5" resolved "https://registry.yarnpkg.com/@babel/helper-regex/-/helper-regex-7.10.5.tgz#32dfbb79899073c415557053a19bd055aae50ae0" @@ -847,6 +852,13 @@ dependencies: "@babel/helper-plugin-utils" "^7.12.13" +"@babel/plugin-syntax-typescript@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.14.5.tgz#b82c6ce471b165b5ce420cf92914d6fb46225716" + integrity sha512-u6OXzDaIXjEstBRRoBCQ/uKQKlbuaeE5in0RvWdA4pN6AhqxTIwUsnHPU1CFZA/amYObMsuWhYfRl3Ch90HD0Q== + dependencies: + "@babel/helper-plugin-utils" "^7.14.5" + "@babel/plugin-transform-arrow-functions@^7.0.0": version "7.8.3" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.8.3.tgz#82776c2ed0cd9e1a49956daeb896024c9473b8b6" @@ -6442,6 +6454,7 @@ eslint-plugin-no-unsanitized@3.1.2: "eslint-plugin-react-internal@link:./scripts/eslint-rules": version "0.0.0" + uid "" eslint-plugin-react@^6.7.1: version "6.10.3"