├── .editorconfig ├── .eslintrc.cjs ├── .gitattributes ├── .github └── workflows │ └── test.yml ├── .gitignore ├── .prettierignore ├── .vim └── coc-settings.json ├── .vscode └── extensions.json ├── .yarn ├── releases │ └── yarn-3.5.0.cjs └── sdks │ ├── eslint │ ├── bin │ │ └── eslint.js │ ├── lib │ │ └── api.js │ └── package.json │ ├── integrations.yml │ ├── prettier │ ├── index.js │ └── package.json │ └── typescript │ ├── bin │ ├── tsc │ └── tsserver │ ├── lib │ ├── tsc.js │ ├── tsserver.js │ ├── tsserverlibrary.js │ └── typescript.js │ └── package.json ├── .yarnrc.yml ├── CHANGELOG.md ├── README.md ├── babel-cjs.config.js ├── babel.config.js ├── cjs └── package.json ├── img ├── example1-after.png ├── example1-before.png └── logo.png ├── package.json ├── renovate.json ├── src ├── analysis.ts ├── analysis │ ├── class_fields.ts │ ├── effect.ts │ ├── error.ts │ ├── lib.ts │ ├── local.ts │ ├── pre.ts │ ├── prop.ts │ ├── state.ts │ ├── track_member.ts │ └── user_defined.ts ├── index.test.ts ├── index.ts └── utils.ts ├── tsconfig.json └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | 7 | [*.{js,json,yml}] 8 | charset = utf-8 9 | indent_style = space 10 | indent_size = 2 11 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /** @type {import("eslint").Linter.Config} */ 4 | const config = { 5 | extends: [ 6 | "eslint:recommended", 7 | "plugin:@typescript-eslint/recommended", 8 | "plugin:node/recommended", 9 | "prettier", 10 | ], 11 | plugins: ["@babel/development"], 12 | parser: "@typescript-eslint/parser", 13 | reportUnusedDisableDirectives: true, 14 | rules: { 15 | "@typescript-eslint/no-non-null-assertion": "off", 16 | "@typescript-eslint/no-unused-vars": [ 17 | "warn", 18 | { 19 | varsIgnorePattern: "^_", 20 | argsIgnorePattern: "^_", 21 | caughtErrorsIgnorePattern: "^_", 22 | }, 23 | ], 24 | "no-constant-condition": [ 25 | "error", 26 | { 27 | checkLoops: false, 28 | }, 29 | ], 30 | "node/no-unsupported-features/es-syntax": "off", 31 | // Specifying *.js for *.ts doesn't work now 32 | "node/no-missing-import": "off", 33 | // Disabling it until it skips type-only imports 34 | "node/no-unpublished-import": "off", 35 | // We target newer Node, so this is unnecessary 36 | "no-inner-declarations": "off", 37 | }, 38 | overrides: [ 39 | { 40 | files: ["src/**/*.ts"], 41 | extends: [ 42 | "plugin:@typescript-eslint/recommended-requiring-type-checking", 43 | ], 44 | parserOptions: { 45 | project: ["./tsconfig.json"], 46 | tsconfigRootDir: __dirname, 47 | }, 48 | }, 49 | { 50 | files: ["*.test.ts"], 51 | extends: ["plugin:jest/recommended"], 52 | rules: { 53 | "node/no-unpublished-import": "off", 54 | }, 55 | }, 56 | ], 57 | ignorePatterns: ["cjs/dist/**/*", "dist/**/*", "coverage/**/*"], 58 | }; 59 | module.exports = config; 60 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | /.yarn/** linguist-vendored 2 | /.yarn/releases/* binary 3 | /.yarn/plugins/**/* binary 4 | /.pnp.* binary linguist-generated 5 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: build and test 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | node-version: [16.x, 18.x] 11 | 12 | steps: 13 | - uses: actions/checkout@v3 14 | - uses: actions/setup-node@v3 15 | with: 16 | node-version: ${{ matrix.node-version }} 17 | cache: 'yarn' 18 | - name: Install yarn dependencies 19 | run: yarn install --immutable 20 | - name: Build 21 | run: yarn build 22 | - name: Run tests 23 | run: yarn test 24 | - name: Typecheck 25 | run: yarn tsc 26 | - name: Lint 27 | run: yarn lint --max-warnings 0 28 | - name: Check formatting 29 | run: yarn fmt:check 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional stylelint cache 58 | .stylelintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variable files 76 | .env 77 | .env.development.local 78 | .env.test.local 79 | .env.production.local 80 | .env.local 81 | 82 | # parcel-bundler cache (https://parceljs.org/) 83 | .cache 84 | .parcel-cache 85 | 86 | # Next.js build output 87 | .next 88 | out 89 | 90 | # Nuxt.js build / generate output 91 | .nuxt 92 | dist 93 | 94 | # Gatsby files 95 | .cache/ 96 | # Comment in the public line in if your project uses Gatsby and not Next.js 97 | # https://nextjs.org/blog/next-9-1#public-directory-support 98 | # public 99 | 100 | # vuepress build output 101 | .vuepress/dist 102 | 103 | # vuepress v2.x temp and cache directory 104 | .temp 105 | .cache 106 | 107 | # Docusaurus cache and generated files 108 | .docusaurus 109 | 110 | # Serverless directories 111 | .serverless/ 112 | 113 | # FuseBox cache 114 | .fusebox/ 115 | 116 | # DynamoDB Local files 117 | .dynamodb/ 118 | 119 | # TernJS port file 120 | .tern-port 121 | 122 | # Stores VSCode versions used for testing VSCode extensions 123 | .vscode-test 124 | 125 | # yarn v2 126 | .yarn/cache 127 | .yarn/unplugged 128 | .yarn/build-state.yml 129 | .yarn/install-state.gz 130 | .pnp.* 131 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .github/workflows 2 | .pnp.cjs 3 | .pnp.loader.mjs 4 | .vim 5 | .vscode 6 | .yarn 7 | .yarnrc.yml 8 | cjs/dist 9 | dist 10 | -------------------------------------------------------------------------------- /.vim/coc-settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "workspace.workspaceFolderCheckCwd": false, 3 | "tsserver.tsdk": ".yarn/sdks/typescript/lib", 4 | "eslint.packageManager": "yarn", 5 | "eslint.nodePath": ".yarn/sdks" 6 | } 7 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "arcanis.vscode-zipfs", 4 | "esbenp.prettier-vscode", 5 | "dbaeumer.vscode-eslint" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /.yarn/sdks/eslint/bin/eslint.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const {existsSync} = require(`fs`); 4 | const {createRequire} = require(`module`); 5 | const {resolve} = require(`path`); 6 | 7 | const relPnpApiPath = "../../../../.pnp.cjs"; 8 | 9 | const absPnpApiPath = resolve(__dirname, relPnpApiPath); 10 | const absRequire = createRequire(absPnpApiPath); 11 | 12 | if (existsSync(absPnpApiPath)) { 13 | if (!process.versions.pnp) { 14 | // Setup the environment to be able to require eslint/bin/eslint.js 15 | require(absPnpApiPath).setup(); 16 | } 17 | } 18 | 19 | // Defer to the real eslint/bin/eslint.js your application uses 20 | module.exports = absRequire(`eslint/bin/eslint.js`); 21 | -------------------------------------------------------------------------------- /.yarn/sdks/eslint/lib/api.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const {existsSync} = require(`fs`); 4 | const {createRequire} = require(`module`); 5 | const {resolve} = require(`path`); 6 | 7 | const relPnpApiPath = "../../../../.pnp.cjs"; 8 | 9 | const absPnpApiPath = resolve(__dirname, relPnpApiPath); 10 | const absRequire = createRequire(absPnpApiPath); 11 | 12 | if (existsSync(absPnpApiPath)) { 13 | if (!process.versions.pnp) { 14 | // Setup the environment to be able to require eslint 15 | require(absPnpApiPath).setup(); 16 | } 17 | } 18 | 19 | // Defer to the real eslint your application uses 20 | module.exports = absRequire(`eslint`); 21 | -------------------------------------------------------------------------------- /.yarn/sdks/eslint/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "eslint", 3 | "version": "8.37.0-sdk", 4 | "main": "./lib/api.js", 5 | "type": "commonjs" 6 | } 7 | -------------------------------------------------------------------------------- /.yarn/sdks/integrations.yml: -------------------------------------------------------------------------------- 1 | # This file is automatically generated by @yarnpkg/sdks. 2 | # Manual changes might be lost! 3 | 4 | integrations: 5 | - vscode 6 | - vim 7 | -------------------------------------------------------------------------------- /.yarn/sdks/prettier/index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const {existsSync} = require(`fs`); 4 | const {createRequire} = require(`module`); 5 | const {resolve} = require(`path`); 6 | 7 | const relPnpApiPath = "../../../.pnp.cjs"; 8 | 9 | const absPnpApiPath = resolve(__dirname, relPnpApiPath); 10 | const absRequire = createRequire(absPnpApiPath); 11 | 12 | if (existsSync(absPnpApiPath)) { 13 | if (!process.versions.pnp) { 14 | // Setup the environment to be able to require prettier/index.js 15 | require(absPnpApiPath).setup(); 16 | } 17 | } 18 | 19 | // Defer to the real prettier/index.js your application uses 20 | module.exports = absRequire(`prettier/index.js`); 21 | -------------------------------------------------------------------------------- /.yarn/sdks/prettier/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "prettier", 3 | "version": "2.8.7-sdk", 4 | "main": "./index.js", 5 | "type": "commonjs" 6 | } 7 | -------------------------------------------------------------------------------- /.yarn/sdks/typescript/bin/tsc: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const {existsSync} = require(`fs`); 4 | const {createRequire} = require(`module`); 5 | const {resolve} = require(`path`); 6 | 7 | const relPnpApiPath = "../../../../.pnp.cjs"; 8 | 9 | const absPnpApiPath = resolve(__dirname, relPnpApiPath); 10 | const absRequire = createRequire(absPnpApiPath); 11 | 12 | if (existsSync(absPnpApiPath)) { 13 | if (!process.versions.pnp) { 14 | // Setup the environment to be able to require typescript/bin/tsc 15 | require(absPnpApiPath).setup(); 16 | } 17 | } 18 | 19 | // Defer to the real typescript/bin/tsc your application uses 20 | module.exports = absRequire(`typescript/bin/tsc`); 21 | -------------------------------------------------------------------------------- /.yarn/sdks/typescript/bin/tsserver: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const {existsSync} = require(`fs`); 4 | const {createRequire} = require(`module`); 5 | const {resolve} = require(`path`); 6 | 7 | const relPnpApiPath = "../../../../.pnp.cjs"; 8 | 9 | const absPnpApiPath = resolve(__dirname, relPnpApiPath); 10 | const absRequire = createRequire(absPnpApiPath); 11 | 12 | if (existsSync(absPnpApiPath)) { 13 | if (!process.versions.pnp) { 14 | // Setup the environment to be able to require typescript/bin/tsserver 15 | require(absPnpApiPath).setup(); 16 | } 17 | } 18 | 19 | // Defer to the real typescript/bin/tsserver your application uses 20 | module.exports = absRequire(`typescript/bin/tsserver`); 21 | -------------------------------------------------------------------------------- /.yarn/sdks/typescript/lib/tsc.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const {existsSync} = require(`fs`); 4 | const {createRequire} = require(`module`); 5 | const {resolve} = require(`path`); 6 | 7 | const relPnpApiPath = "../../../../.pnp.cjs"; 8 | 9 | const absPnpApiPath = resolve(__dirname, relPnpApiPath); 10 | const absRequire = createRequire(absPnpApiPath); 11 | 12 | if (existsSync(absPnpApiPath)) { 13 | if (!process.versions.pnp) { 14 | // Setup the environment to be able to require typescript/lib/tsc.js 15 | require(absPnpApiPath).setup(); 16 | } 17 | } 18 | 19 | // Defer to the real typescript/lib/tsc.js your application uses 20 | module.exports = absRequire(`typescript/lib/tsc.js`); 21 | -------------------------------------------------------------------------------- /.yarn/sdks/typescript/lib/tsserver.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const {existsSync} = require(`fs`); 4 | const {createRequire} = require(`module`); 5 | const {resolve} = require(`path`); 6 | 7 | const relPnpApiPath = "../../../../.pnp.cjs"; 8 | 9 | const absPnpApiPath = resolve(__dirname, relPnpApiPath); 10 | const absRequire = createRequire(absPnpApiPath); 11 | 12 | const moduleWrapper = tsserver => { 13 | if (!process.versions.pnp) { 14 | return tsserver; 15 | } 16 | 17 | const {isAbsolute} = require(`path`); 18 | const pnpApi = require(`pnpapi`); 19 | 20 | const isVirtual = str => str.match(/\/(\$\$virtual|__virtual__)\//); 21 | const isPortal = str => str.startsWith("portal:/"); 22 | const normalize = str => str.replace(/\\/g, `/`).replace(/^\/?/, `/`); 23 | 24 | const dependencyTreeRoots = new Set(pnpApi.getDependencyTreeRoots().map(locator => { 25 | return `${locator.name}@${locator.reference}`; 26 | })); 27 | 28 | // VSCode sends the zip paths to TS using the "zip://" prefix, that TS 29 | // doesn't understand. This layer makes sure to remove the protocol 30 | // before forwarding it to TS, and to add it back on all returned paths. 31 | 32 | function toEditorPath(str) { 33 | // We add the `zip:` prefix to both `.zip/` paths and virtual paths 34 | if (isAbsolute(str) && !str.match(/^\^?(zip:|\/zip\/)/) && (str.match(/\.zip\//) || isVirtual(str))) { 35 | // We also take the opportunity to turn virtual paths into physical ones; 36 | // this makes it much easier to work with workspaces that list peer 37 | // dependencies, since otherwise Ctrl+Click would bring us to the virtual 38 | // file instances instead of the real ones. 39 | // 40 | // We only do this to modules owned by the the dependency tree roots. 41 | // This avoids breaking the resolution when jumping inside a vendor 42 | // with peer dep (otherwise jumping into react-dom would show resolution 43 | // errors on react). 44 | // 45 | const resolved = isVirtual(str) ? pnpApi.resolveVirtual(str) : str; 46 | if (resolved) { 47 | const locator = pnpApi.findPackageLocator(resolved); 48 | if (locator && (dependencyTreeRoots.has(`${locator.name}@${locator.reference}`) || isPortal(locator.reference))) { 49 | str = resolved; 50 | } 51 | } 52 | 53 | str = normalize(str); 54 | 55 | if (str.match(/\.zip\//)) { 56 | switch (hostInfo) { 57 | // Absolute VSCode `Uri.fsPath`s need to start with a slash. 58 | // VSCode only adds it automatically for supported schemes, 59 | // so we have to do it manually for the `zip` scheme. 60 | // The path needs to start with a caret otherwise VSCode doesn't handle the protocol 61 | // 62 | // Ref: https://github.com/microsoft/vscode/issues/105014#issuecomment-686760910 63 | // 64 | // 2021-10-08: VSCode changed the format in 1.61. 65 | // Before | ^zip:/c:/foo/bar.zip/package.json 66 | // After | ^/zip//c:/foo/bar.zip/package.json 67 | // 68 | // 2022-04-06: VSCode changed the format in 1.66. 69 | // Before | ^/zip//c:/foo/bar.zip/package.json 70 | // After | ^/zip/c:/foo/bar.zip/package.json 71 | // 72 | // 2022-05-06: VSCode changed the format in 1.68 73 | // Before | ^/zip/c:/foo/bar.zip/package.json 74 | // After | ^/zip//c:/foo/bar.zip/package.json 75 | // 76 | case `vscode <1.61`: { 77 | str = `^zip:${str}`; 78 | } break; 79 | 80 | case `vscode <1.66`: { 81 | str = `^/zip/${str}`; 82 | } break; 83 | 84 | case `vscode <1.68`: { 85 | str = `^/zip${str}`; 86 | } break; 87 | 88 | case `vscode`: { 89 | str = `^/zip/${str}`; 90 | } break; 91 | 92 | // To make "go to definition" work, 93 | // We have to resolve the actual file system path from virtual path 94 | // and convert scheme to supported by [vim-rzip](https://github.com/lbrayner/vim-rzip) 95 | case `coc-nvim`: { 96 | str = normalize(resolved).replace(/\.zip\//, `.zip::`); 97 | str = resolve(`zipfile:${str}`); 98 | } break; 99 | 100 | // Support neovim native LSP and [typescript-language-server](https://github.com/theia-ide/typescript-language-server) 101 | // We have to resolve the actual file system path from virtual path, 102 | // everything else is up to neovim 103 | case `neovim`: { 104 | str = normalize(resolved).replace(/\.zip\//, `.zip::`); 105 | str = `zipfile://${str}`; 106 | } break; 107 | 108 | default: { 109 | str = `zip:${str}`; 110 | } break; 111 | } 112 | } else { 113 | str = str.replace(/^\/?/, process.platform === `win32` ? `` : `/`); 114 | } 115 | } 116 | 117 | return str; 118 | } 119 | 120 | function fromEditorPath(str) { 121 | switch (hostInfo) { 122 | case `coc-nvim`: { 123 | str = str.replace(/\.zip::/, `.zip/`); 124 | // The path for coc-nvim is in format of //zipfile://.yarn/... 125 | // So in order to convert it back, we use .* to match all the thing 126 | // before `zipfile:` 127 | return process.platform === `win32` 128 | ? str.replace(/^.*zipfile:\//, ``) 129 | : str.replace(/^.*zipfile:/, ``); 130 | } break; 131 | 132 | case `neovim`: { 133 | str = str.replace(/\.zip::/, `.zip/`); 134 | // The path for neovim is in format of zipfile:////.yarn/... 135 | return str.replace(/^zipfile:\/\//, ``); 136 | } break; 137 | 138 | case `vscode`: 139 | default: { 140 | return str.replace(/^\^?(zip:|\/zip(\/ts-nul-authority)?)\/+/, process.platform === `win32` ? `` : `/`) 141 | } break; 142 | } 143 | } 144 | 145 | // Force enable 'allowLocalPluginLoads' 146 | // TypeScript tries to resolve plugins using a path relative to itself 147 | // which doesn't work when using the global cache 148 | // https://github.com/microsoft/TypeScript/blob/1b57a0395e0bff191581c9606aab92832001de62/src/server/project.ts#L2238 149 | // VSCode doesn't want to enable 'allowLocalPluginLoads' due to security concerns but 150 | // TypeScript already does local loads and if this code is running the user trusts the workspace 151 | // https://github.com/microsoft/vscode/issues/45856 152 | const ConfiguredProject = tsserver.server.ConfiguredProject; 153 | const {enablePluginsWithOptions: originalEnablePluginsWithOptions} = ConfiguredProject.prototype; 154 | ConfiguredProject.prototype.enablePluginsWithOptions = function() { 155 | this.projectService.allowLocalPluginLoads = true; 156 | return originalEnablePluginsWithOptions.apply(this, arguments); 157 | }; 158 | 159 | // And here is the point where we hijack the VSCode <-> TS communications 160 | // by adding ourselves in the middle. We locate everything that looks 161 | // like an absolute path of ours and normalize it. 162 | 163 | const Session = tsserver.server.Session; 164 | const {onMessage: originalOnMessage, send: originalSend} = Session.prototype; 165 | let hostInfo = `unknown`; 166 | 167 | Object.assign(Session.prototype, { 168 | onMessage(/** @type {string | object} */ message) { 169 | const isStringMessage = typeof message === 'string'; 170 | const parsedMessage = isStringMessage ? JSON.parse(message) : message; 171 | 172 | if ( 173 | parsedMessage != null && 174 | typeof parsedMessage === `object` && 175 | parsedMessage.arguments && 176 | typeof parsedMessage.arguments.hostInfo === `string` 177 | ) { 178 | hostInfo = parsedMessage.arguments.hostInfo; 179 | if (hostInfo === `vscode` && process.env.VSCODE_IPC_HOOK) { 180 | const [, major, minor] = (process.env.VSCODE_IPC_HOOK.match( 181 | // The RegExp from https://semver.org/ but without the caret at the start 182 | /(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/ 183 | ) ?? []).map(Number) 184 | 185 | if (major === 1) { 186 | if (minor < 61) { 187 | hostInfo += ` <1.61`; 188 | } else if (minor < 66) { 189 | hostInfo += ` <1.66`; 190 | } else if (minor < 68) { 191 | hostInfo += ` <1.68`; 192 | } 193 | } 194 | } 195 | } 196 | 197 | const processedMessageJSON = JSON.stringify(parsedMessage, (key, value) => { 198 | return typeof value === 'string' ? fromEditorPath(value) : value; 199 | }); 200 | 201 | return originalOnMessage.call( 202 | this, 203 | isStringMessage ? processedMessageJSON : JSON.parse(processedMessageJSON) 204 | ); 205 | }, 206 | 207 | send(/** @type {any} */ msg) { 208 | return originalSend.call(this, JSON.parse(JSON.stringify(msg, (key, value) => { 209 | return typeof value === `string` ? toEditorPath(value) : value; 210 | }))); 211 | } 212 | }); 213 | 214 | return tsserver; 215 | }; 216 | 217 | if (existsSync(absPnpApiPath)) { 218 | if (!process.versions.pnp) { 219 | // Setup the environment to be able to require typescript/lib/tsserver.js 220 | require(absPnpApiPath).setup(); 221 | } 222 | } 223 | 224 | // Defer to the real typescript/lib/tsserver.js your application uses 225 | module.exports = moduleWrapper(absRequire(`typescript/lib/tsserver.js`)); 226 | -------------------------------------------------------------------------------- /.yarn/sdks/typescript/lib/tsserverlibrary.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const {existsSync} = require(`fs`); 4 | const {createRequire} = require(`module`); 5 | const {resolve} = require(`path`); 6 | 7 | const relPnpApiPath = "../../../../.pnp.cjs"; 8 | 9 | const absPnpApiPath = resolve(__dirname, relPnpApiPath); 10 | const absRequire = createRequire(absPnpApiPath); 11 | 12 | const moduleWrapper = tsserver => { 13 | if (!process.versions.pnp) { 14 | return tsserver; 15 | } 16 | 17 | const {isAbsolute} = require(`path`); 18 | const pnpApi = require(`pnpapi`); 19 | 20 | const isVirtual = str => str.match(/\/(\$\$virtual|__virtual__)\//); 21 | const isPortal = str => str.startsWith("portal:/"); 22 | const normalize = str => str.replace(/\\/g, `/`).replace(/^\/?/, `/`); 23 | 24 | const dependencyTreeRoots = new Set(pnpApi.getDependencyTreeRoots().map(locator => { 25 | return `${locator.name}@${locator.reference}`; 26 | })); 27 | 28 | // VSCode sends the zip paths to TS using the "zip://" prefix, that TS 29 | // doesn't understand. This layer makes sure to remove the protocol 30 | // before forwarding it to TS, and to add it back on all returned paths. 31 | 32 | function toEditorPath(str) { 33 | // We add the `zip:` prefix to both `.zip/` paths and virtual paths 34 | if (isAbsolute(str) && !str.match(/^\^?(zip:|\/zip\/)/) && (str.match(/\.zip\//) || isVirtual(str))) { 35 | // We also take the opportunity to turn virtual paths into physical ones; 36 | // this makes it much easier to work with workspaces that list peer 37 | // dependencies, since otherwise Ctrl+Click would bring us to the virtual 38 | // file instances instead of the real ones. 39 | // 40 | // We only do this to modules owned by the the dependency tree roots. 41 | // This avoids breaking the resolution when jumping inside a vendor 42 | // with peer dep (otherwise jumping into react-dom would show resolution 43 | // errors on react). 44 | // 45 | const resolved = isVirtual(str) ? pnpApi.resolveVirtual(str) : str; 46 | if (resolved) { 47 | const locator = pnpApi.findPackageLocator(resolved); 48 | if (locator && (dependencyTreeRoots.has(`${locator.name}@${locator.reference}`) || isPortal(locator.reference))) { 49 | str = resolved; 50 | } 51 | } 52 | 53 | str = normalize(str); 54 | 55 | if (str.match(/\.zip\//)) { 56 | switch (hostInfo) { 57 | // Absolute VSCode `Uri.fsPath`s need to start with a slash. 58 | // VSCode only adds it automatically for supported schemes, 59 | // so we have to do it manually for the `zip` scheme. 60 | // The path needs to start with a caret otherwise VSCode doesn't handle the protocol 61 | // 62 | // Ref: https://github.com/microsoft/vscode/issues/105014#issuecomment-686760910 63 | // 64 | // 2021-10-08: VSCode changed the format in 1.61. 65 | // Before | ^zip:/c:/foo/bar.zip/package.json 66 | // After | ^/zip//c:/foo/bar.zip/package.json 67 | // 68 | // 2022-04-06: VSCode changed the format in 1.66. 69 | // Before | ^/zip//c:/foo/bar.zip/package.json 70 | // After | ^/zip/c:/foo/bar.zip/package.json 71 | // 72 | // 2022-05-06: VSCode changed the format in 1.68 73 | // Before | ^/zip/c:/foo/bar.zip/package.json 74 | // After | ^/zip//c:/foo/bar.zip/package.json 75 | // 76 | case `vscode <1.61`: { 77 | str = `^zip:${str}`; 78 | } break; 79 | 80 | case `vscode <1.66`: { 81 | str = `^/zip/${str}`; 82 | } break; 83 | 84 | case `vscode <1.68`: { 85 | str = `^/zip${str}`; 86 | } break; 87 | 88 | case `vscode`: { 89 | str = `^/zip/${str}`; 90 | } break; 91 | 92 | // To make "go to definition" work, 93 | // We have to resolve the actual file system path from virtual path 94 | // and convert scheme to supported by [vim-rzip](https://github.com/lbrayner/vim-rzip) 95 | case `coc-nvim`: { 96 | str = normalize(resolved).replace(/\.zip\//, `.zip::`); 97 | str = resolve(`zipfile:${str}`); 98 | } break; 99 | 100 | // Support neovim native LSP and [typescript-language-server](https://github.com/theia-ide/typescript-language-server) 101 | // We have to resolve the actual file system path from virtual path, 102 | // everything else is up to neovim 103 | case `neovim`: { 104 | str = normalize(resolved).replace(/\.zip\//, `.zip::`); 105 | str = `zipfile://${str}`; 106 | } break; 107 | 108 | default: { 109 | str = `zip:${str}`; 110 | } break; 111 | } 112 | } else { 113 | str = str.replace(/^\/?/, process.platform === `win32` ? `` : `/`); 114 | } 115 | } 116 | 117 | return str; 118 | } 119 | 120 | function fromEditorPath(str) { 121 | switch (hostInfo) { 122 | case `coc-nvim`: { 123 | str = str.replace(/\.zip::/, `.zip/`); 124 | // The path for coc-nvim is in format of //zipfile://.yarn/... 125 | // So in order to convert it back, we use .* to match all the thing 126 | // before `zipfile:` 127 | return process.platform === `win32` 128 | ? str.replace(/^.*zipfile:\//, ``) 129 | : str.replace(/^.*zipfile:/, ``); 130 | } break; 131 | 132 | case `neovim`: { 133 | str = str.replace(/\.zip::/, `.zip/`); 134 | // The path for neovim is in format of zipfile:////.yarn/... 135 | return str.replace(/^zipfile:\/\//, ``); 136 | } break; 137 | 138 | case `vscode`: 139 | default: { 140 | return str.replace(/^\^?(zip:|\/zip(\/ts-nul-authority)?)\/+/, process.platform === `win32` ? `` : `/`) 141 | } break; 142 | } 143 | } 144 | 145 | // Force enable 'allowLocalPluginLoads' 146 | // TypeScript tries to resolve plugins using a path relative to itself 147 | // which doesn't work when using the global cache 148 | // https://github.com/microsoft/TypeScript/blob/1b57a0395e0bff191581c9606aab92832001de62/src/server/project.ts#L2238 149 | // VSCode doesn't want to enable 'allowLocalPluginLoads' due to security concerns but 150 | // TypeScript already does local loads and if this code is running the user trusts the workspace 151 | // https://github.com/microsoft/vscode/issues/45856 152 | const ConfiguredProject = tsserver.server.ConfiguredProject; 153 | const {enablePluginsWithOptions: originalEnablePluginsWithOptions} = ConfiguredProject.prototype; 154 | ConfiguredProject.prototype.enablePluginsWithOptions = function() { 155 | this.projectService.allowLocalPluginLoads = true; 156 | return originalEnablePluginsWithOptions.apply(this, arguments); 157 | }; 158 | 159 | // And here is the point where we hijack the VSCode <-> TS communications 160 | // by adding ourselves in the middle. We locate everything that looks 161 | // like an absolute path of ours and normalize it. 162 | 163 | const Session = tsserver.server.Session; 164 | const {onMessage: originalOnMessage, send: originalSend} = Session.prototype; 165 | let hostInfo = `unknown`; 166 | 167 | Object.assign(Session.prototype, { 168 | onMessage(/** @type {string | object} */ message) { 169 | const isStringMessage = typeof message === 'string'; 170 | const parsedMessage = isStringMessage ? JSON.parse(message) : message; 171 | 172 | if ( 173 | parsedMessage != null && 174 | typeof parsedMessage === `object` && 175 | parsedMessage.arguments && 176 | typeof parsedMessage.arguments.hostInfo === `string` 177 | ) { 178 | hostInfo = parsedMessage.arguments.hostInfo; 179 | if (hostInfo === `vscode` && process.env.VSCODE_IPC_HOOK) { 180 | const [, major, minor] = (process.env.VSCODE_IPC_HOOK.match( 181 | // The RegExp from https://semver.org/ but without the caret at the start 182 | /(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/ 183 | ) ?? []).map(Number) 184 | 185 | if (major === 1) { 186 | if (minor < 61) { 187 | hostInfo += ` <1.61`; 188 | } else if (minor < 66) { 189 | hostInfo += ` <1.66`; 190 | } else if (minor < 68) { 191 | hostInfo += ` <1.68`; 192 | } 193 | } 194 | } 195 | } 196 | 197 | const processedMessageJSON = JSON.stringify(parsedMessage, (key, value) => { 198 | return typeof value === 'string' ? fromEditorPath(value) : value; 199 | }); 200 | 201 | return originalOnMessage.call( 202 | this, 203 | isStringMessage ? processedMessageJSON : JSON.parse(processedMessageJSON) 204 | ); 205 | }, 206 | 207 | send(/** @type {any} */ msg) { 208 | return originalSend.call(this, JSON.parse(JSON.stringify(msg, (key, value) => { 209 | return typeof value === `string` ? toEditorPath(value) : value; 210 | }))); 211 | } 212 | }); 213 | 214 | return tsserver; 215 | }; 216 | 217 | if (existsSync(absPnpApiPath)) { 218 | if (!process.versions.pnp) { 219 | // Setup the environment to be able to require typescript/lib/tsserverlibrary.js 220 | require(absPnpApiPath).setup(); 221 | } 222 | } 223 | 224 | // Defer to the real typescript/lib/tsserverlibrary.js your application uses 225 | module.exports = moduleWrapper(absRequire(`typescript/lib/tsserverlibrary.js`)); 226 | -------------------------------------------------------------------------------- /.yarn/sdks/typescript/lib/typescript.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const {existsSync} = require(`fs`); 4 | const {createRequire} = require(`module`); 5 | const {resolve} = require(`path`); 6 | 7 | const relPnpApiPath = "../../../../.pnp.cjs"; 8 | 9 | const absPnpApiPath = resolve(__dirname, relPnpApiPath); 10 | const absRequire = createRequire(absPnpApiPath); 11 | 12 | if (existsSync(absPnpApiPath)) { 13 | if (!process.versions.pnp) { 14 | // Setup the environment to be able to require typescript/lib/typescript.js 15 | require(absPnpApiPath).setup(); 16 | } 17 | } 18 | 19 | // Defer to the real typescript/lib/typescript.js your application uses 20 | module.exports = absRequire(`typescript/lib/typescript.js`); 21 | -------------------------------------------------------------------------------- /.yarn/sdks/typescript/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "typescript", 3 | "version": "5.0.3-sdk", 4 | "main": "./lib/typescript.js", 5 | "type": "commonjs" 6 | } 7 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | yarnPath: .yarn/releases/yarn-3.5.0.cjs 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## Unreleased 2 | 3 | ## 0.2.0 4 | 5 | - Added 6 | - Implemented soft errors. 7 | - From this version onward, it will try to generate special variables like `TODO_this` to indicate errors and continue transformation. 8 | - It allows you to automate more transformation while using manual rewriting when necessary. 9 | - Not all errors are soft errors, but we will continue to make as many errors soft errors as possible. 10 | 11 | ## 0.1.10 12 | 13 | - Misc 14 | - Internal housekeeping, including introduction of GitHub Actions, Prettier, and ESLint. 15 | 16 | ## 0.1.9 17 | 18 | - Added 19 | - Support for method-binding patterns e.g. `this.foo = this.foo.bind(this);` 20 | - Support for multiple states in one `setState()` call 21 | 22 | ## 0.1.8 23 | 24 | - Added 25 | - Implement MVP for componentDidMount/componentDidUpdate/componentWillUnmount 26 | - Fixed 27 | - Don't fail if user-defined class field (e.g. `this.foo`) is assigned without initializing. 28 | 29 | ## 0.1.7 30 | 31 | - Added 32 | - Add support for `useCallback` 33 | - Fixed 34 | - Use function declaration instead of function expression when possible 35 | 36 | ## 0.1.6 37 | 38 | - Added 39 | - Add support for more type annotations on methods 40 | - Add support for modifying types reflecting `defaultProps` 41 | - Add support for `React.PureComponent` 42 | - Add support for generics 43 | 44 | ## 0.1.5 45 | 46 | - Added 47 | - Add support for refs (types are supported as well) 48 | - Add support for state types 49 | - Add support for opt-out in one of: 50 | - `@abstract` JSDoc comment 51 | - `abstract` modifier 52 | - `react-declassify-disable` comment 53 | - Fixed 54 | - Keep generator/async flags 55 | - Fix renaming failure in some cases 56 | - Fix local variable conflict when the name was introduced in an inner block. 57 | - Fix `this.props`, `this.setState`, and so on not being accounted for when they are declared in the constructor. 58 | 59 | ## 0.1.4 60 | 61 | - Added 62 | - Add support for `const { ... } = this.state` 63 | - Rename methods if necessary 64 | - Misc 65 | - Refactoring 66 | 67 | ## 0.1.3 68 | 69 | - Added 70 | - Add support for `this.state` initialization in constructor 71 | - Add support for `defaultProps` 72 | - Misc 73 | - Heavily refactored internal analysis 74 | 75 | ## 0.1.2 76 | 77 | - Added 78 | - Add support for `export default class` declarations 79 | - Add support for class fields initialized as functions 80 | - Fixed 81 | - Fix emission of hoisted props 82 | 83 | ## 0.1.1 84 | 85 | - Added 86 | - Transform `P` type argument 87 | - Transform `setState` (simple case) 88 | 89 | ## 0.1.0 90 | 91 | Initial experimental release. 92 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-declassify: say goodbye to class components 2 | 3 | 4 | 5 | This codemod automatically transforms **React class components** into **React functional components using Hooks** for you! 6 | 7 | | Before | After | 8 | | ---------------------------------------------- | -------------------------------------------- | 9 | | ![before example 1](./img/example1-before.png) | ![after example 1](./img/example1-after.png) | 10 | 11 | ## Features 12 | 13 | - ✅ Supports props, states, methods, and refs. 14 | - ✅ Comments, spaces, and styles are preserved thanks to the [recast](https://github.com/benjamn/recast) library. 15 | - ✅ Designed to generate as idiomatic code as possible. Not something Babel or Webpack would generate! 16 | - ✅ Based on classical heuristic automation; no need to be fearful about whimsy LLMs. 17 | 18 | ## Why do we need this? 19 | 20 | Class components are [still going to be supported by React for the foreseeable future](https://react.dev/reference/react/Component). However, it is no longer recommended to write new components in class-style. 21 | 22 | So what about the existing components? Although React will continue to support these, you may struggle to maintain them because: 23 | 24 | - New libraries and new versions of existing libraries tend to focus on Hooks-style components, and you may find you in a difficulty adopting the components to the libraries. 25 | - Class components may appear alien to those who are young in React development experience. 26 | 27 | Thus it is still a good idea to migrate from class components to Hooks-based components. 28 | 29 | However, as this is not a simple syntactic change, migration needs a careful hand work and a careful review. This tool is a classic automation, it reduces a risk of introducing human errors during migration. 30 | 31 | ## Usage 32 | 33 | ``` 34 | yarn add -D @codemod/cli react-declassify 35 | # OR 36 | npm install -D @codemod/cli react-declassify 37 | ``` 38 | 39 | then 40 | 41 | ``` 42 | npx codemod --plugin react-declassify 'src/**/*.tsx' 43 | ``` 44 | 45 | ## Example 46 | 47 | Before: 48 | 49 | 50 | ```tsx 51 | import React from "react"; 52 | 53 | type Props = { 54 | by: number; 55 | }; 56 | 57 | type State = { 58 | counter: number; 59 | }; 60 | 61 | export class C extends React.Component { 62 | static defaultProps = { 63 | by: 1 64 | }; 65 | 66 | constructor(props) { 67 | super(props); 68 | this.state = { 69 | counter: 0 70 | }; 71 | } 72 | 73 | render() { 74 | return ( 75 | <> 76 | 79 |

Current step: {this.props.by}

80 | 81 | ); 82 | } 83 | 84 | onClick() { 85 | this.setState({ counter: this.state.counter + this.props.by }); 86 | } 87 | } 88 | ``` 89 | 90 | After: 91 | 92 | 93 | ```tsx 94 | import React from "react"; 95 | 96 | type Props = { 97 | by?: number | undefined 98 | }; 99 | 100 | type State = { 101 | counter: number; 102 | }; 103 | 104 | export const C: React.FC = props => { 105 | const { 106 | by = 1 107 | } = props; 108 | 109 | const [counter, setCounter] = React.useState(0); 110 | 111 | function onClick() { 112 | setCounter(counter + by); 113 | } 114 | 115 | return <> 116 | 119 |

Current step: {by}

120 | ; 121 | }; 122 | ``` 123 | 124 | Before: 125 | 126 | 127 | ```jsx 128 | import React from "react"; 129 | 130 | export class C extends React.Component { 131 | render() { 132 | const { text, color } = this.props; 133 | return ; 134 | } 135 | 136 | onClick() { 137 | const { text, handleClick } = this.props; 138 | alert(`${text} was clicked!`); 139 | handleClick(); 140 | } 141 | } 142 | ``` 143 | 144 | After: 145 | 146 | 147 | ```jsx 148 | import React from "react"; 149 | 150 | export const C = props => { 151 | const { 152 | text, 153 | color, 154 | handleClick 155 | } = props; 156 | 157 | function onClick() { 158 | alert(`${text} was clicked!`); 159 | handleClick(); 160 | } 161 | 162 | return ; 163 | }; 164 | ``` 165 | 166 | ## Errors 167 | 168 | Hard errors are indicated by `/* react-declassify-disable Cannot perform transformation */`. 169 | 170 | Soft errors are indicated by special variable names including: 171 | 172 | - `TODO_this` 173 | 174 | Hard errors stop transformation of the whole class while stop errors do not. You need to fix the errors to conclude transformation. 175 | 176 | ## Configuration 177 | 178 | ### Disabling transformation 179 | 180 | Adding to the class a comment including `react-declassify-disable` will disable transformation of that class. 181 | 182 | ```js 183 | /* react-declassify-disable */ 184 | class MyComponent extends React.Component {} 185 | ``` 186 | 187 | Marking the component class as `abstract` or `/** @abstract */` also disables transformation. 188 | 189 | ### Import style 190 | 191 | The codemod follows your import style from the `extends` clause. So 192 | 193 | ```js 194 | import React from "react"; 195 | 196 | class MyComponent extends React.Component {} 197 | ``` 198 | 199 | is transformed to 200 | 201 | ```js 202 | import React from "react"; 203 | 204 | const MyComponent: React.FC = () => {}; 205 | ``` 206 | 207 | whereas 208 | 209 | ```js 210 | import { Component } from "react"; 211 | 212 | class MyComponent extends Component {} 213 | ``` 214 | 215 | is transformed to 216 | 217 | ```js 218 | import { Component, FC } from "react"; 219 | 220 | const MyComponent: FC = () => {}; 221 | ``` 222 | 223 | It cannot be configured to mix these styles. For example it cannot emit `React.FC` for typing while emitting `useState` (not `React.useState`) for hooks. 224 | 225 | ### Receiving refs 226 | 227 | Class components may receive refs; this is to be supported in the future. Once it is implemented, you will be able to add special directives in the component to enable the feature. 228 | 229 | ### Syntactic styles 230 | 231 | This codemod relies on [recast](https://github.com/benjamn/recast) for pretty-printing and sometimes generates code that does not match your preferred style. This is ineviable. For example it does not currently emit parentheses for the arrow function: 232 | 233 | 234 | ```js 235 | const MyComponent: FC = props => { 236 | // ^^^^^ no parentheses 237 | // ... 238 | }; 239 | ``` 240 | 241 | We have no control over this choice. Even if it were possible, allowing configurations on styles would make the codemod unnecessarily complex. 242 | 243 | If you need to enforce specific styles, use Prettier or ESLint or whatever is your favorite to reformat the code after you apply the transformation. 244 | 245 | ## Progress 246 | 247 | - [x] Convert render function (basic feature) 248 | - [x] Superclass detection 249 | - [x] Support `React.Component` 250 | - [x] Support `React.PureComponent` 251 | - [ ] Class node type 252 | - [x] Support class declarations 253 | - [x] Support `export default class` declarations 254 | - [ ] Support class expressions 255 | - [ ] TypeScript support 256 | - [x] Add `React.FC` annotation 257 | - [x] Transform `P` type argument 258 | - [x] Transform `S` type argument 259 | - [x] Transform ref types 260 | - [x] Transform generic components 261 | - [x] Modify Props appropriately if defaultProps is present 262 | - [ ] Modify Props appropriately if `children` seems to be used 263 | - [ ] Support for `this.props` 264 | - [x] Convert `this.props` to `props` parameter 265 | - [ ] Rename `props` if necessary 266 | - [x] Hoist expansion of `this.props` 267 | - [x] Rename prop variables if necessary 268 | - [x] transform `defaultProps` 269 | - [ ] Support for user-defined methods 270 | - [x] Transform methods to `function`s 271 | - [x] Transform class fields initialized as functions to `function`s 272 | - [x] Use `useCallback` if deemed necessary 273 | - [x] Auto-expand direct callback call (like `this.props.onClick()`) to indirect call 274 | - [x] Rename methods if necessary 275 | - [x] Skip method-binding expressions (e.g. `onClick={this.onClick.bind(this)}`) 276 | - [x] Skip method-binding statements (e.g. `this.onClick = this.onClick.bind(this)`) 277 | - [ ] Support for `this.state` 278 | - [x] Decompose `this.state` into `useState` variables 279 | - [x] Rename states if necessary 280 | - [x] Support updating multiple states at once 281 | - [ ] Support functional updates 282 | - [ ] Support lazy initialization 283 | - [ ] Support for refs 284 | - [x] Transform `createRef` to `useRef` 285 | - [x] Transform member assignment to `useRef` 286 | - [ ] Transform legacy string refs as far as possible 287 | - [ ] Support for lifecycles 288 | - [ ] Transform componentDidMount, componentDidUpdate, and componentWillUnmount 289 | - [x] Support "raw" effects -- simply mapping the three callbacks to guarded effects. 290 | - [ ] Support re-pairing effects 291 | - [ ] Transform shouldComponentUpdate 292 | - [ ] Support for receiving refs 293 | - [ ] Use `forwardRef` + `useImperativeHandle` when requested by the user 294 | - [ ] Support for contexts 295 | - [ ] Transform `contextType` to `useContext` 296 | - [ ] Transform the second parameter for the legacy `contextTypes` 297 | - [ ] Transform `static propTypes` to assignments 298 | - [x] Rename local variables in `render` if necessary 299 | 300 | ## Known limitations 301 | 302 | ### Class refs 303 | 304 | #### Symptom 305 | 306 | You get the following type error: 307 | 308 | ``` 309 | test.tsx:1:1 - error TS2322: Type '{ ... }' is not assignable to type 'IntrinsicAttributes & Props'. 310 | Property 'ref' does not exist on type 'IntrinsicAttributes & Props'. 311 | 312 | 1 ref={ref} 313 | ~~~ 314 | ``` 315 | 316 | or you receive the following warning in the console: 317 | 318 | ``` 319 | Warning: Function components cannot be given refs. Attempts to access this ref will fail. Did you mean to use React.forwardRef()? 320 | 321 | Check the render method of `C`. 322 | at App 323 | ``` 324 | 325 | or you receive some sort of null error (e.g. `Cannot read properties of undefined (reading 'a')`) because `ref.current` is always undefined. 326 | 327 | Type errors can also occur at `useRef` in a component that uses the component under transformation: 328 | 329 | ``` 330 | test.tsx:1:1 - error TS2749: 'C' refers to a value, but is being used as a type here. Did you mean 'typeof C'? 331 | 332 | 41 const component = React.useRef(null); 333 | ~ 334 | ``` 335 | 336 | #### Cause 337 | 338 | Class components receives refs, and the ref points to the instance of the class. Functional components do not receive refs by default. 339 | 340 | #### Solution 341 | 342 | This is not implemented now. However, once it is implemented you can opt in ref support by certain directives. It will generate `forwardRef` + `useImperativeHandle` to expose necessary APIs. 343 | 344 | ### Stricter render types 345 | 346 | ### Symptom 347 | 348 | You get the following type error: 349 | 350 | ``` 351 | test.tsx:1:1 - error TS2322: Type '(props: Props) => ReactNode' is not assignable to type 'FC'. 352 | Type 'ReactNode' is not assignable to type 'ReactElement | null'. 353 | 354 | 1 const C: React.FC = (props) => { 355 | ~ 356 | ``` 357 | 358 | ### Cause 359 | 360 | In DefinitelyTyped, `React.FC` is typed slightly stricter than the `render` method. You are expected a single element or `null`. 361 | 362 | We leave this untransformed because it is known not to cause problems at runtime. 363 | 364 | ### Solution 365 | 366 | An extra layer of a frament `<> ... ` suffices to fix the type error. 367 | 368 | ## Assumptions 369 | 370 | - It assumes that the component only needs to reference the latest values of `this.props` or `this.state`. This assumption is necessary because there is a difference between class components and funtion components in how the callbacks capture props or states. To transform the code in an idiomatic way, this assumption is necessary. 371 | - It assumes, by default, the component is always instantiated without refs. 372 | - It assumes that the methods always receive the same `this` value as the one when the method is referenced. 373 | - It assumes that the component does not update the state conditionally by supplying `undefined` to `this.setState`. We need to replace various functionalities associated with `this` with alternative tools and the transformation relies on the fact that the value of `this` is stable all across the class lifecycle. 374 | -------------------------------------------------------------------------------- /babel-cjs.config.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /** @type {import("@babel/core").TransformOptions} */ 4 | const config = { 5 | extends: "./babel.config.js", 6 | presets: [["@babel/env", { modules: "commonjs" }]], 7 | }; 8 | export default config; 9 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /** @type {import("@babel/core").TransformOptions} */ 4 | const config = { 5 | targets: "node 18", 6 | presets: [ 7 | ["@babel/env", { modules: false }], 8 | ["@babel/typescript", { allowDeclareFields: true }], 9 | ], 10 | }; 11 | export default config; 12 | -------------------------------------------------------------------------------- /cjs/package.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /img/example1-after.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wantedly/react-declassify/023237f6ce4628bff2cd89c7d728e992e55c7c1c/img/example1-after.png -------------------------------------------------------------------------------- /img/example1-before.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wantedly/react-declassify/023237f6ce4628bff2cd89c7d728e992e55c7c1c/img/example1-before.png -------------------------------------------------------------------------------- /img/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wantedly/react-declassify/023237f6ce4628bff2cd89c7d728e992e55c7c1c/img/logo.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-declassify", 3 | "version": "0.2.0", 4 | "description": "say goodbye to class components [EXPERIMENTAL]", 5 | "keywords": [ 6 | "babel-plugin", 7 | "babel-codemod", 8 | "react" 9 | ], 10 | "homepage": "https://github.com/wantedly/react-declassify", 11 | "repository": { 12 | "type": "git", 13 | "url": "https://github.com/wantedly/react-declassify.git" 14 | }, 15 | "bugs": { 16 | "url": "https://github.com/wantedly/react-declassify/issues" 17 | }, 18 | "license": "MIT", 19 | "author": "Masaki Hara ", 20 | "packageManager": "yarn@3.5.0", 21 | "type": "module", 22 | "main": "./cjs/dist/index.js", 23 | "exports": { 24 | "import": "./dist/index.js", 25 | "require": "./cjs/dist/index.js" 26 | }, 27 | "files": [ 28 | "cjs/dist/**/*", 29 | "cjs/package.json", 30 | "dist/**/*", 31 | "src/**/*", 32 | "!**/*.test.ts" 33 | ], 34 | "scripts": { 35 | "build": "$npm_execpath build:esm && $npm_execpath build:cjs", 36 | "build:cjs": "babel -x .ts -d cjs/dist src --ignore '**/*.test.ts' --config-file ./babel-cjs.config.js", 37 | "build:esm": "babel -x .ts -d dist src --ignore '**/*.test.ts'", 38 | "fmt": "prettier -w .", 39 | "fmt:check": "prettier -c .", 40 | "lint": "eslint .", 41 | "prepack": "$npm_execpath build", 42 | "test": "NODE_OPTIONS='--experimental-vm-modules' yarn jest" 43 | }, 44 | "devDependencies": { 45 | "@babel/cli": "^7.21.0", 46 | "@babel/core": "^7.21.3", 47 | "@babel/eslint-plugin-development": "^7.19.1", 48 | "@babel/preset-env": "^7.20.2", 49 | "@babel/preset-typescript": "^7.21.0", 50 | "@babel/types": "^7.21.3", 51 | "@codemod/core": "^2.2.0", 52 | "@jest/globals": "^29.5.0", 53 | "@qnighy/dedent": "^0.1.1", 54 | "@types/babel__core": "^7.20.0", 55 | "@types/babel__traverse": "^7.18.3", 56 | "@types/node": "^18.15.11", 57 | "@typescript-eslint/eslint-plugin": "^5.57.0", 58 | "@typescript-eslint/parser": "^5.57.0", 59 | "@yarnpkg/sdks": "^3.0.0-rc.42", 60 | "babel-jest": "^29.5.0", 61 | "eslint": "^8.37.0", 62 | "eslint-config-prettier": "^8.8.0", 63 | "eslint-plugin-jest": "^27.2.1", 64 | "eslint-plugin-node": "^11.1.0", 65 | "jest": "^29.5.0", 66 | "prettier": "^2.8.7", 67 | "ts-jest-resolver": "^2.0.1", 68 | "typescript": "^5.0.3" 69 | }, 70 | "jest": { 71 | "extensionsToTreatAsEsm": [ 72 | ".ts", 73 | ".mts", 74 | ".tsx", 75 | ".jsx" 76 | ], 77 | "resolver": "ts-jest-resolver" 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": ["config:base"], 4 | "packageRules": [ 5 | { 6 | "matchManagers": ["npm"], 7 | "rangeStrategy": "bump" 8 | }, 9 | { 10 | "matchManagers": ["npm"], 11 | "matchDepTypes": ["dependencies", "peerDependencies"], 12 | "rangeStrategy": "widen" 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /src/analysis.ts: -------------------------------------------------------------------------------- 1 | import type { NodePath } from "@babel/core"; 2 | import type { Scope } from "@babel/traverse"; 3 | import type { 4 | ClassDeclaration, 5 | ClassMethod, 6 | Identifier, 7 | JSXIdentifier, 8 | TSType, 9 | TSTypeParameterDeclaration, 10 | } from "@babel/types"; 11 | import { AnalysisError, SoftErrorRepository } from "./analysis/error.js"; 12 | import { BindThisSite, analyzeClassFields } from "./analysis/class_fields.js"; 13 | import { analyzeState, StateObjAnalysis } from "./analysis/state.js"; 14 | import { getAndDelete } from "./utils.js"; 15 | import { analyzeProps, needAlias, PropsObjAnalysis } from "./analysis/prop.js"; 16 | import { LocalManager, RemovableNode } from "./analysis/local.js"; 17 | import { 18 | analyzeUserDefined, 19 | postAnalyzeCallbackDependencies, 20 | UserDefinedAnalysis, 21 | } from "./analysis/user_defined.js"; 22 | import type { PreAnalysisResult } from "./analysis/pre.js"; 23 | import type { LibRef } from "./analysis/lib.js"; 24 | import { EffectAnalysis, analyzeEffects } from "./analysis/effect.js"; 25 | 26 | export { AnalysisError, SoftErrorRepository } from "./analysis/error.js"; 27 | 28 | export type { LibRef } from "./analysis/lib.js"; 29 | export type { PreAnalysisResult } from "./analysis/pre.js"; 30 | export { preanalyzeClass } from "./analysis/pre.js"; 31 | export type { LocalManager } from "./analysis/local.js"; 32 | export type { 33 | StateObjAnalysis, 34 | SetStateSite, 35 | SetStateFieldSite, 36 | } from "./analysis/state.js"; 37 | export { needAlias } from "./analysis/prop.js"; 38 | export type { PropsObjAnalysis } from "./analysis/prop.js"; 39 | 40 | const SPECIAL_STATIC_NAMES = new Set([ 41 | "childContextTypes", 42 | "contextTypes", 43 | "contextType", 44 | "defaultProps", 45 | "getDerivedStateFromError", 46 | "getDerivedStateFromProps", 47 | ]); 48 | 49 | export type AnalysisResult = { 50 | name?: Identifier | undefined; 51 | typeParameters?: NodePath | undefined; 52 | superClassRef: LibRef; 53 | isPure: boolean; 54 | propsTyping: NodePath | undefined; 55 | locals: LocalManager; 56 | render: RenderAnalysis; 57 | state: StateObjAnalysis; 58 | props: PropsObjAnalysis; 59 | userDefined: UserDefinedAnalysis; 60 | effects: EffectAnalysis; 61 | bindThisSites: BindThisSite[]; 62 | }; 63 | 64 | export function analyzeClass( 65 | path: NodePath, 66 | preanalysis: PreAnalysisResult, 67 | softErrors: SoftErrorRepository 68 | ): AnalysisResult { 69 | const locals = new LocalManager(path); 70 | const { 71 | instanceFields: sites, 72 | staticFields, 73 | bindThisSites, 74 | } = analyzeClassFields(path, softErrors); 75 | 76 | const propsObjAnalysis = getAndDelete(sites, "props") ?? { sites: [] }; 77 | const defaultPropsObjAnalysis = getAndDelete( 78 | staticFields, 79 | "defaultProps" 80 | ) ?? { sites: [] }; 81 | 82 | const stateObjAnalysis = getAndDelete(sites, "state") ?? { sites: [] }; 83 | const setStateAnalysis = getAndDelete(sites, "setState") ?? { sites: [] }; 84 | const states = analyzeState( 85 | stateObjAnalysis, 86 | setStateAnalysis, 87 | locals, 88 | softErrors, 89 | preanalysis 90 | ); 91 | 92 | const componentDidMount = getAndDelete(sites, "componentDidMount") ?? { 93 | sites: [], 94 | }; 95 | const componentDidUpdate = getAndDelete(sites, "componentDidUpdate") ?? { 96 | sites: [], 97 | }; 98 | const componentWillUnmount = getAndDelete(sites, "componentWillUnmount") ?? { 99 | sites: [], 100 | }; 101 | 102 | const renderAnalysis = getAndDelete(sites, "render") ?? { sites: [] }; 103 | 104 | analyzeOuterCapturings(path, locals); 105 | let renderPath: NodePath | undefined = undefined; 106 | { 107 | for (const site of renderAnalysis.sites) { 108 | if (site.type === "expr") { 109 | softErrors.addThisError(site.thisPath); 110 | } 111 | } 112 | const init = renderAnalysis.sites.find((site) => site.init); 113 | if (init) { 114 | if (init.path.isClassMethod()) { 115 | renderPath = init.path; 116 | } 117 | } 118 | } 119 | const userDefined = analyzeUserDefined(sites, softErrors); 120 | for (const [name] of staticFields) { 121 | if (!SPECIAL_STATIC_NAMES.has(name)) { 122 | throw new AnalysisError(`Cannot transform static ${name}`); 123 | } else { 124 | throw new AnalysisError(`Cannot transform static ${name}`); 125 | } 126 | } 127 | if (!renderPath) { 128 | throw new AnalysisError(`Missing render method`); 129 | } 130 | const props = analyzeProps( 131 | propsObjAnalysis, 132 | defaultPropsObjAnalysis, 133 | locals, 134 | softErrors, 135 | preanalysis 136 | ); 137 | postAnalyzeCallbackDependencies(userDefined, props, states, sites); 138 | 139 | for (const [name, propAnalysis] of props.props) { 140 | if (needAlias(propAnalysis)) { 141 | propAnalysis.newAliasName = locals.newLocal( 142 | name, 143 | propAnalysis.sites.map((site) => site.path) 144 | ); 145 | } 146 | } 147 | 148 | const effects = analyzeEffects( 149 | componentDidMount, 150 | componentDidUpdate, 151 | componentWillUnmount, 152 | userDefined 153 | ); 154 | 155 | const render = analyzeRender(renderPath, locals); 156 | 157 | for (const [name, stateAnalysis] of states.states.entries()) { 158 | const bindingPaths = stateAnalysis.sites.map((site) => site.path); 159 | stateAnalysis.localName = locals.newLocal(name, bindingPaths); 160 | stateAnalysis.localSetterName = locals.newLocal( 161 | `set${name.replace(/^[a-z]/, (s) => s.toUpperCase())}`, 162 | bindingPaths 163 | ); 164 | } 165 | 166 | for (const [name, field] of userDefined.fields) { 167 | field.localName = locals.newLocal( 168 | name, 169 | field.sites.map((site) => site.path) 170 | ); 171 | } 172 | 173 | if (effects.cdmPath || effects.cduPath || effects.cwuPath) { 174 | effects.isMountedLocalName = locals.newLocal("isMounted", []); 175 | if (effects.cwuPath) { 176 | effects.cleanupLocalName = locals.newLocal("cleanup", []); 177 | } 178 | } 179 | 180 | return { 181 | name: preanalysis.name, 182 | typeParameters: preanalysis.typeParameters, 183 | superClassRef: preanalysis.superClassRef, 184 | isPure: preanalysis.isPure, 185 | propsTyping: preanalysis.props, 186 | locals, 187 | render, 188 | state: states, 189 | props, 190 | userDefined, 191 | effects, 192 | bindThisSites, 193 | }; 194 | } 195 | 196 | export type RenderAnalysis = { 197 | path: NodePath; 198 | renames: LocalRename[]; 199 | }; 200 | 201 | export type LocalRename = { 202 | scope: Scope; 203 | oldName: string; 204 | newName: string; 205 | }; 206 | 207 | function analyzeRender( 208 | path: NodePath, 209 | locals: LocalManager 210 | ): RenderAnalysis { 211 | const renames: LocalRename[] = []; 212 | for (const [name, binding] of Object.entries(path.scope.bindings)) { 213 | if (locals.allRemovePaths.has(binding.path as NodePath)) { 214 | // Already handled as an alias 215 | continue; 216 | } 217 | const newName = locals.newLocal(name, []); 218 | renames.push({ 219 | scope: binding.scope, 220 | oldName: name, 221 | newName, 222 | }); 223 | } 224 | return { path, renames }; 225 | } 226 | 227 | function analyzeOuterCapturings( 228 | classPath: NodePath, 229 | locals: LocalManager 230 | ): Set { 231 | const capturings = new Set(); 232 | function visitIdent(path: NodePath) { 233 | const binding = path.scope.getBinding(path.node.name); 234 | if (!binding || binding.path.isAncestor(classPath)) { 235 | capturings.add(path.node.name); 236 | locals.markCaptured(path.node.name); 237 | } 238 | } 239 | classPath.get("body").traverse({ 240 | Identifier(path) { 241 | if (path.isReferencedIdentifier()) { 242 | visitIdent(path); 243 | } 244 | }, 245 | JSXIdentifier(path) { 246 | if (path.isReferencedIdentifier()) { 247 | visitIdent(path); 248 | } 249 | }, 250 | }); 251 | return capturings; 252 | } 253 | 254 | export function needsProps(analysis: AnalysisResult): boolean { 255 | return analysis.props.sites.length > 0; 256 | } 257 | -------------------------------------------------------------------------------- /src/analysis/class_fields.ts: -------------------------------------------------------------------------------- 1 | // This file contains analysis for class fields (`this.foo` and `C.foo`) where `C` is the class, 2 | // regardless of whether this is a special one (`this.props`) or a user-defined one (`this.foo`). 3 | // 4 | // Both the declarations and the usages are collected. 5 | 6 | import type { NodePath } from "@babel/core"; 7 | import type { 8 | AssignmentExpression, 9 | CallExpression, 10 | ClassAccessorProperty, 11 | ClassDeclaration, 12 | ClassMethod, 13 | ClassPrivateMethod, 14 | ClassPrivateProperty, 15 | ClassProperty, 16 | Expression, 17 | ExpressionStatement, 18 | MemberExpression, 19 | ThisExpression, 20 | TSDeclareMethod, 21 | TSType, 22 | } from "@babel/types"; 23 | import { 24 | getOr, 25 | isClassAccessorProperty, 26 | isClassMethodLike, 27 | isClassMethodOrDecl, 28 | isClassPropertyLike, 29 | isNamedClassElement, 30 | isStaticBlock, 31 | memberName, 32 | memberRefName, 33 | nonNullPath, 34 | } from "../utils.js"; 35 | import { AnalysisError, SoftErrorRepository } from "./error.js"; 36 | 37 | /** 38 | * Aggregated result of class field analysis. 39 | */ 40 | export type ClassFieldsAnalysis = { 41 | /** Access to instance fields (`this.foo`), indexed by their names. */ 42 | instanceFields: Map; 43 | /** Access to static fields (`C.foo`, where `C` is the class), indexed by their names. */ 44 | staticFields: Map; 45 | /** Appearances of `this` as in `this.foo.bind(this)` */ 46 | bindThisSites: BindThisSite[]; 47 | }; 48 | 49 | /** 50 | * Result of class field analysis for each field name. 51 | */ 52 | export type ClassFieldAnalysis = { 53 | sites: ClassFieldSite[]; 54 | }; 55 | 56 | /** 57 | * A place where the class field is declared or used. 58 | */ 59 | export type ClassFieldSite = ClassFieldDeclSite | ClassFieldExprSite; 60 | 61 | export type ClassFieldDeclSite = { 62 | type: "decl"; 63 | /** 64 | * Declaration. One of: 65 | * 66 | * - Class element (methods, fields, etc.) 67 | * - Assignment to `this` in the constructor (instance case) 68 | * - Assignment to `this` in a static initialization block (static case) 69 | */ 70 | path: NodePath< 71 | | ClassProperty 72 | | ClassPrivateProperty 73 | | ClassMethod 74 | | ClassPrivateMethod 75 | | ClassAccessorProperty 76 | | TSDeclareMethod 77 | | AssignmentExpression 78 | >; 79 | /** 80 | * Where is it referenced in? 81 | */ 82 | owner: string | undefined; 83 | /** 84 | * Type annotation, if any. 85 | * 86 | * Param/return annotations attached to function-like implementations are ignored. 87 | */ 88 | typing: FieldTyping | undefined; 89 | /** 90 | * Initializing expression, if any. 91 | */ 92 | init: FieldInit | undefined; 93 | hasWrite: undefined; 94 | /** 95 | * true if the initializer has a side effect. 96 | */ 97 | hasSideEffect: boolean; 98 | }; 99 | 100 | export type ClassFieldExprSite = { 101 | type: "expr"; 102 | /** 103 | * The node that accesses the field (both read and write) 104 | */ 105 | path: NodePath; 106 | thisPath: NodePath; 107 | owner: string | undefined; 108 | typing: undefined; 109 | init: undefined; 110 | /** 111 | * true if it involves writing. This includes: 112 | * 113 | * - Assignment `this.foo = 42` 114 | * - Compound assignment `this.foo += 42` 115 | * - Delete `delete this.foo` 116 | */ 117 | hasWrite: boolean; 118 | hasSideEffect: undefined; 119 | }; 120 | 121 | /** 122 | * Essentially a TSTypeAnnotation, but accounts for TSDeclareMethod as well. 123 | */ 124 | export type FieldTyping = 125 | | { 126 | type: "type_value"; 127 | valueTypePath: NodePath; 128 | } 129 | | { 130 | type: "type_method"; 131 | methodDeclPath: NodePath; 132 | }; 133 | 134 | /** 135 | * Essentially an Expression, but accounts for ClassMethod as well. 136 | */ 137 | export type FieldInit = 138 | | { 139 | type: "init_value"; 140 | valuePath: NodePath; 141 | } 142 | | { 143 | type: "init_method"; 144 | methodPath: NodePath; 145 | }; 146 | 147 | /** 148 | * Appearance of `this` as in `this.foo.bind(this)` 149 | */ 150 | export type BindThisSite = { 151 | /** 152 | * true if the bind call has more arguments e.g. `this.foo.bind(this, 42)` 153 | */ 154 | bindsMore: boolean; 155 | /** `this` as in the argument to `Function.prototype.bind` */ 156 | thisArgPath: NodePath; 157 | /** The whole bind expression */ 158 | binderPath: NodePath; 159 | /** The member expression that `this` is being bound to e.g. `this.foo` */ 160 | bindeePath: NodePath; 161 | /** true if this is part of self-binding: `this.foo = this.foo.bind(this);` */ 162 | isSelfBindingInitialization: boolean; 163 | }; 164 | 165 | /** 166 | * Collect declarations and uses of the following: 167 | * 168 | * - Instance fields ... `this.foo` 169 | * - Static fields ... `C.foo`, where `C` is the class 170 | */ 171 | export function analyzeClassFields( 172 | path: NodePath, 173 | softErrors: SoftErrorRepository 174 | ): ClassFieldsAnalysis { 175 | const instanceFields = new Map(); 176 | const getInstanceField = (name: string) => 177 | getOr(instanceFields, name, () => ({ sites: [] })); 178 | const staticFields = new Map(); 179 | const getStaticField = (name: string) => 180 | getOr(staticFields, name, () => ({ sites: [] })); 181 | let constructor: NodePath | undefined = undefined; 182 | const bodies: { 183 | owner: string | undefined; 184 | path: NodePath; 185 | }[] = []; 186 | // 1st pass: look for class field definitions 187 | for (const itemPath of path.get("body").get("body")) { 188 | if (isNamedClassElement(itemPath)) { 189 | // The element is a class method or a class field (in a general sense) 190 | const isStatic = itemPath.node.static; 191 | const name = memberName(itemPath.node); 192 | if (name == null) { 193 | if (isStatic) { 194 | throw new AnalysisError(`Unnamed class element`); 195 | } else { 196 | softErrors.addDeclError(itemPath); 197 | continue; 198 | } 199 | } 200 | const field = isStatic ? getStaticField(name) : getInstanceField(name); 201 | if (isClassPropertyLike(itemPath)) { 202 | // Class field. 203 | // - May have an initializer: `foo = 42;` or not: `foo;` 204 | // - May have a type annotation: `foo: number;` or not: `foo;` 205 | const valuePath = nonNullPath(itemPath.get("value")); 206 | const typeAnnotation = itemPath.get("typeAnnotation"); 207 | const typeAnnotation_ = typeAnnotation.isTSTypeAnnotation() 208 | ? typeAnnotation 209 | : undefined; 210 | field.sites.push({ 211 | type: "decl", 212 | path: itemPath, 213 | owner: undefined, 214 | typing: typeAnnotation_ 215 | ? { 216 | type: "type_value", 217 | valueTypePath: typeAnnotation_.get("typeAnnotation"), 218 | } 219 | : undefined, 220 | init: valuePath ? { type: "init_value", valuePath } : undefined, 221 | hasWrite: undefined, 222 | hasSideEffect: 223 | !!itemPath.node.value && estimateSideEffect(itemPath.node.value), 224 | }); 225 | if (valuePath) { 226 | // Initializer should be analyzed in step 2 too (considered to be in the constructor) 227 | bodies.push({ 228 | owner: 229 | valuePath.isFunctionExpression() || 230 | valuePath.isArrowFunctionExpression() 231 | ? name 232 | : undefined, 233 | path: valuePath, 234 | }); 235 | } 236 | } else if (isClassMethodOrDecl(itemPath)) { 237 | // Class method, constructor, getter/setter, or an accessor (those that will be introduced in the decorator proposal). 238 | // 239 | // - In TS, it may lack the implementation (i.e. TSDeclareMethod) 240 | const kind = itemPath.node.kind ?? "method"; 241 | if (kind === "method") { 242 | field.sites.push({ 243 | type: "decl", 244 | path: itemPath, 245 | owner: undefined, 246 | // We put `typing` here only when it is type-only 247 | typing: itemPath.isTSDeclareMethod() 248 | ? { 249 | type: "type_method", 250 | methodDeclPath: itemPath, 251 | } 252 | : undefined, 253 | init: isClassMethodLike(itemPath) 254 | ? { type: "init_method", methodPath: itemPath } 255 | : undefined, 256 | hasWrite: undefined, 257 | hasSideEffect: false, 258 | }); 259 | // Analysis for step 2 260 | if (isClassMethodLike(itemPath)) { 261 | for (const paramPath of itemPath.get("params")) { 262 | bodies.push({ 263 | owner: name, 264 | path: paramPath, 265 | }); 266 | } 267 | bodies.push({ 268 | owner: name, 269 | path: itemPath.get("body"), 270 | }); 271 | } 272 | } else if (kind === "get" || kind === "set") { 273 | throw new AnalysisError(`Not implemented yet: getter / setter`); 274 | } else if (kind === "constructor") { 275 | if (isStatic) { 276 | throw new Error("static constructor found"); 277 | } 278 | constructor = itemPath as NodePath; 279 | } else { 280 | throw new AnalysisError(`Not implemented yet: ${kind as string}`); 281 | } 282 | } else if (isClassAccessorProperty(itemPath)) { 283 | throw new AnalysisError(`Not implemented yet: class accessor property`); 284 | } 285 | } else if (isStaticBlock(itemPath)) { 286 | throw new AnalysisError(`Not implemented yet: static block`); 287 | } else if (itemPath.isTSIndexSignature()) { 288 | // Ignore 289 | } else { 290 | throw new AnalysisError(`Unknown class element`); 291 | } 292 | } 293 | 294 | // 1st pass additional work: field initialization in constructor 295 | if (constructor) { 296 | // Only `constructor(props)` is allowed. 297 | // TODO: accept context as well 298 | if (constructor.node.params.length > 1) { 299 | throw new AnalysisError(`Constructor has too many parameters`); 300 | } else if (constructor.node.params.length < 1) { 301 | throw new AnalysisError(`Constructor has too few parameters`); 302 | } 303 | const param = constructor.node.params[0]!; 304 | if (param.type !== "Identifier") { 305 | throw new AnalysisError(`Invalid constructor parameters`); 306 | } 307 | 308 | const stmts = constructor.get("body").get("body"); 309 | 310 | // Check super() call 311 | // Must be super(props) or super(props, context) 312 | const superCallIndex = stmts.findIndex( 313 | (stmt) => 314 | stmt.node.type === "ExpressionStatement" && 315 | stmt.node.expression.type === "CallExpression" && 316 | stmt.node.expression.callee.type === "Super" 317 | ); 318 | if (superCallIndex === -1) { 319 | throw new AnalysisError(`No super call`); 320 | } else if (superCallIndex > 0) { 321 | throw new AnalysisError(`No immediate super call`); 322 | } 323 | const superCall = stmts[superCallIndex]!; 324 | const superCallArgs = ( 325 | (superCall.node as ExpressionStatement).expression as CallExpression 326 | ).arguments; 327 | if (superCallArgs.length > 1) { 328 | throw new AnalysisError(`Too many arguments for super()`); 329 | } else if (superCallArgs.length < 1) { 330 | throw new AnalysisError(`Too few arguments for super()`); 331 | } 332 | const superCallArg = superCallArgs[0]!; 333 | if ( 334 | superCallArg.type !== "Identifier" || 335 | superCallArg.name !== param.name 336 | ) { 337 | throw new AnalysisError(`Invalid argument for super()`); 338 | } 339 | 340 | // Analyze init statements (must be in the form of `this.foo = expr;`) 341 | const initStmts = stmts.slice(superCallIndex + 1); 342 | for (const stmt of initStmts) { 343 | if ( 344 | !( 345 | stmt.node.type === "ExpressionStatement" && 346 | stmt.node.expression.type === "AssignmentExpression" && 347 | stmt.node.expression.operator === "=" && 348 | stmt.node.expression.left.type === "MemberExpression" && 349 | stmt.node.expression.left.object.type === "ThisExpression" 350 | ) 351 | ) { 352 | throw new AnalysisError(`Non-analyzable initialization in constructor`); 353 | } 354 | const exprPath = (stmt as NodePath).get( 355 | "expression" 356 | ) as NodePath; 357 | const name = memberRefName(stmt.node.expression.left); 358 | if (name == null) { 359 | throw new AnalysisError(`Non-analyzable initialization in constructor`); 360 | } 361 | // TODO: check for parameter/local variable reference 362 | 363 | const field = getInstanceField(name)!; 364 | field.sites.push({ 365 | type: "decl", 366 | path: exprPath, 367 | owner: undefined, 368 | typing: undefined, 369 | init: { 370 | type: "init_value", 371 | valuePath: exprPath.get("right"), 372 | }, 373 | hasWrite: undefined, 374 | hasSideEffect: estimateSideEffect(stmt.node.expression.right), 375 | }); 376 | bodies.push({ 377 | owner: name, 378 | path: exprPath.get("right"), 379 | }); 380 | } 381 | } 382 | 383 | // 2nd pass: look for uses within items 384 | const bindThisSites: BindThisSite[] = []; 385 | function traverseItem(owner: string | undefined, path: NodePath) { 386 | traverseThis(path, (thisPath) => { 387 | // Ensure this is part of `this.foo` 388 | const thisMemberPath = thisPath.parentPath; 389 | if ( 390 | !thisMemberPath.isMemberExpression({ 391 | object: thisPath.node, 392 | }) 393 | ) { 394 | // Check for bind arguments: `this.foo.bind(this)` 395 | if ( 396 | thisMemberPath.isCallExpression() && 397 | thisMemberPath.node.arguments[0] === thisPath.node && 398 | thisMemberPath.node.callee.type === "MemberExpression" && 399 | memberRefName(thisMemberPath.node.callee) === "bind" && 400 | thisMemberPath.node.callee.object.type === "MemberExpression" && 401 | thisMemberPath.node.callee.object.object.type === "ThisExpression" 402 | ) { 403 | bindThisSites.push({ 404 | bindsMore: thisMemberPath.node.arguments.length > 1, 405 | thisArgPath: thisPath, 406 | binderPath: thisMemberPath, 407 | bindeePath: ( 408 | thisMemberPath.get("callee") as NodePath 409 | ).get("object") as NodePath, 410 | // Checked later 411 | isSelfBindingInitialization: false, 412 | }); 413 | return; 414 | } 415 | softErrors.addThisError(thisPath); 416 | return; 417 | } 418 | 419 | const name = memberRefName(thisMemberPath.node); 420 | if (name == null) { 421 | softErrors.addThisError(thisPath); 422 | return; 423 | } 424 | 425 | const field = getInstanceField(name)!; 426 | 427 | const thisMemberParentPath = thisMemberPath.parentPath; 428 | const hasWrite = 429 | // `this.foo = 0;` (incl. operator assignment) 430 | thisMemberParentPath.isAssignmentExpression({ 431 | left: thisMemberPath.node, 432 | }) || 433 | // `delete this.foo;` 434 | thisMemberParentPath.isUnaryExpression({ 435 | operator: "delete", 436 | argument: thisMemberPath.node, 437 | }); 438 | 439 | field.sites.push({ 440 | type: "expr", 441 | owner, 442 | path: thisMemberPath, 443 | thisPath, 444 | typing: undefined, 445 | init: undefined, 446 | hasWrite, 447 | hasSideEffect: undefined, 448 | }); 449 | }); 450 | } 451 | for (const body of bodies) { 452 | traverseItem(body.owner, body.path); 453 | } 454 | 455 | // Special handling for self-binding initialization (`this.foo = this.foo.bind(this)`) 456 | for (const [name, field] of instanceFields) { 457 | field.sites = field.sites.filter((site) => { 458 | if (site.type === "decl" && site.init?.type === "init_value") { 459 | const valuePath = site.init.valuePath; 460 | const bindThisSite = bindThisSites.find( 461 | (binder) => binder.binderPath === valuePath 462 | ); 463 | if ( 464 | bindThisSite && 465 | !bindThisSite.bindsMore && 466 | memberRefName(bindThisSite.bindeePath.node) === name 467 | ) { 468 | bindThisSite.isSelfBindingInitialization = true; 469 | // Skip the self-binding initialization (lhs) 470 | return false; 471 | } 472 | } 473 | return true; 474 | }); 475 | } 476 | for (const [, field] of instanceFields) { 477 | field.sites = field.sites.filter((site) => { 478 | if (site.type === "expr") { 479 | const bindThisSite = bindThisSites.find( 480 | (binder) => binder.bindeePath === site.path 481 | ); 482 | if (bindThisSite?.isSelfBindingInitialization) { 483 | // Skip the self-binding initialization (rhs) 484 | return false; 485 | } 486 | } 487 | return true; 488 | }); 489 | } 490 | 491 | // Post validation 492 | for (const [name, field] of instanceFields) { 493 | if (field.sites.length === 0) { 494 | instanceFields.delete(name); 495 | } 496 | const numInits = field.sites.reduce( 497 | (n, site) => n + Number(!!site.init), 498 | 0 499 | ); 500 | if (numInits > 1) { 501 | throw new AnalysisError(`${name} is initialized more than once`); 502 | } 503 | const numTypes = field.sites.reduce( 504 | (n, site) => n + Number(!!site.typing), 505 | 0 506 | ); 507 | if (numTypes > 1) { 508 | throw new AnalysisError(`${name} is declared more than once`); 509 | } 510 | } 511 | for (const [name, field] of staticFields) { 512 | if (field.sites.length === 0) { 513 | instanceFields.delete(name); 514 | } 515 | const numInits = field.sites.reduce( 516 | (n, site) => n + Number(!!site.init), 517 | 0 518 | ); 519 | if (numInits > 1) { 520 | throw new AnalysisError(`static ${name} is initialized more than once`); 521 | } 522 | const numTypes = field.sites.reduce( 523 | (n, site) => n + Number(!!site.typing), 524 | 0 525 | ); 526 | if (numTypes > 1) { 527 | throw new AnalysisError(`static ${name} is declared more than once`); 528 | } 529 | } 530 | 531 | return { instanceFields, staticFields, bindThisSites }; 532 | } 533 | 534 | export function addClassFieldError( 535 | site: ClassFieldSite, 536 | softErrors: SoftErrorRepository 537 | ) { 538 | if (site.type === "decl") { 539 | if (isNamedClassElement(site.path)) { 540 | softErrors.addDeclError(site.path); 541 | } else if (site.path.isAssignmentExpression()) { 542 | const left = site.path.get("left") as NodePath; 543 | const object = left.get("object") as NodePath; 544 | softErrors.addThisError(object); 545 | } else { 546 | throw new Error(`Unreachable: invalid type: ${site.path.node.type}`); 547 | } 548 | } else { 549 | softErrors.addThisError(site.thisPath); 550 | } 551 | } 552 | 553 | function traverseThis( 554 | path: NodePath, 555 | visit: (path: NodePath) => void 556 | ) { 557 | path.traverse({ 558 | ThisExpression: visit, 559 | FunctionDeclaration(path) { 560 | path.skip(); 561 | }, 562 | FunctionExpression(path) { 563 | path.skip(); 564 | }, 565 | ClassDeclaration(path) { 566 | path.skip(); 567 | }, 568 | ClassExpression(path) { 569 | path.skip(); 570 | }, 571 | ObjectMethod(path) { 572 | path.skip(); 573 | }, 574 | }); 575 | } 576 | 577 | function estimateSideEffect(expr: Expression): boolean { 578 | switch (expr.type) { 579 | case "NullLiteral": 580 | case "BooleanLiteral": 581 | case "NumericLiteral": 582 | case "BigIntLiteral": 583 | case "Identifier": 584 | case "FunctionExpression": 585 | case "ArrowFunctionExpression": 586 | return false; 587 | 588 | case "MemberExpression": 589 | // Assume `foo.bar` to be pure 590 | return ( 591 | estimateSideEffect(expr.object) || 592 | (expr.property.type !== "PrivateName" && 593 | estimateSideEffect(expr.property)) 594 | ); 595 | 596 | case "UnaryExpression": 597 | switch (expr.operator) { 598 | case "void": 599 | case "!": 600 | case "+": 601 | case "-": 602 | case "~": 603 | case "typeof": 604 | return estimateSideEffect(expr.argument); 605 | } 606 | break; 607 | case "BinaryExpression": 608 | if (expr.left.type === "PrivateName") { 609 | return estimateSideEffect(expr.right); 610 | } else { 611 | return estimateSideEffect(expr.left) || estimateSideEffect(expr.right); 612 | } 613 | case "SequenceExpression": 614 | return expr.expressions.some((elem) => estimateSideEffect(elem)); 615 | case "ArrayExpression": 616 | return expr.elements.some((elem) => 617 | elem == null 618 | ? false 619 | : elem.type === "SpreadElement" 620 | ? estimateSideEffect(elem.argument) 621 | : estimateSideEffect(elem) 622 | ); 623 | case "ObjectExpression": 624 | return expr.properties.some((elem) => 625 | elem.type === "SpreadElement" 626 | ? estimateSideEffect(elem.argument) 627 | : elem.type === "ObjectMethod" 628 | ? estimateSideEffect(elem.key) 629 | : elem.key.type === "PrivateName" 630 | ? estimateSideEffect(elem.value as Expression) 631 | : estimateSideEffect(elem.key) && 632 | estimateSideEffect(elem.value as Expression) 633 | ); 634 | } 635 | return true; 636 | } 637 | -------------------------------------------------------------------------------- /src/analysis/effect.ts: -------------------------------------------------------------------------------- 1 | import type { ClassMethod } from "@babel/types"; 2 | import type { NodePath } from "@babel/traverse"; 3 | import type { ClassFieldAnalysis } from "./class_fields.js"; 4 | import { AnalysisError } from "./error.js"; 5 | import type { UserDefinedAnalysis } from "./user_defined.js"; 6 | 7 | export type EffectAnalysis = { 8 | cdmPath: NodePath | undefined; 9 | cduPath: NodePath | undefined; 10 | cwuPath: NodePath | undefined; 11 | isMountedLocalName?: string | undefined; 12 | cleanupLocalName?: string | undefined; 13 | }; 14 | export function analyzeEffects( 15 | componentDidMount: ClassFieldAnalysis, 16 | componentDidUpdate: ClassFieldAnalysis, 17 | componentWillUnmount: ClassFieldAnalysis, 18 | userDefined: UserDefinedAnalysis 19 | ): EffectAnalysis { 20 | const cdmInit = componentDidMount.sites.find((site) => site.init); 21 | const cduInit = componentDidUpdate.sites.find((site) => site.init); 22 | const cwuInit = componentWillUnmount.sites.find((site) => site.init); 23 | if (componentDidMount.sites.some((site) => !site.init)) { 24 | throw new AnalysisError("Do not use componentDidMount by yourself"); 25 | } 26 | if (componentDidUpdate.sites.some((site) => !site.init)) { 27 | throw new AnalysisError("Do not use componentDidUpdate by yourself"); 28 | } 29 | if (componentWillUnmount.sites.some((site) => !site.init)) { 30 | throw new AnalysisError("Do not use componentWillUnmount by yourself"); 31 | } 32 | let cdmPath: NodePath | undefined = undefined; 33 | let cduPath: NodePath | undefined = undefined; 34 | let cwuPath: NodePath | undefined = undefined; 35 | if (cdmInit) { 36 | if (!cdmInit.path.isClassMethod()) { 37 | throw new AnalysisError("Not a class method: componentDidMount"); 38 | } 39 | if (cdmInit.path.node.params.length > 0) { 40 | throw new AnalysisError("Invalid parameter of componentDidMount"); 41 | } 42 | cdmPath = cdmInit.path; 43 | } 44 | if (cduInit) { 45 | if (!cduInit.path.isClassMethod()) { 46 | throw new AnalysisError("Not a class method: componentDidUpdate"); 47 | } 48 | if (cduInit.path.node.params.length > 0) { 49 | throw new AnalysisError("Not supported: componentDidUpdate parameters"); 50 | } 51 | cduPath = cduInit.path; 52 | } 53 | if (cwuInit) { 54 | if (!cwuInit.path.isClassMethod()) { 55 | throw new AnalysisError("Not a class method: componentWillUnmount"); 56 | } 57 | if (cwuInit.path.node.params.length > 0) { 58 | throw new AnalysisError("Invalid parameter of componentWillUnmount"); 59 | } 60 | cwuPath = cwuInit.path; 61 | } 62 | 63 | for (const [name, field] of userDefined.fields) { 64 | if ( 65 | field.type === "user_defined_function" && 66 | field.sites.some( 67 | (site) => 68 | site.type === "expr" && 69 | site.owner === "componentWillUnmount" && 70 | !site.path.parentPath.isCallExpression() 71 | ) 72 | ) { 73 | // A user-defined function is used without immediately calling in componentWillUnmount. 74 | // This is likely the following idiom: 75 | // 76 | // ```js 77 | // onMouseOver = () => { 78 | // ... 79 | // } 80 | // componentDidMount() { 81 | // this.div.addEventListener("mouseover", this.onMouseOver); 82 | // } 83 | // componentWillUnmount() { 84 | // this.div.removeEventListener("mouseover", this.onMouseOver); 85 | // } 86 | // ``` 87 | // 88 | // It may break in our "raw effect" transformation 89 | // because function identity may change over time. 90 | // 91 | // We will implement a separate paths for the patterns above, 92 | // but for now we just error out to avoid risks. 93 | 94 | throw new AnalysisError( 95 | `Possible event unregistration of ${name} in componentWillUnmount` 96 | ); 97 | } 98 | } 99 | return { 100 | cdmPath, 101 | cduPath, 102 | cwuPath, 103 | }; 104 | } 105 | -------------------------------------------------------------------------------- /src/analysis/error.ts: -------------------------------------------------------------------------------- 1 | import type { NodePath } from "@babel/traverse"; 2 | import type { 3 | ClassAccessorProperty, 4 | ClassMethod, 5 | ClassPrivateMethod, 6 | ClassPrivateProperty, 7 | ClassProperty, 8 | TSDeclareMethod, 9 | ThisExpression, 10 | } from "@babel/types"; 11 | 12 | export type SoftError = SoftErrorThisExpr | SoftErrorDecl; 13 | export type SoftErrorThisExpr = { 14 | type: "invalid_this"; 15 | path: NodePath; 16 | }; 17 | export type SoftErrorDecl = { 18 | type: "invalid_decl"; 19 | path: NodePath< 20 | | ClassProperty 21 | | ClassPrivateProperty 22 | | ClassMethod 23 | | ClassPrivateMethod 24 | | TSDeclareMethod 25 | | ClassAccessorProperty 26 | >; 27 | }; 28 | 29 | export class SoftErrorRepository { 30 | errors: SoftError[] = []; 31 | addThisError(thisPath: NodePath) { 32 | this.errors.push({ 33 | type: "invalid_this", 34 | path: thisPath, 35 | }); 36 | } 37 | addDeclError( 38 | declPath: NodePath< 39 | | ClassProperty 40 | | ClassPrivateProperty 41 | | ClassMethod 42 | | ClassPrivateMethod 43 | | TSDeclareMethod 44 | | ClassAccessorProperty 45 | > 46 | ) { 47 | this.errors.push({ 48 | type: "invalid_decl", 49 | path: declPath, 50 | }); 51 | } 52 | } 53 | 54 | export class AnalysisError extends Error { 55 | static { 56 | this.prototype.name = "AnalysisError"; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/analysis/lib.ts: -------------------------------------------------------------------------------- 1 | import type { NodePath } from "@babel/core"; 2 | import { 3 | Expression, 4 | ImportDeclaration, 5 | ImportDefaultSpecifier, 6 | ImportNamespaceSpecifier, 7 | ImportSpecifier, 8 | } from "@babel/types"; 9 | import { importName, memberRefName } from "../utils.js"; 10 | 11 | export type LibRef = 12 | | { 13 | type: "import"; 14 | kind: "named"; 15 | source: string; 16 | specPath: NodePath; 17 | name: string; 18 | } 19 | | { 20 | type: "import"; 21 | kind: "ns"; 22 | source: string; 23 | specPath: NodePath< 24 | ImportSpecifier | ImportDefaultSpecifier | ImportNamespaceSpecifier 25 | >; 26 | name: string; 27 | } 28 | | { 29 | type: "global"; 30 | globalName: string; 31 | name: string; 32 | }; 33 | 34 | export function analyzeLibRef(path: NodePath): LibRef | undefined { 35 | if (path.isIdentifier()) { 36 | const binding = path.scope.getBinding(path.node.name); 37 | if (!binding) { 38 | return; 39 | } 40 | const decl = binding.path; 41 | if (decl.isImportSpecifier()) { 42 | return { 43 | type: "import", 44 | kind: "named", 45 | source: (decl.parentPath as NodePath).node.source 46 | .value, 47 | specPath: decl, 48 | name: importName(decl.node.imported), 49 | }; 50 | } else if (decl.isImportDefaultSpecifier()) { 51 | return { 52 | type: "import", 53 | kind: "named", 54 | source: (decl.parentPath as NodePath).node.source 55 | .value, 56 | specPath: decl, 57 | name: "default", 58 | }; 59 | } 60 | } else if (path.isMemberExpression()) { 61 | const ns = path.get("object"); 62 | if (!ns.isIdentifier()) { 63 | return; 64 | } 65 | const name = memberRefName(path.node); 66 | if (name == null) { 67 | return; 68 | } 69 | 70 | const binding = path.scope.getBinding(ns.node.name); 71 | if (!binding) { 72 | return { 73 | type: "global", 74 | globalName: ns.node.name, 75 | name, 76 | }; 77 | } 78 | const decl = binding.path; 79 | if (decl.isImportNamespaceSpecifier()) { 80 | return { 81 | type: "import", 82 | kind: "ns", 83 | source: (decl.parentPath as NodePath).node.source 84 | .value, 85 | specPath: decl, 86 | name, 87 | }; 88 | } else if ( 89 | decl.isImportDefaultSpecifier() || 90 | (decl.isImportSpecifier() && importName(decl.node.imported) === "default") 91 | ) { 92 | return { 93 | type: "import", 94 | kind: "ns", 95 | source: (decl.parentPath as NodePath).node.source 96 | .value, 97 | specPath: decl, 98 | name, 99 | }; 100 | } 101 | } 102 | } 103 | 104 | export function isReactRef(r: LibRef): boolean { 105 | return ( 106 | (r.type === "import" && r.source === "react") || 107 | (r.type === "global" && r.globalName === "React") 108 | ); 109 | } 110 | -------------------------------------------------------------------------------- /src/analysis/local.ts: -------------------------------------------------------------------------------- 1 | import type { Scope } from "@babel/traverse"; 2 | import type { NodePath } from "@babel/core"; 3 | import type { 4 | ClassDeclaration, 5 | ObjectProperty, 6 | RestElement, 7 | VariableDeclaration, 8 | VariableDeclarator, 9 | } from "@babel/types"; 10 | 11 | // const RE_IDENT = /^[\p{ID_Start}_$][\p{ID_Continue}$\u200C\u200D]*$/u; 12 | const RESERVED: ReadonlySet = new Set([ 13 | // Pure reserved words 14 | "break", 15 | "case", 16 | "catch", 17 | "class", 18 | "const", 19 | "continue", 20 | "debugger", 21 | "default", 22 | "delete", 23 | "do", 24 | "else", 25 | "export", 26 | "extends", 27 | "false", 28 | "finally", 29 | "for", 30 | "function", 31 | "if", 32 | "import", 33 | "in", 34 | "instanceof", 35 | "new", 36 | "null", 37 | "return", 38 | "super", 39 | "switch", 40 | "this", 41 | "throw", 42 | "true", 43 | "try", 44 | "typeof", 45 | "var", 46 | "void", 47 | "while", 48 | "with", 49 | // Strict mode reserved 50 | "arguments", 51 | "enum", 52 | "eval", 53 | "implements", 54 | "interface", 55 | "let", 56 | "package", 57 | "private", 58 | "protected", 59 | "public", 60 | "static", 61 | "yield", 62 | // Module-level reserved 63 | "await", 64 | ]); 65 | 66 | export type RemovableNode = 67 | | ObjectProperty 68 | | RestElement 69 | | VariableDeclarator 70 | | VariableDeclaration; 71 | 72 | export class LocalManager { 73 | classPath: NodePath; 74 | constructor(classPath: NodePath) { 75 | this.classPath = classPath; 76 | } 77 | 78 | assigned = new Set(); 79 | markCaptured(name: string) { 80 | this.assigned.add(name); 81 | } 82 | newLocal(baseName: string, paths: NodePath[]): string { 83 | const bindingScopes = this.collectScope(paths); 84 | let name = baseName.replace(/[^\p{ID_Continue}$\u200C\u200D]/gu, ""); 85 | if (!/^[\p{ID_Start}_$]/u.test(name) || RESERVED.has(name)) { 86 | name = `_${name}`; 87 | } 88 | if (this.hasName(name, bindingScopes)) { 89 | name = name.replace(/\d+$/, ""); 90 | for (let i = 0; ; i++) { 91 | if (i >= 1000000) { 92 | throw new Error("Unexpected infinite loop"); 93 | } 94 | if (!this.hasName(`${name}${i}`, bindingScopes)) { 95 | name = `${name}${i}`; 96 | break; 97 | } 98 | } 99 | } 100 | this.assigned.add(name); 101 | return name; 102 | } 103 | 104 | private hasName(name: string, scopes: Scope[]): boolean { 105 | return ( 106 | this.assigned.has(name) || 107 | scopes.some((scope) => { 108 | const binding = scope.getBinding(name); 109 | if (!binding) { 110 | return false; 111 | } 112 | if (this.allRemovePaths.has(binding.path as NodePath)) { 113 | return false; 114 | } 115 | return true; 116 | }) 117 | ); 118 | } 119 | 120 | private collectScope(paths: NodePath[]): Scope[] { 121 | const scopes = new Set(); 122 | const baseScope = this.classPath.scope; 123 | for (const path of paths) { 124 | let currentScope: Scope | undefined = path.scope; 125 | while (currentScope && currentScope !== baseScope) { 126 | scopes.add(currentScope); 127 | currentScope = currentScope.parent; 128 | } 129 | } 130 | return Array.from(scopes); 131 | } 132 | 133 | removePaths = new Set>(); 134 | allRemovePaths = new Set>(); 135 | reserveRemoval(path: NodePath): boolean { 136 | const cPath = canonicalRemoveTarget(path); 137 | if (!cPath) { 138 | return false; 139 | } 140 | this.allRemovePaths.add(cPath); 141 | this.removePaths.add(cPath); 142 | const path1 = cPath.parentPath; 143 | if (!path1) { 144 | return true; 145 | } 146 | if (path1.isObjectPattern()) { 147 | this.tryPromote(path1.get("properties"), path1); 148 | } else if (path1.isVariableDeclaration()) { 149 | this.tryPromote(path1.get("declarations"), path1); 150 | } 151 | return true; 152 | } 153 | // Try to remove the parent node instead 154 | tryPromote(subPaths: NodePath[], path: NodePath) { 155 | if (subPaths.every((subPath) => this.removePaths.has(subPath))) { 156 | const promoted = this.reserveRemoval(path); 157 | if (promoted) { 158 | for (const subPath of subPaths) { 159 | this.removePaths.delete(subPath); 160 | } 161 | } 162 | } 163 | } 164 | } 165 | 166 | function canonicalRemoveTarget( 167 | path: NodePath 168 | ): NodePath | undefined { 169 | if (path.isIdentifier() || path.isObjectPattern()) { 170 | if (path.parentPath.isObjectProperty({ value: path.node })) { 171 | return path.parentPath; 172 | } else if (path.parentPath.isVariableDeclarator({ id: path.node })) { 173 | return path.parentPath; 174 | } 175 | } else if (path.isObjectProperty()) { 176 | return path; 177 | } else if (path.isVariableDeclarator()) { 178 | return path; 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /src/analysis/pre.ts: -------------------------------------------------------------------------------- 1 | // This file contains analysis paths for class heads. 2 | 3 | import type { NodePath } from "@babel/core"; 4 | import type { 5 | BlockStatement, 6 | ClassDeclaration, 7 | Identifier, 8 | Program, 9 | TSInterfaceBody, 10 | TSMethodSignature, 11 | TSPropertySignature, 12 | TSType, 13 | TSTypeParameterDeclaration, 14 | } from "@babel/types"; 15 | import { memberName, nonNullPath } from "../utils.js"; 16 | import { analyzeLibRef, isReactRef, LibRef } from "./lib.js"; 17 | 18 | export type PreAnalysisResult = { 19 | /** 20 | * The declared name of the class declaration/expression. 21 | * 22 | * May be absent if it is a class expression or a class declaration in an `export default` declaration. 23 | */ 24 | name?: Identifier | undefined; 25 | /** 26 | * Generics on the class. 27 | */ 28 | typeParameters?: NodePath | undefined; 29 | /** 30 | * How does the component reference `React.Component`? 31 | * This is necessary to add another reference to React libraries, such as `React.FC` and `React.useState`. 32 | */ 33 | superClassRef: LibRef; 34 | /** 35 | * Does it extend `PureComponent` instead of `Component`? 36 | */ 37 | isPure: boolean; 38 | /** 39 | * A node containing Props type (`P` as in `React.Component

`) 40 | */ 41 | props: NodePath | undefined; 42 | /** 43 | * Decomposed Props type (`P` as in `React.Component

`) 44 | */ 45 | propsEach: Map>; 46 | /** 47 | * Decomposed State type (`S` as in `React.Component`) 48 | */ 49 | states: Map>; 50 | }; 51 | 52 | /** 53 | * Analyzes a class header to determine if it should be transformed. 54 | * 55 | * @param path the pass to the class node 56 | * @returns an object containing analysis result, if the class should be transformed 57 | */ 58 | export function preanalyzeClass( 59 | path: NodePath 60 | ): PreAnalysisResult | undefined { 61 | if ( 62 | path.node.leadingComments?.some((comment) => 63 | /react-declassify-disable/.test(comment.value) 64 | ) 65 | ) { 66 | // Explicitly disabled 67 | // 68 | // E.g. 69 | // ```js 70 | // /* react-declassify-disable */ 71 | // class MyComponent extends Component {} 72 | // ``` 73 | return; 74 | } 75 | if ( 76 | path.node.leadingComments?.some( 77 | (comment) => 78 | comment.type === "CommentBlock" && 79 | /^\*/.test(comment.value) && 80 | /@abstract/.test(comment.value) 81 | ) || 82 | path.node.abstract 83 | ) { 84 | // This is an abstract class to be inherited; do not attempt transformation. 85 | // 86 | // E.g. 87 | // ```js 88 | // abstract class MyComponent extends Component {} 89 | // /** @abstract */ 90 | // class MyComponent2 extends Component {} 91 | // ``` 92 | return; 93 | } 94 | 95 | // Check if it extends React.Component or React.PureComponent 96 | const superClass = nonNullPath(path.get("superClass")); 97 | if (!superClass) { 98 | // Not a subclass 99 | return; 100 | } 101 | const superClassRef = analyzeLibRef(superClass); 102 | if ( 103 | // Subclass of an unknown class 104 | !superClassRef || 105 | // Not a react thing, presumably 106 | !isReactRef(superClassRef) || 107 | // React.Something but I'm not sure what it is 108 | !( 109 | superClassRef.name === "Component" || 110 | superClassRef.name === "PureComponent" 111 | ) 112 | ) { 113 | return; 114 | } 115 | 116 | // OK, now we are going to transform the component 117 | const name = path.node.id; 118 | const typeParameters_ = nonNullPath(path.get("typeParameters")); 119 | const typeParameters = typeParameters_?.isTSTypeParameterDeclaration() 120 | ? typeParameters_ 121 | : undefined; 122 | const isPure = superClassRef.name === "PureComponent"; 123 | let props: NodePath | undefined; 124 | let propsEach: 125 | | Map> 126 | | undefined = undefined; 127 | let states: 128 | | Map> 129 | | undefined = undefined; 130 | const superTypeParameters = path.get("superTypeParameters"); 131 | if (superTypeParameters.isTSTypeParameterInstantiation()) { 132 | // Analyze P and S as in React.Component 133 | const params = superTypeParameters.get("params"); 134 | if (params.length > 0) { 135 | props = params[0]; 136 | propsEach = decompose(params[0]!); 137 | } 138 | if (params.length > 1) { 139 | const stateParamPath = params[1]!; 140 | states = decompose(stateParamPath); 141 | } 142 | } 143 | propsEach ??= new Map(); 144 | states ??= new Map(); 145 | return { 146 | name, 147 | typeParameters, 148 | superClassRef, 149 | isPure, 150 | props, 151 | propsEach, 152 | states, 153 | }; 154 | } 155 | 156 | /** 157 | * Tries to decompose a type into a set of property signatures. 158 | * 159 | * @param path a type 160 | * @returns a map containing property signatures and method signatures 161 | */ 162 | function decompose( 163 | path: NodePath 164 | ): Map> { 165 | const aliasPath = resolveAlias(path); 166 | const members = aliasPath.isTSTypeLiteral() 167 | ? aliasPath.get("members") 168 | : aliasPath.isTSInterfaceBody() 169 | ? aliasPath.get("body") 170 | : undefined; 171 | const decomposed = new Map< 172 | string, 173 | NodePath 174 | >(); 175 | if (members) { 176 | for (const member of members) { 177 | if (member.isTSPropertySignature() || member.isTSMethodSignature()) { 178 | const name = memberName(member.node); 179 | if (name != null) { 180 | decomposed.set(name, member); 181 | } 182 | } 183 | } 184 | } 185 | return decomposed; 186 | } 187 | 188 | /** 189 | * Jumps to the definition if the type node references other type. 190 | * 191 | * @param path a type to resolve 192 | * @returns A type node or a node containing an `interface` definition 193 | */ 194 | function resolveAlias( 195 | path: NodePath 196 | ): NodePath { 197 | if (path.isTSTypeReference()) { 198 | const typeNamePath = path.get("typeName"); 199 | if (typeNamePath.isIdentifier()) { 200 | // Resolve identifier using heuristics. 201 | // Babel does not have full scope resolver for types. 202 | const name = typeNamePath.node.name; 203 | let scope = typeNamePath.scope; 204 | while (scope) { 205 | if (scope.path.isBlockStatement() || scope.path.isProgram()) { 206 | const path_: NodePath = scope.path; 207 | for (const body of path_.get("body")) { 208 | if (body.isTSTypeAliasDeclaration() && body.node.id.name === name) { 209 | return body.get("typeAnnotation"); 210 | } else if ( 211 | body.isTSInterfaceDeclaration() && 212 | body.node.id.name === name 213 | ) { 214 | return body.get("body"); 215 | } 216 | } 217 | } 218 | scope = scope.parent; 219 | } 220 | } 221 | } 222 | return path; 223 | } 224 | -------------------------------------------------------------------------------- /src/analysis/prop.ts: -------------------------------------------------------------------------------- 1 | import type { NodePath } from "@babel/core"; 2 | import type { Scope } from "@babel/traverse"; 3 | import type { 4 | Expression, 5 | MemberExpression, 6 | TSMethodSignature, 7 | TSPropertySignature, 8 | } from "@babel/types"; 9 | import { getOr, memberName } from "../utils.js"; 10 | import { AnalysisError, SoftErrorRepository } from "./error.js"; 11 | import type { LocalManager } from "./local.js"; 12 | import { ClassFieldAnalysis, addClassFieldError } from "./class_fields.js"; 13 | import { trackMember } from "./track_member.js"; 14 | import { PreAnalysisResult } from "./pre.js"; 15 | 16 | export type PropsObjAnalysis = { 17 | hasDefaults: boolean; 18 | sites: PropsObjSite[]; 19 | props: Map; 20 | allAliases: PropAlias[]; 21 | }; 22 | 23 | export type PropAnalysis = { 24 | newAliasName?: string | undefined; 25 | defaultValue?: NodePath; 26 | sites: PropSite[]; 27 | aliases: PropAlias[]; 28 | typing?: NodePath | undefined; 29 | }; 30 | 31 | // These are mutually linked 32 | export type PropsObjSite = { 33 | path: NodePath; 34 | owner: string | undefined; 35 | decomposedAsAliases: boolean; 36 | child: PropSite | undefined; 37 | }; 38 | 39 | export type PropSite = { 40 | path: NodePath; 41 | parent: PropsObjSite; 42 | owner: string | undefined; 43 | enabled: boolean; 44 | }; 45 | 46 | export type PropAlias = { 47 | scope: Scope; 48 | localName: string; 49 | owner: string | undefined; 50 | }; 51 | 52 | /** 53 | * Detects assignments that expand `this.props` to variables, like: 54 | * 55 | * ```js 56 | * const { foo, bar } = this.props; 57 | * ``` 58 | * 59 | * or: 60 | * 61 | * ```js 62 | * const foo = this.props.foo; 63 | * const bar = this.props.bar; 64 | * ``` 65 | */ 66 | export function analyzeProps( 67 | propsObjAnalysis: ClassFieldAnalysis, 68 | defaultPropsObjAnalysis: ClassFieldAnalysis, 69 | locals: LocalManager, 70 | softErrors: SoftErrorRepository, 71 | preanalysis: PreAnalysisResult 72 | ): PropsObjAnalysis { 73 | const defaultProps = analyzeDefaultProps(defaultPropsObjAnalysis); 74 | const newObjSites: PropsObjSite[] = []; 75 | const props = new Map(); 76 | const getProp = (name: string) => 77 | getOr(props, name, () => ({ 78 | sites: [], 79 | aliases: [], 80 | })); 81 | 82 | for (const site of propsObjAnalysis.sites) { 83 | if (site.type !== "expr" || site.hasWrite) { 84 | addClassFieldError(site, softErrors); 85 | continue; 86 | } 87 | const memberAnalysis = trackMember(site.path); 88 | const parentSite: PropsObjSite = { 89 | path: site.path, 90 | owner: site.owner, 91 | decomposedAsAliases: false, 92 | child: undefined, 93 | }; 94 | if (memberAnalysis.fullyDecomposed && memberAnalysis.memberAliases) { 95 | newObjSites.push(parentSite); 96 | for (const [name, aliasing] of memberAnalysis.memberAliases) { 97 | getProp(name).aliases.push({ 98 | scope: aliasing.scope, 99 | localName: aliasing.localName, 100 | owner: site.owner, 101 | }); 102 | locals.reserveRemoval(aliasing.idPath); 103 | } 104 | parentSite.decomposedAsAliases = true; 105 | } else { 106 | if (defaultProps && !memberAnalysis.memberExpr) { 107 | addClassFieldError(site, softErrors); 108 | continue; 109 | } 110 | newObjSites.push(parentSite); 111 | if (memberAnalysis.memberExpr) { 112 | const child: PropSite = { 113 | path: memberAnalysis.memberExpr.path, 114 | parent: parentSite, 115 | owner: site.owner, 116 | // `enabled` will also be turned on later in callback analysis 117 | enabled: !!defaultProps, 118 | }; 119 | parentSite.child = child; 120 | getProp(memberAnalysis.memberExpr.name).sites.push(child); 121 | } 122 | } 123 | } 124 | for (const [name, propTyping] of preanalysis.propsEach) { 125 | getProp(name).typing = propTyping; 126 | } 127 | if (defaultProps) { 128 | for (const [name, defaultValue] of defaultProps) { 129 | getProp(name).defaultValue = defaultValue; 130 | } 131 | } 132 | const allAliases = Array.from(props.values()).flatMap((prop) => prop.aliases); 133 | return { 134 | hasDefaults: !!defaultProps, 135 | sites: newObjSites, 136 | props, 137 | allAliases, 138 | }; 139 | } 140 | 141 | export function needAlias(prop: PropAnalysis): boolean { 142 | return prop.aliases.length > 0 || prop.sites.some((s) => s.enabled); 143 | } 144 | 145 | function analyzeDefaultProps( 146 | defaultPropsAnalysis: ClassFieldAnalysis 147 | ): Map> | undefined { 148 | for (const site of defaultPropsAnalysis.sites) { 149 | if (!site.init) { 150 | throw new AnalysisError(`Invalid use of static defaultProps`); 151 | } 152 | } 153 | 154 | const defaultPropsFields = new Map>(); 155 | const init = defaultPropsAnalysis.sites.find((site) => site.init); 156 | if (!init) { 157 | return; 158 | } 159 | const init_ = init.init!; 160 | if (init_.type !== "init_value") { 161 | throw new AnalysisError("Non-analyzable defaultProps initializer"); 162 | } 163 | const initPath = init_.valuePath; 164 | if (!initPath.isObjectExpression()) { 165 | throw new AnalysisError("Non-analyzable defaultProps initializer"); 166 | } 167 | for (const fieldPath of initPath.get("properties")) { 168 | if (!fieldPath.isObjectProperty()) { 169 | throw new AnalysisError("Non-analyzable defaultProps initializer"); 170 | } 171 | const stateName = memberName(fieldPath.node); 172 | if (stateName == null) { 173 | throw new AnalysisError("Non-analyzable defaultProps initializer"); 174 | } 175 | const fieldInitPath = fieldPath.get("value"); 176 | if (!fieldInitPath.isExpression()) { 177 | throw new AnalysisError("Non-analyzable defaultProps initializer"); 178 | } 179 | defaultPropsFields.set(stateName, fieldInitPath); 180 | } 181 | return defaultPropsFields.size > 0 ? defaultPropsFields : undefined; 182 | } 183 | -------------------------------------------------------------------------------- /src/analysis/state.ts: -------------------------------------------------------------------------------- 1 | import type { NodePath } from "@babel/core"; 2 | import type { 3 | CallExpression, 4 | Expression, 5 | Identifier, 6 | ObjectProperty, 7 | RestElement, 8 | TSType, 9 | } from "@babel/types"; 10 | import { getOr, memberName } from "../utils.js"; 11 | import { AnalysisError, SoftErrorRepository } from "./error.js"; 12 | import { PreAnalysisResult } from "./pre.js"; 13 | import type { LocalManager } from "./local.js"; 14 | import { ClassFieldAnalysis, addClassFieldError } from "./class_fields.js"; 15 | import { trackMember } from "./track_member.js"; 16 | 17 | export type StateObjAnalysis = { 18 | states: Map; 19 | setStateSites: SetStateSite[]; 20 | }; 21 | 22 | export type StateAnalysis = { 23 | localName?: string | undefined; 24 | localSetterName?: string | undefined; 25 | init?: StateInitSite | undefined; 26 | typeAnnotation?: StateTypeAnnotation; 27 | sites: StateSite[]; 28 | }; 29 | 30 | export type StateSite = StateInitSite | StateExprSite; 31 | 32 | export type StateInitSite = { 33 | type: "state_init"; 34 | path: NodePath; 35 | valuePath: NodePath; 36 | }; 37 | export type StateExprSite = { 38 | type: "expr"; 39 | path: NodePath; 40 | owner: string | undefined; 41 | }; 42 | 43 | export type SetStateSite = { 44 | path: NodePath; 45 | fields: SetStateFieldSite[]; 46 | }; 47 | 48 | export type SetStateFieldSite = { 49 | name: string; 50 | valuePath: NodePath; 51 | }; 52 | 53 | export type StateTypeAnnotation = 54 | | { 55 | type: "simple"; 56 | path: NodePath; 57 | } 58 | | { 59 | type: "method"; 60 | params: NodePath[]; 61 | returnType: NodePath; 62 | }; 63 | 64 | export function analyzeState( 65 | stateObjAnalysis: ClassFieldAnalysis, 66 | setStateAnalysis: ClassFieldAnalysis, 67 | locals: LocalManager, 68 | softErrors: SoftErrorRepository, 69 | preanalysis: PreAnalysisResult 70 | ): StateObjAnalysis { 71 | const states = new Map(); 72 | const getState = (name: string) => 73 | getOr(states, name, () => ({ 74 | sites: [], 75 | })); 76 | 77 | const init = stateObjAnalysis.sites.find((site) => site.init); 78 | if (init) { 79 | const init_ = init.init!; 80 | if (init_.type !== "init_value") { 81 | throw new AnalysisError("Non-analyzable state initializer"); 82 | } 83 | const initPath = init_.valuePath; 84 | if (!initPath.isObjectExpression()) { 85 | throw new AnalysisError("Non-analyzable state initializer"); 86 | } 87 | for (const fieldPath of initPath.get("properties")) { 88 | if (!fieldPath.isObjectProperty()) { 89 | throw new AnalysisError("Non-analyzable state initializer"); 90 | } 91 | const stateName = memberName(fieldPath.node); 92 | if (stateName == null) { 93 | throw new AnalysisError("Non-analyzable state initializer"); 94 | } 95 | const fieldInitPath = fieldPath.get("value"); 96 | if (!fieldInitPath.isExpression()) { 97 | throw new AnalysisError("Non-analyzable state initializer"); 98 | } 99 | const state = getState(stateName); 100 | state.sites.push({ 101 | type: "state_init", 102 | path: fieldPath, 103 | valuePath: fieldInitPath, 104 | }); 105 | } 106 | } 107 | for (const site of stateObjAnalysis.sites) { 108 | if (site.init) { 109 | continue; 110 | } 111 | if (site.type !== "expr" || site.hasWrite) { 112 | addClassFieldError(site, softErrors); 113 | continue; 114 | } 115 | const memberAnalysis = trackMember(site.path); 116 | if (memberAnalysis.fullyDecomposed && memberAnalysis.memberAliases) { 117 | for (const [name, aliasInfo] of memberAnalysis.memberAliases) { 118 | const binding = aliasInfo.scope.getBinding(aliasInfo.localName)!; 119 | locals.reserveRemoval(binding.path); 120 | for (const path of binding.referencePaths) { 121 | if (!path.isExpression()) { 122 | throw new Error("referencePath contains non-Expression"); 123 | } 124 | getState(name).sites.push({ 125 | type: "expr", 126 | path, 127 | owner: site.owner, 128 | }); 129 | } 130 | } 131 | } else if (memberAnalysis.memberExpr) { 132 | getState(memberAnalysis.memberExpr.name).sites.push({ 133 | type: "expr", 134 | path: memberAnalysis.memberExpr.path, 135 | owner: site.owner, 136 | }); 137 | } else { 138 | addClassFieldError(site, softErrors); 139 | continue; 140 | } 141 | } 142 | const setStateSites: SetStateSite[] = []; 143 | setStateLoop: for (const site of setStateAnalysis.sites) { 144 | if (site.type !== "expr" || site.hasWrite) { 145 | addClassFieldError(site, softErrors); 146 | continue; 147 | } 148 | const gpPath = site.path.parentPath; 149 | if (!gpPath.isCallExpression()) { 150 | addClassFieldError(site, softErrors); 151 | continue; 152 | } 153 | const args = gpPath.get("arguments"); 154 | if (args.length !== 1) { 155 | addClassFieldError(site, softErrors); 156 | continue; 157 | } 158 | const arg0 = args[0]!; 159 | if (arg0.isObjectExpression()) { 160 | const props = arg0.get("properties"); 161 | const fields: SetStateFieldSite[] = []; 162 | for (const prop of props) { 163 | if (!prop.isObjectProperty()) { 164 | addClassFieldError(site, softErrors); 165 | continue setStateLoop; 166 | } 167 | const setStateName = memberName(prop.node); 168 | if (setStateName == null) { 169 | addClassFieldError(site, softErrors); 170 | continue setStateLoop; 171 | } 172 | // Ensure the state exists 173 | getState(setStateName); 174 | fields.push({ 175 | name: setStateName, 176 | valuePath: prop.get("value") as NodePath, 177 | }); 178 | } 179 | setStateSites.push({ 180 | path: gpPath, 181 | fields, 182 | }); 183 | } else { 184 | addClassFieldError(site, softErrors); 185 | continue; 186 | } 187 | } 188 | for (const [name, stateType] of preanalysis.states) { 189 | const state = getState(name); 190 | if (stateType.isTSPropertySignature()) { 191 | const annot = stateType.get("typeAnnotation"); 192 | if (annot.isTSTypeAnnotation()) { 193 | state.typeAnnotation = { 194 | type: "simple", 195 | path: annot.get("typeAnnotation"), 196 | }; 197 | } 198 | } else if (stateType.isTSMethodSignature()) { 199 | const params = stateType.get("parameters"); 200 | const returnAnnot = stateType.get("typeAnnotation"); 201 | if (returnAnnot.isTSTypeAnnotation()) { 202 | state.typeAnnotation = { 203 | type: "method", 204 | params, 205 | returnType: returnAnnot.get("typeAnnotation"), 206 | }; 207 | } 208 | } 209 | } 210 | for (const [name, state] of states.entries()) { 211 | const numInits = state.sites.reduce( 212 | (n, site) => n + Number(site.type === "state_init"), 213 | 0 214 | ); 215 | if (numInits > 1) { 216 | throw new AnalysisError(`${name} is initialized more than once`); 217 | } 218 | state.init = state.sites.find( 219 | (site): site is StateInitSite => site.type === "state_init" 220 | ); 221 | } 222 | return { states, setStateSites }; 223 | } 224 | -------------------------------------------------------------------------------- /src/analysis/track_member.ts: -------------------------------------------------------------------------------- 1 | import type { NodePath } from "@babel/core"; 2 | import type { Scope } from "@babel/traverse"; 3 | import type { Expression, Identifier, MemberExpression } from "@babel/types"; 4 | import { memberName, memberRefName } from "../utils.js"; 5 | 6 | export type ObjectExpressionAnalysis = { 7 | path: NodePath; 8 | memberExpr?: MemberExprInfo | undefined; 9 | memberAliases?: Map | undefined; 10 | fullyDecomposed: boolean; 11 | }; 12 | 13 | export type MemberExprInfo = { 14 | name: string; 15 | path: NodePath; 16 | }; 17 | 18 | export type MemberAliasing = { 19 | scope: Scope; 20 | localName: string; 21 | idPath: NodePath; 22 | }; 23 | 24 | export function trackMember( 25 | path: NodePath 26 | ): ObjectExpressionAnalysis { 27 | let memberExpr: MemberExprInfo | undefined = undefined; 28 | let memberAliases: Map | undefined = undefined; 29 | let fullyDecomposed = false; 30 | const path1 = path.parentPath; 31 | if (path1.isMemberExpression({ object: path.node })) { 32 | // Check for `.foo` 33 | const name = memberRefName(path1.node); 34 | if (name != null) { 35 | memberExpr = { 36 | name, 37 | path: path1, 38 | }; 39 | const idPath = getSimpleAliasing(path1); 40 | if (idPath) { 41 | // Found `const foo = .foo;` 42 | memberAliases = new Map(); 43 | memberAliases.set(name, { 44 | scope: idPath.scope, 45 | localName: idPath.node.name, 46 | idPath, 47 | }); 48 | fullyDecomposed = true; 49 | } 50 | } 51 | } else if (path1.isVariableDeclarator({ init: path.node })) { 52 | const path2 = path1.parentPath; 53 | if (path2.isVariableDeclaration({ kind: "const" })) { 54 | // Check for `const { foo } = ;` 55 | const lvPath = path1.get("id"); 56 | if (lvPath.isObjectPattern()) { 57 | fullyDecomposed = true; 58 | memberAliases = new Map(); 59 | for (const propPath of lvPath.get("properties")) { 60 | let ok = false; 61 | if (propPath.isObjectProperty()) { 62 | const name = memberName(propPath.node); 63 | const valuePath = propPath.get("value"); 64 | if (name != null && valuePath.isIdentifier()) { 65 | ok = true; 66 | memberAliases.set(name, { 67 | scope: valuePath.scope, 68 | localName: valuePath.node.name, 69 | idPath: valuePath, 70 | }); 71 | } 72 | } 73 | fullyDecomposed &&= ok; 74 | } 75 | } 76 | } 77 | } 78 | return { path, memberExpr, memberAliases, fullyDecomposed }; 79 | } 80 | 81 | function getSimpleAliasing( 82 | path: NodePath 83 | ): NodePath | undefined { 84 | const path1 = path.parentPath; 85 | if (path1.isVariableDeclarator({ init: path.node })) { 86 | const path2 = path1.parentPath; 87 | if (path2.isVariableDeclaration({ kind: "const" })) { 88 | const idPath = path1.get("id"); 89 | if (idPath.isIdentifier()) { 90 | return idPath; 91 | } 92 | } 93 | } 94 | return undefined; 95 | } 96 | -------------------------------------------------------------------------------- /src/analysis/user_defined.ts: -------------------------------------------------------------------------------- 1 | import type { NodePath } from "@babel/core"; 2 | import { 3 | ArrowFunctionExpression, 4 | ClassMethod, 5 | ClassPrivateMethod, 6 | Expression, 7 | FunctionExpression, 8 | TSType, 9 | } from "@babel/types"; 10 | import { getOr, isClassMethodLike, nonNullPath } from "../utils.js"; 11 | import { AnalysisError, SoftErrorRepository } from "./error.js"; 12 | import { analyzeLibRef, isReactRef } from "./lib.js"; 13 | import { 14 | ClassFieldAnalysis, 15 | ClassFieldSite, 16 | addClassFieldError, 17 | } from "./class_fields.js"; 18 | import { PropsObjAnalysis } from "./prop.js"; 19 | import { StateObjAnalysis } from "./state.js"; 20 | 21 | const SPECIAL_MEMBER_NAMES = new Set([ 22 | // Special variables 23 | "context", 24 | "props", 25 | "refs", 26 | "state", 27 | // Lifecycle 28 | "constructor", 29 | "render", 30 | "componentDidCatch", 31 | "componentDidMount", 32 | "componentDidUpdate", 33 | "componentWillMount", 34 | "UNSAFE_componentWillMount", 35 | "componentWillReceiveProps", 36 | "UNSAFE_componentWillReceiveProps", 37 | "componentWillUpdate", 38 | "UNSAFE_componentWillUpdate", 39 | "componentWillUnmount", 40 | // Lifecycle predicates 41 | "shouldComponentUpdate", 42 | "getSnapshotBeforeUpdate", 43 | "getChildContext", 44 | // APIs (including deprecated) 45 | "isReactComponent", 46 | "isMounted", 47 | "forceUpdate", 48 | "setState", 49 | "replaceState", 50 | ]); 51 | 52 | export type UserDefinedAnalysis = { 53 | fields: Map; 54 | }; 55 | 56 | export type UserDefined = UserDefinedRef | UserDefinedDirectRef | UserDefinedFn; 57 | 58 | export type UserDefinedRef = { 59 | type: "user_defined_ref"; 60 | localName?: string | undefined; 61 | typeAnnotation?: NodePath | undefined; 62 | sites: ClassFieldSite[]; 63 | }; 64 | export type UserDefinedDirectRef = { 65 | type: "user_defined_direct_ref"; 66 | localName?: string | undefined; 67 | init: NodePath | undefined; 68 | typeAnnotation?: NodePath | undefined; 69 | sites: ClassFieldSite[]; 70 | }; 71 | export type UserDefinedFn = { 72 | type: "user_defined_function"; 73 | localName?: string | undefined; 74 | init: FnInit; 75 | typeAnnotation?: NodePath | undefined; 76 | sites: ClassFieldSite[]; 77 | needMemo: boolean; 78 | dependencies: CallbackDependency[]; 79 | }; 80 | 81 | export type FnInit = 82 | | { 83 | type: "method"; 84 | path: NodePath; 85 | } 86 | | { 87 | type: "func_def"; 88 | initPath: NodePath; 89 | }; 90 | 91 | export type CallbackDependency = 92 | | CallbackDependencyPropsObj 93 | | CallbackDependencyProp 94 | | CallbackDependencyPropAlias 95 | | CallbackDependencyState 96 | | CallbackDependencyFn; 97 | 98 | export type CallbackDependencyPropsObj = { 99 | type: "dep_props_obj"; 100 | }; 101 | export type CallbackDependencyProp = { 102 | type: "dep_prop"; 103 | name: string; 104 | }; 105 | export type CallbackDependencyPropAlias = { 106 | type: "dep_prop_alias"; 107 | name: string; 108 | }; 109 | export type CallbackDependencyState = { 110 | type: "dep_state"; 111 | name: string; 112 | }; 113 | export type CallbackDependencyFn = { 114 | type: "dep_function"; 115 | name: string; 116 | }; 117 | 118 | export function analyzeUserDefined( 119 | instanceFields: Map, 120 | softErrors: SoftErrorRepository 121 | ): UserDefinedAnalysis { 122 | const fields = new Map(); 123 | for (const [name, field] of instanceFields) { 124 | if (SPECIAL_MEMBER_NAMES.has(name)) { 125 | for (const site of field.sites) { 126 | addClassFieldError(site, softErrors); 127 | } 128 | continue; 129 | } 130 | let fnInit: FnInit | undefined = undefined; 131 | let isRefInit = false; 132 | let refInitType1: NodePath | undefined = undefined; 133 | let refInitType2: NodePath | undefined = undefined; 134 | let valInit: NodePath | undefined = undefined; 135 | let valInitType: NodePath | undefined = undefined; 136 | const initSite = field.sites.find((site) => site.init); 137 | if (initSite) { 138 | const init = initSite.init!; 139 | if (isClassMethodLike(initSite.path)) { 140 | fnInit = { 141 | type: "method", 142 | path: initSite.path, 143 | }; 144 | } else if (init.type === "init_value") { 145 | const initPath = init.valuePath; 146 | if ( 147 | initPath.isFunctionExpression() || 148 | initPath.isArrowFunctionExpression() 149 | ) { 150 | fnInit = { 151 | type: "func_def", 152 | initPath, 153 | }; 154 | } 155 | 156 | if (initPath.isCallExpression()) { 157 | const initFn = initPath.get("callee") as NodePath; 158 | const initArgs = initPath.get("arguments"); 159 | const initRef = analyzeLibRef(initFn); 160 | if (initRef && isReactRef(initRef) && initRef.name === "createRef") { 161 | if (initArgs.length > 0) { 162 | throw new AnalysisError("Extra arguments to createRef"); 163 | } 164 | isRefInit = true; 165 | const typeParameters = nonNullPath(initPath.get("typeParameters")); 166 | if (typeParameters) { 167 | const params = typeParameters.get("params"); 168 | if (params.length > 0) { 169 | // this.foo = React.createRef(); 170 | // ^^^^^^^^^^^^^^ 171 | refInitType1 = params[0]!; 172 | } 173 | } 174 | } 175 | } 176 | valInit = initPath; 177 | } 178 | } 179 | const typeSite = field.sites.find((site) => site.typing); 180 | if (typeSite) { 181 | const typing = typeSite.typing!; 182 | if (typing.type === "type_value") { 183 | if (typing.valueTypePath.isTSTypeReference()) { 184 | const lastName = 185 | typing.valueTypePath.node.typeName.type === "Identifier" 186 | ? typing.valueTypePath.node.typeName.name 187 | : typing.valueTypePath.node.typeName.right.name; 188 | const typeParameters = nonNullPath( 189 | typing.valueTypePath.get("typeParameters") 190 | ); 191 | if (lastName === "RefObject" && typeParameters) { 192 | const params = typeParameters.get("params"); 193 | if (params.length > 0) { 194 | // class C { 195 | // foo: React.RefObject; 196 | // ^^^^^^^^^^^^^^ 197 | // } 198 | refInitType2 = params[0]!; 199 | } 200 | } 201 | } 202 | // class C { 203 | // foo: HTMLDivElement | null; 204 | // ^^^^^^^^^^^^^^^^^^^^^ 205 | // } 206 | valInitType = typing.valueTypePath; 207 | } 208 | } 209 | const hasWrite = field.sites.some((site) => site.hasWrite); 210 | if (fnInit && !hasWrite) { 211 | fields.set(name, { 212 | type: "user_defined_function", 213 | init: fnInit, 214 | typeAnnotation: valInitType, 215 | sites: field.sites, 216 | // set to true in the later analysis 217 | needMemo: false, 218 | dependencies: [], 219 | }); 220 | } else if (isRefInit && !hasWrite) { 221 | fields.set(name, { 222 | type: "user_defined_ref", 223 | typeAnnotation: refInitType1 ?? refInitType2, 224 | sites: field.sites, 225 | }); 226 | } else { 227 | fields.set(name, { 228 | type: "user_defined_direct_ref", 229 | init: valInit, 230 | typeAnnotation: valInitType, 231 | sites: field.sites, 232 | }); 233 | } 234 | } 235 | 236 | // Analysis for `useCallback` inference 237 | // preDependencies: dependency between methods 238 | const preDependencies = new Map(); 239 | // It's actually a stack but either is fine 240 | const queue: string[] = []; 241 | // First loop: analyze preDependencies and memo requirement 242 | for (const [name, field] of instanceFields) { 243 | const ud = fields.get(name); 244 | if (ud?.type !== "user_defined_function") { 245 | continue; 246 | } 247 | for (const site of field.sites) { 248 | if (site.type === "expr" && site.owner != null) { 249 | const ownerField = fields.get(site.owner); 250 | if (ownerField?.type === "user_defined_function") { 251 | getOr(preDependencies, site.owner, () => []).push(name); 252 | } 253 | } 254 | if (site.type === "expr") { 255 | const path1 = site.path.parentPath; 256 | // If it is directly called, memoization is not necessary for this expression. 257 | if (!path1.isCallExpression()) { 258 | if (!ud.needMemo) { 259 | queue.push(name); 260 | ud.needMemo = true; 261 | } 262 | } 263 | } 264 | } 265 | } 266 | // Do a search (BFS or DFS) to expand needMemo frontier 267 | while (queue.length > 0) { 268 | const name = queue.pop()!; 269 | for (const depName of preDependencies.get(name) ?? []) { 270 | const depUD = fields.get(depName)!; 271 | if (depUD.type === "user_defined_function" && !depUD.needMemo) { 272 | queue.push(depName); 273 | depUD.needMemo = true; 274 | } 275 | } 276 | } 277 | // Teorder fields in the order of dependency 278 | // while keepping the original order otherwise. 279 | // This is done with a typical topological sort 280 | const reorderedFields = new Map(); 281 | const reorderVisited = new Set(); 282 | function addReorderedField(name: string) { 283 | if (reorderedFields.has(name)) { 284 | return; 285 | } 286 | if (reorderVisited.has(name)) { 287 | throw new AnalysisError("Recursive dependency in memoized methods"); 288 | } 289 | reorderVisited.add(name); 290 | 291 | const ud = fields.get(name); 292 | if (ud?.type === "user_defined_function" && ud.needMemo) { 293 | for (const depName of preDependencies.get(name) ?? []) { 294 | if (fields.get(depName)?.type === "user_defined_function") { 295 | addReorderedField(depName); 296 | } 297 | } 298 | } 299 | 300 | reorderedFields.set(name, fields.get(name)!); 301 | } 302 | for (const [name] of fields) { 303 | addReorderedField(name); 304 | } 305 | 306 | return { fields: reorderedFields }; 307 | } 308 | 309 | export function postAnalyzeCallbackDependencies( 310 | userDefined: UserDefinedAnalysis, 311 | props: PropsObjAnalysis, 312 | states: StateObjAnalysis, 313 | instanceFields: Map 314 | ) { 315 | for (const [name, prop] of props.props) { 316 | for (const alias of prop.aliases) { 317 | if (alias.owner == null) { 318 | continue; 319 | } 320 | const ownerField = userDefined.fields.get(alias.owner); 321 | if (ownerField?.type !== "user_defined_function") { 322 | continue; 323 | } 324 | ownerField.dependencies.push({ 325 | type: "dep_prop_alias", 326 | name, 327 | }); 328 | } 329 | for (const site of prop.sites) { 330 | if (site.owner == null) { 331 | continue; 332 | } 333 | const ownerField = userDefined.fields.get(site.owner); 334 | if (ownerField?.type !== "user_defined_function") { 335 | continue; 336 | } 337 | 338 | if (site.path.parentPath.isCallExpression()) { 339 | // Special case for `this.props.onClick()`: 340 | // Always try to decompose it to avoid false eslint-plugin-react-hooks exhaustive-deps warning. 341 | site.enabled = true; 342 | } 343 | if (site.enabled) { 344 | ownerField.dependencies.push({ 345 | type: "dep_prop_alias", 346 | name, 347 | }); 348 | } else { 349 | ownerField.dependencies.push({ 350 | type: "dep_prop", 351 | name, 352 | }); 353 | } 354 | } 355 | } 356 | for (const site of props.sites) { 357 | if (site.owner == null || site.child || site.decomposedAsAliases) { 358 | continue; 359 | } 360 | const ownerField = userDefined.fields.get(site.owner); 361 | if (ownerField?.type !== "user_defined_function") { 362 | continue; 363 | } 364 | 365 | ownerField.dependencies.push({ 366 | type: "dep_props_obj", 367 | }); 368 | } 369 | for (const [name, state] of states.states) { 370 | for (const site of state.sites) { 371 | if (site.type !== "expr") { 372 | continue; 373 | } 374 | 375 | if (site.owner == null) { 376 | continue; 377 | } 378 | const ownerField = userDefined.fields.get(site.owner); 379 | if (ownerField?.type !== "user_defined_function") { 380 | continue; 381 | } 382 | ownerField.dependencies.push({ 383 | type: "dep_state", 384 | name, 385 | }); 386 | } 387 | } 388 | for (const [name, field] of instanceFields) { 389 | const ud = userDefined.fields.get(name); 390 | if (ud?.type !== "user_defined_function") { 391 | continue; 392 | } 393 | for (const site of field.sites) { 394 | if (site.type === "expr" && site.owner != null) { 395 | const ownerField = userDefined.fields.get(site.owner); 396 | if (ownerField?.type === "user_defined_function") { 397 | ownerField.dependencies.push({ 398 | type: "dep_function", 399 | name, 400 | }); 401 | } 402 | } 403 | } 404 | } 405 | } 406 | -------------------------------------------------------------------------------- /src/index.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, test } from "@jest/globals"; 2 | import { transform as transformCore } from "@codemod/core"; 3 | import { dedent } from "@qnighy/dedent"; 4 | import plugin from "./index.js"; 5 | 6 | function transform( 7 | code: string, 8 | options: { 9 | ts?: boolean | undefined; 10 | } = {} 11 | ) { 12 | const { ts } = options; 13 | const result = transformCore(code, { 14 | configFile: false, 15 | babelrc: false, 16 | filename: ts ? "file.tsx" : "file.jsx", 17 | parserOpts: { 18 | plugins: ts ? ["jsx", "typescript"] : ["jsx"], 19 | }, 20 | plugins: [plugin], 21 | }); 22 | return result.code; 23 | } 24 | 25 | describe("react-declassify", () => { 26 | it("transforms simple Component class", () => { 27 | const input = dedent`\ 28 | class C extends React.Component { 29 | render() { 30 | return

Hello, world!
; 31 | } 32 | } 33 | `; 34 | const output = dedent`\ 35 | const C = () => { 36 | return
Hello, world!
; 37 | }; 38 | `; 39 | expect(transform(input)).toBe(output); 40 | }); 41 | 42 | describe("TypeScript support", () => { 43 | it("generates React.FC", () => { 44 | const input = dedent`\ 45 | class C extends React.Component { 46 | render() { 47 | return
Hello, world!
; 48 | } 49 | } 50 | `; 51 | const output = dedent`\ 52 | const C: React.FC = () => { 53 | return
Hello, world!
; 54 | }; 55 | `; 56 | expect(transform(input, { ts: true })).toBe(output); 57 | }); 58 | 59 | it("generates FC", () => { 60 | const input = dedent`\ 61 | import { Component } from "react"; 62 | class C extends Component { 63 | render() { 64 | return
Hello, world!
; 65 | } 66 | } 67 | `; 68 | const output = dedent`\ 69 | import { Component, FC } from "react"; 70 | 71 | const C: FC = () => { 72 | return
Hello, world!
; 73 | }; 74 | `; 75 | expect(transform(input, { ts: true })).toBe(output); 76 | }); 77 | 78 | it("transforms first type argument", () => { 79 | const input = dedent`\ 80 | type Props = { 81 | text: string; 82 | }; 83 | class C extends React.Component { 84 | render() { 85 | return
Hello, {this.props.text}!
; 86 | } 87 | } 88 | `; 89 | const output = dedent`\ 90 | type Props = { 91 | text: string; 92 | }; 93 | 94 | const C: React.FC = props => { 95 | return
Hello, {props.text}!
; 96 | }; 97 | `; 98 | expect(transform(input, { ts: true })).toBe(output); 99 | }); 100 | 101 | it("transforms type parameters", () => { 102 | const input = dedent`\ 103 | type Props = { 104 | text: T; 105 | }; 106 | class C extends React.Component> { 107 | render() { 108 | return
Hello, {this.props.text}!
; 109 | } 110 | } 111 | `; 112 | const output = dedent`\ 113 | type Props = { 114 | text: T; 115 | }; 116 | 117 | function C(props: Props): React.ReactElement | null { 118 | return
Hello, {props.text}!
; 119 | } 120 | `; 121 | expect(transform(input, { ts: true })).toBe(output); 122 | }); 123 | }); 124 | 125 | it("doesn't transform empty Component class", () => { 126 | const input = dedent`\ 127 | class C extends React.Component {} 128 | `; 129 | const output = dedent`\ 130 | /* react-declassify-disable Cannot perform transformation: Missing render method */ 131 | class C extends React.Component {} 132 | `; 133 | expect(transform(input)).toBe(output); 134 | }); 135 | 136 | describe("Error handling", () => { 137 | it("adds error message when analysis failed", () => { 138 | const input = dedent`\ 139 | class C extends React.Component { 140 | rende() {} 141 | } 142 | `; 143 | const output = dedent`\ 144 | /* react-declassify-disable Cannot perform transformation: Missing render method */ 145 | class C extends React.Component { 146 | rende() {} 147 | } 148 | `; 149 | expect(transform(input)).toBe(output); 150 | }); 151 | 152 | it("produces soft errors", () => { 153 | const input = dedent`\ 154 | class C extends React.Component { 155 | static defaultProps = { 156 | foo: 42, 157 | }; 158 | render() { 159 | console.log(this.state); 160 | console.log(this.props); 161 | } 162 | 163 | componentWillReceiveProps() { 164 | console.log("foo"); 165 | } 166 | } 167 | `; 168 | const output = dedent`\ 169 | const C = () => { 170 | TODO_this.componentWillReceiveProps = function() { 171 | console.log("foo"); 172 | }; 173 | 174 | console.log(TODO_this.state); 175 | console.log(TODO_this.props); 176 | }; 177 | `; 178 | expect(transform(input)).toBe(output); 179 | }); 180 | }); 181 | 182 | describe("Component detection", () => { 183 | it("transforms Component subclass (named import case)", () => { 184 | const input = dedent`\ 185 | import { Component } from "react"; 186 | class C extends Component { 187 | render() {} 188 | } 189 | `; 190 | const output = dedent`\ 191 | import { Component } from "react"; 192 | const C = () => {}; 193 | `; 194 | expect(transform(input)).toBe(output); 195 | }); 196 | 197 | it("transforms PureComponent subclass", () => { 198 | const input = dedent`\ 199 | import React from "react"; 200 | class C extends React.PureComponent { 201 | render() {} 202 | } 203 | `; 204 | const output = dedent`\ 205 | import React from "react"; 206 | const C = React.memo(function C() {}); 207 | `; 208 | expect(transform(input)).toBe(output); 209 | }); 210 | 211 | it("transforms Component subclass (renamed import case)", () => { 212 | const input = dedent`\ 213 | import { Component as CBase } from "react"; 214 | class C extends CBase { 215 | render() {} 216 | } 217 | `; 218 | const output = dedent`\ 219 | import { Component as CBase } from "react"; 220 | const C = () => {}; 221 | `; 222 | expect(transform(input)).toBe(output); 223 | }); 224 | 225 | it("transforms React.Component subclass (global case)", () => { 226 | const input = dedent`\ 227 | class C extends React.Component { 228 | render() {} 229 | } 230 | `; 231 | const output = dedent`\ 232 | const C = () => {}; 233 | `; 234 | expect(transform(input)).toBe(output); 235 | }); 236 | 237 | it("transforms React.Component subclass (default import case)", () => { 238 | const input = dedent`\ 239 | import React from "react"; 240 | class C extends React.Component { 241 | render() {} 242 | } 243 | `; 244 | const output = dedent`\ 245 | import React from "react"; 246 | const C = () => {}; 247 | `; 248 | expect(transform(input)).toBe(output); 249 | }); 250 | 251 | it("transforms React.Component subclass (namespace import case)", () => { 252 | const input = dedent`\ 253 | import * as React from "react"; 254 | class C extends React.Component { 255 | render() {} 256 | } 257 | `; 258 | const output = dedent`\ 259 | import * as React from "react"; 260 | const C = () => {}; 261 | `; 262 | expect(transform(input)).toBe(output); 263 | }); 264 | 265 | it("transforms React.Component subclass (renamed default import case)", () => { 266 | const input = dedent`\ 267 | import MyReact from "react"; 268 | class C extends MyReact.Component { 269 | render() {} 270 | } 271 | `; 272 | const output = dedent`\ 273 | import MyReact from "react"; 274 | const C = () => {}; 275 | `; 276 | expect(transform(input)).toBe(output); 277 | }); 278 | 279 | it("ignores plain classes", () => { 280 | const input = dedent`\ 281 | class C { 282 | render() {} 283 | } 284 | `; 285 | expect(transform(input)).toBe(input); 286 | }); 287 | 288 | it("ignores complex inheritance", () => { 289 | const input = dedent`\ 290 | class C extends mixin() { 291 | render() {} 292 | } 293 | `; 294 | expect(transform(input)).toBe(input); 295 | }); 296 | 297 | it("ignores non-Component subclass (named import case)", () => { 298 | const input = dedent`\ 299 | import { Componen } from "react"; 300 | class C extends Componen { 301 | render() {} 302 | } 303 | `; 304 | expect(transform(input)).toBe(input); 305 | }); 306 | 307 | it("ignores non-Component subclass (renamed import case)", () => { 308 | const input = dedent`\ 309 | import { Componen as Component } from "react"; 310 | class C extends Component { 311 | render() {} 312 | } 313 | `; 314 | expect(transform(input)).toBe(input); 315 | }); 316 | 317 | it("ignores non-Component subclass (global case)", () => { 318 | const input = dedent`\ 319 | class C extends React.Componen { 320 | render() {} 321 | } 322 | `; 323 | expect(transform(input)).toBe(input); 324 | }); 325 | 326 | it("ignores non-Component subclass (default import case)", () => { 327 | const input = dedent`\ 328 | import React from "react"; 329 | class C extends React.Componen { 330 | render() {} 331 | } 332 | `; 333 | expect(transform(input)).toBe(input); 334 | }); 335 | 336 | it("ignores non-Component subclass (namespace import case)", () => { 337 | const input = dedent`\ 338 | import * as React from "react"; 339 | class C extends React.Componen { 340 | render() {} 341 | } 342 | `; 343 | expect(transform(input)).toBe(input); 344 | }); 345 | 346 | it("ignores non-React subclass (non-react import case)", () => { 347 | const input = dedent`\ 348 | import React from "reeeeact"; 349 | class C extends React.Component { 350 | render() {} 351 | } 352 | `; 353 | expect(transform(input)).toBe(input); 354 | }); 355 | 356 | describe("opt-out", () => { 357 | it("ignores if marked as react-declassify-disable", () => { 358 | const input = dedent`\ 359 | /* react-declassify-disable */ 360 | class C extends React.Component { 361 | render() {} 362 | } 363 | `; 364 | expect(transform(input)).toBe(input); 365 | }); 366 | 367 | it("ignores if marked as abstract", () => { 368 | const input = dedent`\ 369 | abstract class C extends React.Component { 370 | render() {} 371 | } 372 | `; 373 | expect(transform(input, { ts: true })).toBe(input); 374 | }); 375 | 376 | it("ignores if marked as @abstract", () => { 377 | const input = dedent`\ 378 | /** @abstract */ 379 | class C extends React.Component { 380 | render() {} 381 | } 382 | `; 383 | expect(transform(input)).toBe(input); 384 | }); 385 | }); 386 | }); 387 | 388 | describe("Class forms", () => { 389 | it("transforms a simple class declaration", () => { 390 | const input = dedent`\ 391 | class C extends React.Component { 392 | render() { 393 | return
Hello, world!
; 394 | } 395 | } 396 | `; 397 | const output = dedent`\ 398 | const C = () => { 399 | return
Hello, world!
; 400 | }; 401 | `; 402 | expect(transform(input)).toBe(output); 403 | }); 404 | 405 | it("transforms a class declaration within export default (named case)", () => { 406 | const input = dedent`\ 407 | export default class C extends React.Component { 408 | render() { 409 | return
Hello, world!
; 410 | } 411 | } 412 | `; 413 | const output = dedent`\ 414 | const C = () => { 415 | return
Hello, world!
; 416 | }; 417 | 418 | export default C; 419 | `; 420 | expect(transform(input)).toBe(output); 421 | }); 422 | 423 | it("transforms a class declaration within export default (anonymous case)", () => { 424 | const input = dedent`\ 425 | export default class extends React.Component { 426 | render() { 427 | return
Hello, world!
; 428 | } 429 | } 430 | `; 431 | const output = dedent`\ 432 | export default () => { 433 | return
Hello, world!
; 434 | }; 435 | `; 436 | expect(transform(input)).toBe(output); 437 | }); 438 | }); 439 | 440 | describe("Render function transformation", () => { 441 | it("Renames local variables to avoid capturing", () => { 442 | const input = dedent`\ 443 | class C extends React.Component { 444 | render() { 445 | const x = 42; 446 | this.foo(100); 447 | return x; 448 | } 449 | 450 | foo() { 451 | return x + 42; 452 | } 453 | } 454 | `; 455 | const output = dedent`\ 456 | const C = () => { 457 | function foo() { 458 | return x + 42; 459 | } 460 | 461 | const x0 = 42; 462 | foo(100); 463 | return x0; 464 | }; 465 | `; 466 | expect(transform(input)).toBe(output); 467 | }); 468 | }); 469 | 470 | describe("Method transformation", () => { 471 | it("transforms methods as functions", () => { 472 | const input = dedent`\ 473 | class C extends React.Component { 474 | render() { 475 | this.foo(100); 476 | return null; 477 | } 478 | 479 | foo(x) { 480 | return x + 42; 481 | } 482 | } 483 | `; 484 | const output = dedent`\ 485 | const C = () => { 486 | function foo(x) { 487 | return x + 42; 488 | } 489 | 490 | foo(100); 491 | return null; 492 | }; 493 | `; 494 | expect(transform(input)).toBe(output); 495 | }); 496 | 497 | it("transforms functional fields as functions", () => { 498 | const input = dedent`\ 499 | class C extends React.Component { 500 | render() { 501 | this.foo(100); 502 | return null; 503 | } 504 | 505 | foo = (x) => x + 42; 506 | bar = (x) => { 507 | return x + 42; 508 | }; 509 | baz = function(x) { 510 | return x + 42; 511 | }; 512 | } 513 | `; 514 | const output = dedent`\ 515 | const C = () => { 516 | const foo = (x) => x + 42; 517 | 518 | const bar = (x) => { 519 | return x + 42; 520 | }; 521 | 522 | function baz(x) { 523 | return x + 42; 524 | } 525 | 526 | foo(100); 527 | return null; 528 | }; 529 | `; 530 | expect(transform(input)).toBe(output); 531 | }); 532 | 533 | it("renames methods if necessary (toplevel capturing)", () => { 534 | const input = dedent`\ 535 | class C extends React.Component { 536 | render() { 537 | const foo = this.foo(100); 538 | return null; 539 | } 540 | 541 | foo(x) { 542 | return x + 42; 543 | } 544 | } 545 | `; 546 | const output = dedent`\ 547 | const C = () => { 548 | function foo0(x) { 549 | return x + 42; 550 | } 551 | 552 | const foo = foo0(100); 553 | return null; 554 | }; 555 | `; 556 | expect(transform(input)).toBe(output); 557 | }); 558 | 559 | it("renames methods if necessary (inner capturing)", () => { 560 | const input = dedent`\ 561 | class C extends React.Component { 562 | render() { 563 | if (true) { 564 | const foo = this.foo(100); 565 | } 566 | return null; 567 | } 568 | 569 | foo(x) { 570 | return x + 42; 571 | } 572 | } 573 | `; 574 | const output = dedent`\ 575 | const C = () => { 576 | function foo0(x) { 577 | return x + 42; 578 | } 579 | 580 | if (true) { 581 | const foo = foo0(100); 582 | } 583 | return null; 584 | }; 585 | `; 586 | expect(transform(input)).toBe(output); 587 | }); 588 | 589 | it("transforms this.props in methods", () => { 590 | const input = dedent`\ 591 | class C extends React.Component { 592 | render() { 593 | this.foo(); 594 | return null; 595 | } 596 | 597 | foo() { 598 | return this.props.foo + 42; 599 | } 600 | } 601 | `; 602 | const output = dedent`\ 603 | const C = props => { 604 | function foo() { 605 | return props.foo + 42; 606 | } 607 | 608 | foo(); 609 | return null; 610 | }; 611 | `; 612 | expect(transform(input)).toBe(output); 613 | }); 614 | 615 | it("hoists this.props expansion", () => { 616 | const input = dedent`\ 617 | class C extends React.Component { 618 | render() { 619 | const { bar, baz } = this.props; 620 | const baz2 = this.props.baz; 621 | return this.meth() + bar + baz + baz2; 622 | } 623 | 624 | meth() { 625 | const { foo, bar } = this.props; 626 | return foo + bar; 627 | } 628 | } 629 | `; 630 | const output = dedent`\ 631 | const C = props => { 632 | const { 633 | bar, 634 | baz, 635 | foo 636 | } = props; 637 | 638 | function meth() { 639 | return foo + bar; 640 | } 641 | 642 | return meth() + bar + baz + baz; 643 | }; 644 | `; 645 | expect(transform(input)).toBe(output); 646 | }); 647 | 648 | it("Transforms defaultProps", () => { 649 | const input = dedent`\ 650 | class C extends React.Component { 651 | static defaultProps = { 652 | foo: 42, 653 | quux: 0, 654 | }; 655 | render() { 656 | const { foo, bar } = this.props; 657 | return foo + bar + this.props.baz + this.props.quux; 658 | } 659 | } 660 | `; 661 | const output = dedent`\ 662 | const C = props => { 663 | const { 664 | foo = 42, 665 | bar, 666 | baz, 667 | quux = 0 668 | } = props; 669 | return foo + bar + baz + quux; 670 | }; 671 | `; 672 | expect(transform(input)).toBe(output); 673 | }); 674 | 675 | it("Transforms types for defaultProps", () => { 676 | const input = dedent`\ 677 | type Props = { 678 | foo: number; 679 | bar: number; 680 | baz: number; 681 | quux: number; 682 | }; 683 | class C extends React.Component { 684 | static defaultProps = { 685 | foo: 42, 686 | quux: 0, 687 | }; 688 | render() { 689 | const { foo, bar } = this.props; 690 | return foo + bar + this.props.baz + this.props.quux; 691 | } 692 | } 693 | `; 694 | const output = dedent`\ 695 | type Props = { 696 | foo?: number | undefined 697 | bar: number; 698 | baz: number; 699 | quux?: number | undefined 700 | }; 701 | 702 | const C: React.FC = props => { 703 | const { 704 | foo = 42, 705 | bar, 706 | baz, 707 | quux = 0 708 | } = props; 709 | return foo + bar + baz + quux; 710 | }; 711 | `; 712 | expect(transform(input, { ts: true })).toBe(output); 713 | }); 714 | 715 | it("transforms method types", () => { 716 | const input = dedent`\ 717 | class C extends React.Component { 718 | render() { 719 | return null; 720 | } 721 | 722 | foo(x: number): number { 723 | return x + 42; 724 | } 725 | 726 | bar: MyHandler = (x) => { 727 | return x + 42; 728 | } 729 | } 730 | `; 731 | const output = dedent`\ 732 | const C: React.FC = () => { 733 | function foo(x: number): number { 734 | return x + 42; 735 | } 736 | 737 | const bar: MyHandler = (x) => { 738 | return x + 42; 739 | }; 740 | 741 | return null; 742 | }; 743 | `; 744 | expect(transform(input, { ts: true })).toBe(output); 745 | }); 746 | 747 | it("memoizes methods if necessary", () => { 748 | const input = dedent`\ 749 | class C extends React.Component { 750 | render() { 751 | this.bar(); 752 | return
; 753 | } 754 | 755 | foo = () => { 756 | this.baz(); 757 | }; 758 | 759 | bar = () => { 760 | this.baz(); 761 | }; 762 | 763 | baz = () => { 764 | const { callbackB, text } = this.props; 765 | this.props.callbackA(); 766 | callbackB(text); 767 | }; 768 | } 769 | `; 770 | const output = dedent`\ 771 | const C = props => { 772 | const { 773 | callbackB, 774 | text, 775 | callbackA 776 | } = props; 777 | 778 | const baz = React.useCallback(() => { 779 | callbackA(); 780 | callbackB(text); 781 | }, [callbackA, callbackB, text]); 782 | 783 | const foo = React.useCallback(() => { 784 | baz(); 785 | }, [baz]); 786 | 787 | const bar = () => { 788 | baz(); 789 | }; 790 | 791 | bar(); 792 | return
; 793 | }; 794 | `; 795 | expect(transform(input)).toBe(output); 796 | }); 797 | 798 | it("transforms binding initialization", () => { 799 | const input = dedent`\ 800 | class C extends React.Component { 801 | constructor(props) { 802 | super(props); 803 | this.foo = this.foo.bind(this); 804 | } 805 | 806 | render() { 807 | return ( 808 | 812 | ); 813 | } 814 | 815 | foo() { 816 | console.log("foo"); 817 | } 818 | 819 | bar() { 820 | console.log("bar"); 821 | } 822 | } 823 | `; 824 | const output = dedent`\ 825 | const C = () => { 826 | const foo = React.useCallback(function foo() { 827 | console.log("foo"); 828 | }, []); 829 | 830 | const bar = React.useCallback(function bar() { 831 | console.log("bar"); 832 | }, []); 833 | 834 | return ( 835 | 839 | ); 840 | }; 841 | `; 842 | expect(transform(input)).toBe(output); 843 | }); 844 | }); 845 | 846 | describe("State transformation", () => { 847 | it("transforms simple states", () => { 848 | const input = dedent`\ 849 | class C extends React.Component { 850 | state = { 851 | foo: 1, 852 | bar: 2, 853 | }; 854 | render() { 855 | return ; 856 | } 857 | } 858 | `; 859 | const output = dedent`\ 860 | const C = () => { 861 | const [foo, setFoo] = React.useState(1); 862 | const [bar, setBar] = React.useState(2); 863 | return ; 864 | }; 865 | `; 866 | expect(transform(input)).toBe(output); 867 | }); 868 | 869 | it("transforms multi-value assignments", () => { 870 | const input = dedent`\ 871 | class C extends React.Component { 872 | render() { 873 | return ; 913 | } 914 | } 915 | `; 916 | const output = dedent`\ 917 | type Props = {}; 918 | type State = { 919 | foo: number, 920 | bar: number, 921 | }; 922 | 923 | const C: React.FC = () => { 924 | const [foo, setFoo] = React.useState(1); 925 | const [bar, setBar] = React.useState(2); 926 | return ; 927 | }; 928 | `; 929 | expect(transform(input, { ts: true })).toBe(output); 930 | }); 931 | 932 | it("transforms state types (interface)", () => { 933 | const input = dedent`\ 934 | interface Props {} 935 | interface State { 936 | foo: number, 937 | bar: number, 938 | } 939 | class C extends React.Component { 940 | state = { 941 | foo: 1, 942 | bar: 2, 943 | }; 944 | render() { 945 | return ; 946 | } 947 | } 948 | `; 949 | const output = dedent`\ 950 | interface Props {} 951 | interface State { 952 | foo: number, 953 | bar: number, 954 | } 955 | 956 | const C: React.FC = () => { 957 | const [foo, setFoo] = React.useState(1); 958 | const [bar, setBar] = React.useState(2); 959 | return ; 960 | }; 961 | `; 962 | expect(transform(input, { ts: true })).toBe(output); 963 | }); 964 | 965 | it("transforms state decomposition", () => { 966 | const input = dedent`\ 967 | class C extends React.Component { 968 | render() { 969 | const { foo, bar } = this.state; 970 | return foo + bar; 971 | } 972 | } 973 | `; 974 | const output = dedent`\ 975 | const C = () => { 976 | const [foo, setFoo] = React.useState(); 977 | const [bar, setBar] = React.useState(); 978 | return foo + bar; 979 | }; 980 | `; 981 | expect(transform(input)).toBe(output); 982 | }); 983 | 984 | it("transforms setState in constructor", () => { 985 | const input = dedent`\ 986 | class C extends React.Component { 987 | constructor(props) { 988 | super(props); 989 | this.reset = () => { 990 | this.setState({ foo: 42 }); 991 | }; 992 | } 993 | render() { 994 | } 995 | } 996 | `; 997 | const output = dedent`\ 998 | const C = () => { 999 | const [foo, setFoo] = React.useState(); 1000 | 1001 | const reset = () => { 1002 | setFoo(42); 1003 | }; 1004 | }; 1005 | `; 1006 | expect(transform(input)).toBe(output); 1007 | }); 1008 | }); 1009 | 1010 | describe("Ref transformation", () => { 1011 | it("transforms createRef as useRef", () => { 1012 | const input = dedent`\ 1013 | class C extends React.Component { 1014 | constructor(props) { 1015 | super(props); 1016 | this.div = React.createRef(); 1017 | } 1018 | 1019 | foo() { 1020 | console.log(this.div.current); 1021 | } 1022 | 1023 | render() { 1024 | return
; 1025 | } 1026 | } 1027 | `; 1028 | const output = dedent`\ 1029 | const C = () => { 1030 | function foo() { 1031 | console.log(div.current); 1032 | } 1033 | 1034 | const div = React.useRef(null); 1035 | return
; 1036 | }; 1037 | `; 1038 | expect(transform(input)).toBe(output); 1039 | }); 1040 | 1041 | it("transforms typed createRef as useRef", () => { 1042 | const input = dedent`\ 1043 | class C extends React.Component { 1044 | button: React.RefObject 1045 | constructor(props) { 1046 | super(props); 1047 | this.div = React.createRef(); 1048 | this.button = React.createRef(); 1049 | } 1050 | 1051 | foo() { 1052 | console.log(this.div.current); 1053 | } 1054 | 1055 | render() { 1056 | return
; 1057 | } 1058 | } 1059 | `; 1060 | const output = dedent`\ 1061 | const C: React.FC = () => { 1062 | const button = React.useRef(null); 1063 | 1064 | function foo() { 1065 | console.log(div.current); 1066 | } 1067 | 1068 | const div = React.useRef(null); 1069 | return
; 1070 | }; 1071 | `; 1072 | expect(transform(input, { ts: true })).toBe(output); 1073 | }); 1074 | 1075 | it("transforms class field as useRef", () => { 1076 | const input = dedent`\ 1077 | class C extends React.Component { 1078 | constructor(props) { 1079 | super(props); 1080 | this.div = null; 1081 | } 1082 | 1083 | foo() { 1084 | console.log(this.div); 1085 | } 1086 | 1087 | render() { 1088 | return
this.div = elem} />; 1089 | } 1090 | } 1091 | `; 1092 | const output = dedent`\ 1093 | const C = () => { 1094 | function foo() { 1095 | console.log(div.current); 1096 | } 1097 | 1098 | const div = React.useRef(null); 1099 | return
div.current = elem} />; 1100 | }; 1101 | `; 1102 | expect(transform(input)).toBe(output); 1103 | }); 1104 | 1105 | it("transforms class field without initializer as useRef", () => { 1106 | const input = dedent`\ 1107 | class C extends React.Component { 1108 | constructor(props) { 1109 | super(props); 1110 | } 1111 | 1112 | foo() { 1113 | console.log(this.div); 1114 | } 1115 | 1116 | render() { 1117 | return
this.div = elem} />; 1118 | } 1119 | } 1120 | `; 1121 | const output = dedent`\ 1122 | const C = () => { 1123 | function foo() { 1124 | console.log(div.current); 1125 | } 1126 | 1127 | const div = React.useRef(undefined); 1128 | return
div.current = elem} />; 1129 | }; 1130 | `; 1131 | expect(transform(input)).toBe(output); 1132 | }); 1133 | 1134 | it("transforms typed class field as useRef", () => { 1135 | const input = dedent`\ 1136 | class C extends React.Component { 1137 | div: HTMLDivElement | null; 1138 | constructor(props) { 1139 | super(props); 1140 | this.div = null; 1141 | } 1142 | 1143 | foo() { 1144 | console.log(this.div); 1145 | } 1146 | 1147 | render() { 1148 | return
this.div = elem} />; 1149 | } 1150 | } 1151 | `; 1152 | const output = dedent`\ 1153 | const C: React.FC = () => { 1154 | const div = React.useRef(null); 1155 | 1156 | function foo() { 1157 | console.log(div.current); 1158 | } 1159 | 1160 | return
div.current = elem} />; 1161 | }; 1162 | `; 1163 | expect(transform(input, { ts: true })).toBe(output); 1164 | }); 1165 | 1166 | it("transforms ref initializer", () => { 1167 | const input = dedent`\ 1168 | class C extends React.Component { 1169 | counter = 42 1170 | 1171 | foo() { 1172 | console.log(this.counter++); 1173 | } 1174 | 1175 | render() { 1176 | return null; 1177 | } 1178 | } 1179 | `; 1180 | const output = dedent`\ 1181 | const C = () => { 1182 | const counter = React.useRef(42); 1183 | 1184 | function foo() { 1185 | console.log(counter.current++); 1186 | } 1187 | 1188 | return null; 1189 | }; 1190 | `; 1191 | expect(transform(input)).toBe(output); 1192 | }); 1193 | }); 1194 | 1195 | it("transforms props", () => { 1196 | const input = dedent`\ 1197 | class C extends React.Component { 1198 | render() { 1199 | return
Hello, {this.props.name}!
; 1200 | } 1201 | } 1202 | `; 1203 | const output = dedent`\ 1204 | const C = props => { 1205 | return
Hello, {props.name}!
; 1206 | }; 1207 | `; 1208 | expect(transform(input)).toBe(output); 1209 | }); 1210 | 1211 | describe("constructor support", () => { 1212 | it("transforms state in constructor", () => { 1213 | const input = dedent`\ 1214 | class C extends React.Component { 1215 | constructor(props) { 1216 | super(props); 1217 | this.state = { 1218 | foo: 1, 1219 | bar: 2, 1220 | }; 1221 | } 1222 | render() { 1223 | return ; 1224 | } 1225 | } 1226 | `; 1227 | const output = dedent`\ 1228 | const C = () => { 1229 | const [foo, setFoo] = React.useState(1); 1230 | const [bar, setBar] = React.useState(2); 1231 | return ; 1232 | }; 1233 | `; 1234 | expect(transform(input)).toBe(output); 1235 | }); 1236 | }); 1237 | 1238 | describe("Memoization", () => { 1239 | it("transforms PureComponent to React.memo", () => { 1240 | const input = dedent`\ 1241 | class C extends React.PureComponent { 1242 | render() { 1243 | return
Hello, world!
; 1244 | } 1245 | } 1246 | `; 1247 | const output = dedent`\ 1248 | const C = React.memo(function C() { 1249 | return
Hello, world!
; 1250 | }); 1251 | `; 1252 | expect(transform(input)).toBe(output); 1253 | }); 1254 | 1255 | it("Places types on const", () => { 1256 | const input = dedent`\ 1257 | class C extends React.PureComponent { 1258 | render() { 1259 | return
Hello, world!
; 1260 | } 1261 | } 1262 | `; 1263 | const output = dedent`\ 1264 | const C: React.FC = React.memo(function C() { 1265 | return
Hello, world!
; 1266 | }); 1267 | `; 1268 | expect(transform(input, { ts: true })).toBe(output); 1269 | }); 1270 | }); 1271 | 1272 | describe("Effects", () => { 1273 | it("transforms raw effects", () => { 1274 | const input = dedent`\ 1275 | class C extends React.Component { 1276 | componentDidMount() { 1277 | console.log("mounted"); 1278 | } 1279 | componentDidUpdate() { 1280 | console.log("updated"); 1281 | } 1282 | componentWillUnmount() { 1283 | console.log("unmounting"); 1284 | } 1285 | render() { 1286 | return null; 1287 | } 1288 | } 1289 | `; 1290 | const output = dedent`\ 1291 | const C = () => { 1292 | const isMounted = React.useRef(false); 1293 | 1294 | // TODO(react-declassify): refactor this effect (automatically generated from lifecycle) 1295 | React.useEffect(() => { 1296 | if (!isMounted.current) { 1297 | isMounted.current = true; 1298 | console.log("mounted"); 1299 | } else { 1300 | console.log("updated"); 1301 | } 1302 | }); 1303 | 1304 | const cleanup = React.useRef(null); 1305 | 1306 | cleanup.current = () => { 1307 | console.log("unmounting"); 1308 | }; 1309 | 1310 | // TODO(react-declassify): refactor this effect (automatically generated from lifecycle) 1311 | React.useEffect(() => { 1312 | return () => { 1313 | if (isMounted.current) { 1314 | isMounted.current = false; 1315 | cleanup.current?.(); 1316 | } 1317 | }; 1318 | }, []); 1319 | 1320 | return null; 1321 | }; 1322 | `; 1323 | expect(transform(input)).toBe(output); 1324 | }); 1325 | }); 1326 | 1327 | test("readme example 1", () => { 1328 | const input = dedent`\ 1329 | import React from "react"; 1330 | 1331 | type Props = { 1332 | by: number; 1333 | }; 1334 | 1335 | type State = { 1336 | counter: number; 1337 | }; 1338 | 1339 | export class C extends React.Component { 1340 | static defaultProps = { 1341 | by: 1 1342 | }; 1343 | 1344 | constructor(props) { 1345 | super(props); 1346 | this.state = { 1347 | counter: 0 1348 | }; 1349 | } 1350 | 1351 | render() { 1352 | return ( 1353 | <> 1354 | 1357 |

Current step: {this.props.by}

1358 | 1359 | ); 1360 | } 1361 | 1362 | onClick() { 1363 | this.setState({ counter: this.state.counter + this.props.by }); 1364 | } 1365 | } 1366 | `; 1367 | const output = dedent`\ 1368 | import React from "react"; 1369 | 1370 | type Props = { 1371 | by?: number | undefined 1372 | }; 1373 | 1374 | type State = { 1375 | counter: number; 1376 | }; 1377 | 1378 | export const C: React.FC = props => { 1379 | const { 1380 | by = 1 1381 | } = props; 1382 | 1383 | const [counter, setCounter] = React.useState(0); 1384 | 1385 | function onClick() { 1386 | setCounter(counter + by); 1387 | } 1388 | 1389 | return <> 1390 | 1393 |

Current step: {by}

1394 | ; 1395 | }; 1396 | `; 1397 | expect(transform(input, { ts: true })).toBe(output); 1398 | }); 1399 | 1400 | test("readme example 2", () => { 1401 | const input = dedent`\ 1402 | import React from "react"; 1403 | 1404 | export class C extends React.Component { 1405 | render() { 1406 | const { text, color } = this.props; 1407 | return ; 1408 | } 1409 | 1410 | onClick() { 1411 | const { text, handleClick } = this.props; 1412 | alert(\`\${text} was clicked!\`); 1413 | handleClick(); 1414 | } 1415 | } 1416 | `; 1417 | const output = dedent`\ 1418 | import React from "react"; 1419 | 1420 | export const C = props => { 1421 | const { 1422 | text, 1423 | color, 1424 | handleClick 1425 | } = props; 1426 | 1427 | function onClick() { 1428 | alert(\`\${text} was clicked!\`); 1429 | handleClick(); 1430 | } 1431 | 1432 | return ; 1433 | }; 1434 | `; 1435 | expect(transform(input)).toBe(output); 1436 | }); 1437 | }); 1438 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | ArrowFunctionExpression, 3 | ClassMethod, 4 | ClassPrivateMethod, 5 | Comment, 6 | Expression, 7 | FunctionDeclaration, 8 | FunctionExpression, 9 | Identifier, 10 | ImportDeclaration, 11 | MemberExpression, 12 | Node, 13 | ObjectMethod, 14 | Pattern, 15 | RestElement, 16 | Statement, 17 | TSEntityName, 18 | TSType, 19 | TSTypeAnnotation, 20 | TSTypeParameterDeclaration, 21 | VariableDeclaration, 22 | } from "@babel/types"; 23 | import type { NodePath, PluginObj, PluginPass } from "@babel/core"; 24 | import { 25 | assignReturnType, 26 | assignTypeAnnotation, 27 | assignTypeArguments, 28 | assignTypeParameters, 29 | importName, 30 | isClassMethodLike, 31 | isClassPropertyLike, 32 | isTS, 33 | memberFromDecl, 34 | nonNullPath, 35 | } from "./utils.js"; 36 | import { 37 | AnalysisError, 38 | analyzeClass, 39 | preanalyzeClass, 40 | AnalysisResult, 41 | needsProps, 42 | LibRef, 43 | needAlias, 44 | SetStateFieldSite, 45 | SoftErrorRepository, 46 | } from "./analysis.js"; 47 | 48 | // eslint-disable-next-line @typescript-eslint/ban-types 49 | type Options = {}; 50 | 51 | export default function plugin( 52 | babel: typeof import("@babel/core") 53 | ): PluginObj { 54 | const { types: t } = babel; 55 | return { 56 | name: "react-declassify", 57 | visitor: { 58 | ClassDeclaration(path, state) { 59 | const ts = isTS(state); 60 | const preanalysis = preanalyzeClass(path); 61 | if (!preanalysis) { 62 | return; 63 | } 64 | const softErrors = new SoftErrorRepository(); 65 | if (path.parentPath.isExportDefaultDeclaration()) { 66 | const declPath = path.parentPath; 67 | try { 68 | const analysis = analyzeClass(path, preanalysis, softErrors); 69 | const { funcNode, typeNode } = transformClass( 70 | analysis, 71 | softErrors, 72 | { ts }, 73 | babel 74 | ); 75 | if (path.node.id) { 76 | // Necessary to avoid false error regarding duplicate declaration. 77 | path.scope.removeBinding(path.node.id.name); 78 | declPath.replaceWithMultiple([ 79 | constDeclaration( 80 | babel, 81 | t.cloneNode(path.node.id), 82 | funcNode, 83 | typeNode ? t.tsTypeAnnotation(typeNode) : undefined 84 | ), 85 | t.exportDefaultDeclaration(t.cloneNode(path.node.id)), 86 | ]); 87 | } else { 88 | path.replaceWith(funcNode); 89 | } 90 | } catch (e) { 91 | if (!(e instanceof AnalysisError)) { 92 | throw e; 93 | } 94 | t.addComment( 95 | declPath.node, 96 | "leading", 97 | ` react-declassify-disable Cannot perform transformation: ${e.message} ` 98 | ); 99 | refreshComments(declPath.node); 100 | } 101 | } else { 102 | try { 103 | const analysis = analyzeClass(path, preanalysis, softErrors); 104 | const { funcNode, typeNode } = transformClass( 105 | analysis, 106 | softErrors, 107 | { ts }, 108 | babel 109 | ); 110 | // Necessary to avoid false error regarding duplicate declaration. 111 | path.scope.removeBinding(path.node.id.name); 112 | path.replaceWith( 113 | constDeclaration( 114 | babel, 115 | t.cloneNode(path.node.id), 116 | funcNode, 117 | typeNode ? t.tsTypeAnnotation(typeNode) : undefined 118 | ) 119 | ); 120 | } catch (e) { 121 | if (!(e instanceof AnalysisError)) { 122 | throw e; 123 | } 124 | t.addComment( 125 | path.node, 126 | "leading", 127 | ` react-declassify-disable Cannot perform transformation: ${e.message} ` 128 | ); 129 | refreshComments(path.node); 130 | } 131 | } 132 | }, 133 | }, 134 | }; 135 | } 136 | 137 | type TransformResult = { 138 | funcNode: Expression; 139 | typeNode?: TSType | undefined; 140 | }; 141 | 142 | function transformClass( 143 | analysis: AnalysisResult, 144 | softErrors: SoftErrorRepository, 145 | options: { ts: boolean }, 146 | babel: typeof import("@babel/core") 147 | ): TransformResult { 148 | const { types: t } = babel; 149 | const { ts } = options; 150 | 151 | for (const [, prop] of analysis.props.props) { 152 | for (const alias of prop.aliases) { 153 | if (alias.localName !== prop.newAliasName!) { 154 | // Rename variables that props are bound to. 155 | // E.g. `foo` as in `const { foo } = this.props`. 156 | // This is to ensure we hoist them correctly. 157 | alias.scope.rename(alias.localName, prop.newAliasName); 158 | } 159 | } 160 | } 161 | for (const path of analysis.locals.removePaths) { 162 | path.remove(); 163 | } 164 | for (const ren of analysis.render.renames) { 165 | // Rename local variables in the render method 166 | // to avoid unintentional variable capturing. 167 | ren.scope.rename(ren.oldName, ren.newName); 168 | } 169 | for (const [, prop] of analysis.props.props) { 170 | for (const site of prop.sites) { 171 | if (site.enabled) { 172 | // this.props.foo -> foo 173 | site.path.replaceWith(t.identifier(prop.newAliasName!)); 174 | } 175 | } 176 | } 177 | for (const site of analysis.props.sites) { 178 | if (!site.child?.enabled) { 179 | // this.props -> props 180 | site.path.replaceWith(site.path.node.property); 181 | } 182 | } 183 | for (const [, prop] of analysis.props.props) { 184 | if (prop.defaultValue && prop.typing) { 185 | // Make the prop optional 186 | prop.typing.node.optional = true; 187 | if (prop.typing.isTSPropertySignature()) { 188 | const typeAnnotation = nonNullPath( 189 | prop.typing.get("typeAnnotation") 190 | )?.get("typeAnnotation"); 191 | if (typeAnnotation) { 192 | if (typeAnnotation.isTSUnionType()) { 193 | if ( 194 | typeAnnotation.node.types.some( 195 | (t) => t.type === "TSUndefinedKeyword" 196 | ) 197 | ) { 198 | // No need to add undefined 199 | } else { 200 | typeAnnotation.node.types.push(t.tsUndefinedKeyword()); 201 | } 202 | } else { 203 | typeAnnotation.replaceWith( 204 | t.tsUnionType([typeAnnotation.node, t.tsUndefinedKeyword()]) 205 | ); 206 | } 207 | } 208 | } 209 | } 210 | } 211 | for (const [, stateAnalysis] of analysis.state.states) { 212 | for (const site of stateAnalysis.sites) { 213 | if (site.type === "expr") { 214 | // this.state.foo -> foo 215 | site.path.replaceWith(t.identifier(stateAnalysis.localName!)); 216 | } 217 | } 218 | } 219 | for (const site of analysis.state.setStateSites) { 220 | function setter(field: SetStateFieldSite) { 221 | // this.setState({ foo: 1 }) -> setFoo(1) 222 | return t.callExpression( 223 | t.identifier(analysis.state.states.get(field.name)!.localSetterName!), 224 | [field.valuePath.node] 225 | ); 226 | } 227 | if (site.fields.length === 1) { 228 | const field = site.fields[0]!; 229 | site.path.replaceWith(setter(field)); 230 | } else if (site.path.parentPath.isExpressionStatement()) { 231 | site.path.parentPath.replaceWithMultiple( 232 | site.fields.map((field) => t.expressionStatement(setter(field))) 233 | ); 234 | } else if (site.fields.length === 0) { 235 | site.path.replaceWith(t.nullLiteral()); 236 | } else { 237 | site.path.replaceWith(t.sequenceExpression(site.fields.map(setter))); 238 | } 239 | } 240 | for (const [, field] of analysis.userDefined.fields) { 241 | if ( 242 | field.type === "user_defined_function" || 243 | field.type === "user_defined_ref" 244 | ) { 245 | for (const site of field.sites) { 246 | if (site.type === "expr") { 247 | // this.foo -> foo 248 | site.path.replaceWith(t.identifier(field.localName!)); 249 | } 250 | } 251 | } else if (field.type === "user_defined_direct_ref") { 252 | for (const site of field.sites) { 253 | if (site.type === "expr") { 254 | // this.foo -> foo.current 255 | site.path.replaceWith( 256 | t.memberExpression( 257 | t.identifier(field.localName!), 258 | t.identifier("current") 259 | ) 260 | ); 261 | } 262 | } 263 | } 264 | } 265 | for (const bindThisSite of analysis.bindThisSites) { 266 | if (bindThisSite.bindsMore) { 267 | bindThisSite.thisArgPath.replaceWith(t.nullLiteral()); 268 | } else { 269 | bindThisSite.binderPath.replaceWith(bindThisSite.bindeePath.node); 270 | } 271 | } 272 | 273 | // Soft error handling 274 | for (const softError of softErrors.errors) { 275 | if (softError.type === "invalid_this") { 276 | // this -> TODO_this 277 | softError.path.replaceWith(t.identifier("TODO_this")); 278 | } 279 | } 280 | 281 | // Preamble is a set of statements to be added before the original render body. 282 | const preamble: Statement[] = []; 283 | const propsWithAlias = Array.from(analysis.props.props).filter(([, prop]) => 284 | needAlias(prop) 285 | ); 286 | if (propsWithAlias.length > 0) { 287 | // Expand this.props into variables. 288 | // E.g. const { foo, bar } = props; 289 | preamble.push( 290 | t.variableDeclaration("const", [ 291 | t.variableDeclarator( 292 | t.objectPattern( 293 | propsWithAlias.map(([name, prop]) => 294 | t.objectProperty( 295 | t.identifier(name), 296 | prop.defaultValue 297 | ? t.assignmentPattern( 298 | t.identifier(prop.newAliasName!), 299 | prop.defaultValue.node 300 | ) 301 | : t.identifier(prop.newAliasName!), 302 | false, 303 | name === prop.newAliasName! 304 | ) 305 | ) 306 | ), 307 | t.identifier("props") 308 | ), 309 | ]) 310 | ); 311 | } 312 | for (const field of analysis.state.states.values()) { 313 | // State declarations 314 | const call = t.callExpression( 315 | getReactImport("useState", babel, analysis.superClassRef), 316 | field.init ? [field.init.valuePath.node] : [] 317 | ); 318 | preamble.push( 319 | t.variableDeclaration("const", [ 320 | t.variableDeclarator( 321 | t.arrayPattern([ 322 | t.identifier(field.localName!), 323 | t.identifier(field.localSetterName!), 324 | ]), 325 | ts && field.typeAnnotation 326 | ? assignTypeArguments( 327 | call, 328 | t.tsTypeParameterInstantiation([ 329 | field.typeAnnotation.type === "method" 330 | ? t.tsFunctionType( 331 | undefined, 332 | field.typeAnnotation.params.map((p) => p.node), 333 | t.tsTypeAnnotation(field.typeAnnotation.returnType.node) 334 | ) 335 | : field.typeAnnotation.path.node, 336 | ]) 337 | ) 338 | : call 339 | ), 340 | ]) 341 | ); 342 | } 343 | for (const [, field] of analysis.userDefined.fields) { 344 | if (field.type === "user_defined_function") { 345 | // Method definitions. 346 | let init: Expression = 347 | field.init.type === "method" 348 | ? functionExpressionFrom( 349 | babel, 350 | field.init.path.node, 351 | t.identifier(field.localName!) 352 | ) 353 | : field.init.initPath.node; 354 | if (field.needMemo) { 355 | const depVars = new Set(); 356 | const depProps = new Set(); 357 | for (const dep of field.dependencies) { 358 | switch (dep.type) { 359 | case "dep_props_obj": 360 | depVars.add("props"); 361 | break; 362 | case "dep_prop": 363 | depProps.add(dep.name); 364 | break; 365 | case "dep_prop_alias": { 366 | const prop = analysis.props.props.get(dep.name)!; 367 | depVars.add(prop.newAliasName!); 368 | break; 369 | } 370 | case "dep_state": { 371 | const state = analysis.state.states.get(dep.name)!; 372 | depVars.add(state.localName!); 373 | break; 374 | } 375 | case "dep_function": { 376 | const field = analysis.userDefined.fields.get(dep.name)!; 377 | depVars.add(field.localName!); 378 | break; 379 | } 380 | } 381 | } 382 | init = t.callExpression( 383 | getReactImport("useCallback", babel, analysis.superClassRef), 384 | [ 385 | init, 386 | t.arrayExpression([ 387 | ...Array.from(depVars) 388 | .sort() 389 | .map((name) => t.identifier(name)), 390 | ...Array.from(depProps) 391 | .sort() 392 | .map((name) => 393 | t.memberExpression(t.identifier("props"), t.identifier(name)) 394 | ), 395 | ]), 396 | ] 397 | ); 398 | } 399 | preamble.push( 400 | constDeclaration( 401 | babel, 402 | t.identifier(field.localName!), 403 | init, 404 | field.typeAnnotation 405 | ? t.tsTypeAnnotation(field.typeAnnotation.node) 406 | : undefined 407 | ) 408 | ); 409 | } else if (field.type === "user_defined_ref") { 410 | // const foo = useRef(null); 411 | const call = t.callExpression( 412 | getReactImport("useRef", babel, analysis.superClassRef), 413 | [t.nullLiteral()] 414 | ); 415 | preamble.push( 416 | t.variableDeclaration("const", [ 417 | t.variableDeclarator( 418 | t.identifier(field.localName!), 419 | ts && field.typeAnnotation 420 | ? assignTypeArguments( 421 | call, 422 | t.tsTypeParameterInstantiation([field.typeAnnotation.node]) 423 | ) 424 | : call 425 | ), 426 | ]) 427 | ); 428 | } else if (field.type === "user_defined_direct_ref") { 429 | // const foo = useRef(init); 430 | const call = t.callExpression( 431 | getReactImport("useRef", babel, analysis.superClassRef), 432 | [field.init ? field.init.node : t.identifier("undefined")] 433 | ); 434 | preamble.push( 435 | t.variableDeclaration("const", [ 436 | t.variableDeclarator( 437 | t.identifier(field.localName!), 438 | ts && field.typeAnnotation 439 | ? assignTypeArguments( 440 | call, 441 | t.tsTypeParameterInstantiation([field.typeAnnotation.node]) 442 | ) 443 | : call 444 | ), 445 | ]) 446 | ); 447 | } 448 | } 449 | 450 | if ( 451 | analysis.effects.cdmPath || 452 | analysis.effects.cduPath || 453 | analysis.effects.cwuPath 454 | ) { 455 | // Emit "raw effects" 456 | 457 | // Emit `const isMounted = useRef(false);` 458 | preamble.push( 459 | t.variableDeclaration("const", [ 460 | t.variableDeclarator( 461 | t.identifier(analysis.effects.isMountedLocalName!), 462 | t.callExpression( 463 | getReactImport("useRef", babel, analysis.superClassRef), 464 | [t.booleanLiteral(false)] 465 | ) 466 | ), 467 | ]) 468 | ); 469 | 470 | // Emit first `useEffect` for componentDidMount/componentDidUpdate 471 | // It also updates `isMounted` -- needed for componentWillUnmount as well! 472 | preamble.push( 473 | t.addComment( 474 | t.expressionStatement( 475 | t.callExpression( 476 | getReactImport("useEffect", babel, analysis.superClassRef), 477 | [ 478 | t.arrowFunctionExpression( 479 | [], 480 | t.blockStatement([ 481 | t.ifStatement( 482 | // Condition: `!isMountedLocalName.current` 483 | t.unaryExpression( 484 | "!", 485 | t.memberExpression( 486 | t.identifier(analysis.effects.isMountedLocalName!), 487 | t.identifier("current") 488 | ) 489 | ), 490 | // Consequent: `{ isMountedLocalName.current = true; ... }` 491 | t.blockStatement([ 492 | t.expressionStatement( 493 | t.assignmentExpression( 494 | "=", 495 | t.memberExpression( 496 | t.identifier(analysis.effects.isMountedLocalName!), 497 | t.identifier("current") 498 | ), 499 | t.booleanLiteral(true) 500 | ) 501 | ), 502 | ...(analysis.effects.cdmPath?.node.body.body ?? []), 503 | ]), 504 | // Alternate: contents of componentDidUpdate, if any 505 | analysis.effects.cduPath?.node.body 506 | ), 507 | ]) 508 | ), 509 | ] 510 | ) 511 | ), 512 | "leading", 513 | " TODO(react-declassify): refactor this effect (automatically generated from lifecycle)", 514 | true 515 | ) 516 | ); 517 | refreshComments(preamble[preamble.length - 1]!); 518 | 519 | if (analysis.effects.cwuPath) { 520 | // To workaround dependency issues, store the latest callback in a ref 521 | 522 | // Emit `const cleanup = useRef(null);` 523 | preamble.push( 524 | t.variableDeclaration("const", [ 525 | t.variableDeclarator( 526 | t.identifier(analysis.effects.cleanupLocalName!), 527 | assignTypeArguments( 528 | t.callExpression( 529 | getReactImport("useRef", babel, analysis.superClassRef), 530 | [t.nullLiteral()] 531 | ), 532 | // Type annotation: useRef<(() => void) | null> 533 | ts 534 | ? t.tsTypeParameterInstantiation([ 535 | t.tsUnionType([ 536 | t.tsFunctionType( 537 | null, 538 | [], 539 | t.tsTypeAnnotation(t.tsVoidKeyword()) 540 | ), 541 | t.tsNullKeyword(), 542 | ]), 543 | ]) 544 | : null 545 | ) 546 | ), 547 | ]) 548 | ); 549 | 550 | // Emit `cleanup.current = () => { ... }` 551 | preamble.push( 552 | t.expressionStatement( 553 | t.assignmentExpression( 554 | "=", 555 | t.memberExpression( 556 | t.identifier(analysis.effects.cleanupLocalName!), 557 | t.identifier("current") 558 | ), 559 | t.arrowFunctionExpression([], analysis.effects.cwuPath.node.body) 560 | ) 561 | ) 562 | ); 563 | 564 | // Emit the second `useEffect` -- this time with empty dependency array 565 | preamble.push( 566 | t.addComment( 567 | t.expressionStatement( 568 | t.callExpression( 569 | getReactImport("useEffect", babel, analysis.superClassRef), 570 | [ 571 | t.arrowFunctionExpression( 572 | [], 573 | t.blockStatement([ 574 | // Immediately return the cleanup function 575 | t.returnStatement( 576 | t.arrowFunctionExpression( 577 | [], 578 | t.blockStatement([ 579 | // Check isMounted 580 | t.ifStatement( 581 | // `if (isMounted.current)` 582 | t.memberExpression( 583 | t.identifier( 584 | analysis.effects.isMountedLocalName! 585 | ), 586 | t.identifier("current") 587 | ), 588 | t.blockStatement([ 589 | // `isMounted.current = false;` 590 | t.expressionStatement( 591 | t.assignmentExpression( 592 | "=", 593 | t.memberExpression( 594 | t.identifier( 595 | analysis.effects.isMountedLocalName! 596 | ), 597 | t.identifier("current") 598 | ), 599 | t.booleanLiteral(false) 600 | ) 601 | ), 602 | // `cleanup.current?.()` 603 | t.expressionStatement( 604 | t.optionalCallExpression( 605 | t.memberExpression( 606 | t.identifier( 607 | analysis.effects.cleanupLocalName! 608 | ), 609 | t.identifier("current") 610 | ), 611 | [], 612 | true 613 | ) 614 | ), 615 | ]) 616 | ), 617 | ]) 618 | ) 619 | ), 620 | ]) 621 | ), 622 | t.arrayExpression([]), 623 | ] 624 | ) 625 | ), 626 | "leading", 627 | " TODO(react-declassify): refactor this effect (automatically generated from lifecycle)", 628 | true 629 | ) 630 | ); 631 | refreshComments(preamble[preamble.length - 1]!); 632 | } 633 | } 634 | 635 | // Soft error handling 636 | for (const softError of softErrors.errors) { 637 | if (softError.type === "invalid_decl") { 638 | if (isClassPropertyLike(softError.path) && softError.path.node.value) { 639 | preamble.push( 640 | t.expressionStatement( 641 | t.assignmentExpression( 642 | "=", 643 | memberFromDecl( 644 | babel, 645 | t.identifier("TODO_this"), 646 | softError.path.node 647 | ), 648 | softError.path.node.value 649 | ) 650 | ) 651 | ); 652 | } else if (isClassMethodLike(softError.path)) { 653 | preamble.push( 654 | t.expressionStatement( 655 | t.assignmentExpression( 656 | "=", 657 | memberFromDecl( 658 | babel, 659 | t.identifier("TODO_this"), 660 | softError.path.node 661 | ), 662 | functionExpressionFrom(babel, softError.path.node) 663 | ) 664 | ) 665 | ); 666 | } 667 | } 668 | } 669 | 670 | const bodyNode = analysis.render.path.node.body; 671 | bodyNode.body.splice(0, 0, ...preamble); 672 | // recast is not smart enough to correctly pretty-print type parameters for arrow functions. 673 | // so we fall back to functions when type parameters are present. 674 | const functionNeeded = analysis.isPure || !!analysis.typeParameters; 675 | const params = needsProps(analysis) 676 | ? [ 677 | assignTypeAnnotation( 678 | t.identifier("props"), 679 | // If the function is generic, put type annotations here instead of the `const` to be defined. 680 | // TODO: take children into account, while being careful about difference between `@types/react` v17 and v18 681 | analysis.typeParameters 682 | ? analysis.propsTyping 683 | ? t.tsTypeAnnotation(analysis.propsTyping.node) 684 | : undefined 685 | : undefined 686 | ), 687 | ] 688 | : []; 689 | // If the function is generic, put type annotations here instead of the `const` to be defined. 690 | const returnType = analysis.typeParameters 691 | ? // Construct `React.ReactElement | null` 692 | t.tsTypeAnnotation( 693 | t.tsUnionType([ 694 | t.tsTypeReference( 695 | toTSEntity( 696 | getReactImport("ReactElement", babel, analysis.superClassRef), 697 | babel 698 | ) 699 | ), 700 | t.tsNullKeyword(), 701 | ]) 702 | ) 703 | : undefined; 704 | let funcNode: Expression = assignTypeParameters( 705 | assignReturnType( 706 | functionNeeded 707 | ? t.functionExpression( 708 | analysis.name ? t.cloneNode(analysis.name) : undefined, 709 | params, 710 | bodyNode 711 | ) 712 | : t.arrowFunctionExpression(params, bodyNode), 713 | returnType 714 | ), 715 | analysis.typeParameters?.node 716 | ); 717 | if (analysis.isPure) { 718 | funcNode = t.callExpression( 719 | getReactImport("memo", babel, analysis.superClassRef), 720 | [funcNode] 721 | ); 722 | } 723 | return { 724 | funcNode, 725 | typeNode: 726 | ts && !analysis.typeParameters 727 | ? t.tsTypeReference( 728 | toTSEntity( 729 | getReactImport("FC", babel, analysis.superClassRef), 730 | babel 731 | ), 732 | analysis.propsTyping 733 | ? t.tsTypeParameterInstantiation([analysis.propsTyping.node]) 734 | : null 735 | ) 736 | : undefined, 737 | }; 738 | } 739 | 740 | function toTSEntity( 741 | expr: Expression, 742 | babel: typeof import("@babel/core") 743 | ): TSEntityName { 744 | const { types: t } = babel; 745 | if ( 746 | expr.type === "MemberExpression" && 747 | !expr.computed && 748 | expr.property.type === "Identifier" 749 | ) { 750 | return t.tsQualifiedName( 751 | toTSEntity(expr.object, babel), 752 | t.cloneNode(expr.property) 753 | ); 754 | } else if (expr.type === "Identifier") { 755 | return t.cloneNode(expr); 756 | } 757 | throw new Error(`Cannot convert to TSEntityName: ${expr.type}`); 758 | } 759 | 760 | function getReactImport( 761 | name: string, 762 | babel: typeof import("@babel/core"), 763 | superClassRef: LibRef 764 | ): MemberExpression | Identifier { 765 | const { types: t } = babel; 766 | if (superClassRef.type === "global") { 767 | return t.memberExpression( 768 | t.identifier(superClassRef.globalName), 769 | t.identifier(name) 770 | ); 771 | } 772 | if (superClassRef.kind === "ns") { 773 | return t.memberExpression( 774 | t.identifier(superClassRef.specPath.node.local.name), 775 | t.identifier(name) 776 | ); 777 | } 778 | const decl = superClassRef.specPath.parentPath as NodePath; 779 | for (const spec of decl.get("specifiers")) { 780 | if (spec.isImportSpecifier() && importName(spec.node.imported) === name) { 781 | return t.cloneNode(spec.node.local); 782 | } 783 | } 784 | // No existing decl 785 | const newName = decl.scope.getBinding(name) 786 | ? decl.scope.generateUid(name) 787 | : name; 788 | const local = t.identifier(newName); 789 | decl 790 | .get("specifiers") 791 | [decl.node.specifiers.length - 1]!.insertAfter( 792 | t.importSpecifier(local, name === newName ? local : t.identifier(newName)) 793 | ); 794 | return t.identifier(newName); 795 | } 796 | 797 | type FunctionLike = 798 | | FunctionDeclaration 799 | | FunctionExpression 800 | | ArrowFunctionExpression 801 | | ClassMethod 802 | | ClassPrivateMethod 803 | | ObjectMethod; 804 | 805 | function functionName(node: FunctionLike): Identifier | undefined { 806 | switch (node.type) { 807 | case "FunctionDeclaration": 808 | case "FunctionExpression": 809 | return node.id ?? undefined; 810 | } 811 | } 812 | 813 | function functionDeclarationFrom( 814 | babel: typeof import("@babel/core"), 815 | node: FunctionLike, 816 | name?: Identifier | null 817 | ) { 818 | const { types: t } = babel; 819 | return assignTypeParameters( 820 | assignReturnType( 821 | t.functionDeclaration( 822 | name ?? functionName(node), 823 | node.params as (Identifier | RestElement | Pattern)[], 824 | node.body.type === "BlockStatement" 825 | ? node.body 826 | : t.blockStatement([t.returnStatement(node.body)]), 827 | node.generator, 828 | node.async 829 | ), 830 | node.returnType 831 | ), 832 | node.typeParameters as TSTypeParameterDeclaration | null | undefined 833 | ); 834 | } 835 | 836 | function functionExpressionFrom( 837 | babel: typeof import("@babel/core"), 838 | node: FunctionLike, 839 | name?: Identifier | null 840 | ) { 841 | const { types: t } = babel; 842 | return assignTypeParameters( 843 | assignReturnType( 844 | t.functionExpression( 845 | name ?? functionName(node), 846 | node.params as (Identifier | RestElement | Pattern)[], 847 | node.body.type === "BlockStatement" 848 | ? node.body 849 | : t.blockStatement([t.returnStatement(node.body)]), 850 | node.generator, 851 | node.async 852 | ), 853 | node.returnType 854 | ), 855 | node.typeParameters as TSTypeParameterDeclaration | null | undefined 856 | ); 857 | } 858 | 859 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 860 | function arrowFunctionExpressionFrom( 861 | babel: typeof import("@babel/core"), 862 | node: FunctionLike 863 | ) { 864 | const { types: t } = babel; 865 | return assignTypeParameters( 866 | assignReturnType( 867 | t.arrowFunctionExpression( 868 | node.params as (Identifier | RestElement | Pattern)[], 869 | node.body, 870 | node.async 871 | ), 872 | node.returnType 873 | ), 874 | node.typeParameters as TSTypeParameterDeclaration | null | undefined 875 | ); 876 | } 877 | 878 | function constDeclaration( 879 | babel: typeof import("@babel/core"), 880 | id: Identifier, 881 | init: Expression, 882 | typeAnnotation?: TSTypeAnnotation 883 | ): VariableDeclaration | FunctionDeclaration { 884 | const { types: t } = babel; 885 | if ( 886 | init.type === "FunctionExpression" && 887 | (!init.id || init.id.name === id.name) && 888 | !typeAnnotation 889 | ) { 890 | return functionDeclarationFrom(babel, init, id); 891 | } 892 | return t.variableDeclaration("const", [ 893 | t.variableDeclarator(assignTypeAnnotation(id, typeAnnotation), init), 894 | ]); 895 | } 896 | 897 | /** 898 | * Refreshes recast's internal state to force generically printing comments. 899 | */ 900 | function refreshComments(node: Node): void; 901 | function refreshComments(node: Node) { 902 | type ExtNode = Node & { 903 | comments: Comment[]; 904 | original?: Node | undefined; 905 | }; 906 | type ExtComment = Comment & { 907 | leading?: boolean | undefined; 908 | trailing?: boolean | undefined; 909 | }; 910 | for (const comment of (node.leadingComments ?? []) as ExtComment[]) { 911 | comment.leading ??= true; 912 | comment.trailing ??= false; 913 | } 914 | for (const comment of (node.trailingComments ?? []) as ExtComment[]) { 915 | comment.leading ??= false; 916 | comment.trailing ??= true; 917 | } 918 | (node as ExtNode).comments = [ 919 | ...(node.leadingComments ?? []), 920 | ...(node.innerComments ?? []), 921 | ...(node.trailingComments ?? []), 922 | ]; 923 | (node as ExtNode).original = undefined; 924 | } 925 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import type { NodePath, PluginPass } from "@babel/core"; 2 | import type { 3 | ArrayPattern, 4 | ArrowFunctionExpression, 5 | AssignmentPattern, 6 | CallExpression, 7 | ClassAccessorProperty, 8 | ClassDeclaration, 9 | ClassExpression, 10 | ClassMethod, 11 | ClassPrivateMethod, 12 | ClassPrivateProperty, 13 | ClassProperty, 14 | Expression, 15 | FunctionDeclaration, 16 | FunctionExpression, 17 | Identifier, 18 | JSXOpeningElement, 19 | MemberExpression, 20 | NewExpression, 21 | Noop, 22 | ObjectMethod, 23 | ObjectPattern, 24 | ObjectProperty, 25 | OptionalCallExpression, 26 | RestElement, 27 | StaticBlock, 28 | StringLiteral, 29 | TaggedTemplateExpression, 30 | TSCallSignatureDeclaration, 31 | TSConstructorType, 32 | TSConstructSignatureDeclaration, 33 | TSDeclareFunction, 34 | TSDeclareMethod, 35 | TSExpressionWithTypeArguments, 36 | TSFunctionType, 37 | TSImportType, 38 | TSInstantiationExpression, 39 | TSInterfaceDeclaration, 40 | TSMethodSignature, 41 | TSPropertySignature, 42 | TSTypeAliasDeclaration, 43 | TSTypeAnnotation, 44 | TSTypeParameterDeclaration, 45 | TSTypeParameterInstantiation, 46 | TSTypeQuery, 47 | TSTypeReference, 48 | TypeAnnotation, 49 | } from "@babel/types"; 50 | 51 | export function getOr(m: Map, k: K, getDefault: () => V): V { 52 | if (m.has(k)) { 53 | return m.get(k)!; 54 | } else { 55 | const v = getDefault(); 56 | m.set(k, v); 57 | return v; 58 | } 59 | } 60 | 61 | export function getAndDelete(m: Map, k: K): V | undefined { 62 | const v = m.get(k); 63 | m.delete(k); 64 | return v; 65 | } 66 | 67 | export function memberName( 68 | member: 69 | | ClassMethod 70 | | ClassPrivateMethod 71 | | ClassProperty 72 | | ClassPrivateProperty 73 | | ClassAccessorProperty 74 | | TSDeclareMethod 75 | | ObjectMethod 76 | | ObjectProperty 77 | | TSPropertySignature 78 | | TSMethodSignature 79 | ): string | undefined { 80 | const computed = 81 | member.type === "ClassPrivateMethod" || 82 | member.type === "ClassPrivateProperty" 83 | ? false 84 | : member.computed; 85 | if (computed && member.key.type === "StringLiteral") { 86 | return member.key.value; 87 | } else if (!computed && member.key.type === "Identifier") { 88 | return member.key.name; 89 | } 90 | } 91 | 92 | export function memberRefName(member: MemberExpression): string | undefined { 93 | if (member.computed && member.property.type === "StringLiteral") { 94 | return member.property.value; 95 | } else if (!member.computed && member.property.type === "Identifier") { 96 | return member.property.name; 97 | } 98 | } 99 | 100 | export function importName(name: Identifier | StringLiteral): string { 101 | if (name.type === "StringLiteral") { 102 | return name.value; 103 | } else { 104 | return name.name; 105 | } 106 | } 107 | 108 | export function memberFromDecl( 109 | babel: typeof import("@babel/core"), 110 | object: Expression, 111 | decl: 112 | | ClassMethod 113 | | ClassPrivateMethod 114 | | ClassProperty 115 | | ClassPrivateProperty 116 | | ClassAccessorProperty 117 | | TSDeclareMethod 118 | | ObjectMethod 119 | | ObjectProperty 120 | | TSPropertySignature 121 | | TSMethodSignature 122 | ): MemberExpression { 123 | const { types: t } = babel; 124 | if ( 125 | decl.type === "ClassPrivateMethod" || 126 | decl.type === "ClassPrivateProperty" 127 | ) { 128 | return t.memberExpression(object, t.stringLiteral(decl.key.id.name), true); 129 | } 130 | if (decl.key.type === "PrivateName") { 131 | return t.memberExpression(object, t.stringLiteral(decl.key.id.name), true); 132 | } 133 | return t.memberExpression(object, decl.key, decl.computed); 134 | } 135 | 136 | export function nonNullPath( 137 | path: NodePath 138 | ): NodePath | undefined { 139 | return path.node ? (path as NodePath) : undefined; 140 | } 141 | 142 | export function isNamedClassElement( 143 | path: NodePath 144 | ): path is NodePath< 145 | | ClassProperty 146 | | ClassPrivateProperty 147 | | ClassMethod 148 | | ClassPrivateMethod 149 | | TSDeclareMethod 150 | | ClassAccessorProperty 151 | > { 152 | return ( 153 | path.isClassProperty() || 154 | path.isClassPrivateProperty() || 155 | path.isClassMethod() || 156 | path.isClassPrivateMethod() || 157 | path.isTSDeclareMethod() || 158 | isClassAccessorProperty(path) 159 | ); 160 | } 161 | 162 | export function isClassPropertyLike( 163 | path: NodePath 164 | ): path is NodePath { 165 | return path.isClassProperty() || path.isClassPrivateProperty(); 166 | } 167 | 168 | export function isClassMethodLike( 169 | path: NodePath 170 | ): path is NodePath { 171 | return path.isClassMethod() || path.isClassPrivateMethod(); 172 | } 173 | 174 | export function isClassMethodOrDecl( 175 | path: NodePath 176 | ): path is NodePath { 177 | return ( 178 | path.isClassMethod() || 179 | path.isClassPrivateMethod() || 180 | path.isTSDeclareMethod() 181 | ); 182 | } 183 | 184 | export function isStaticBlock(path: NodePath): path is NodePath { 185 | return path.node.type === "StaticBlock"; 186 | } 187 | 188 | export function isClassAccessorProperty( 189 | path: NodePath 190 | ): path is NodePath { 191 | return path.node.type === "ClassAccessorProperty"; 192 | } 193 | 194 | export function isTS(state: PluginPass): boolean { 195 | if (state.filename) { 196 | return /\.(?:[mc]ts|tsx?)$/i.test(state.filename); 197 | } 198 | return false; 199 | } 200 | 201 | type Annotatable = 202 | | Identifier 203 | | AssignmentPattern 204 | | ArrayPattern 205 | | ObjectPattern 206 | | RestElement 207 | | ClassProperty 208 | | ClassAccessorProperty 209 | | ClassPrivateProperty; 210 | 211 | export function assignTypeAnnotation( 212 | node: T, 213 | typeAnnotation: TSTypeAnnotation | null | undefined 214 | ): T { 215 | return Object.assign(node, { 216 | typeAnnotation, 217 | }); 218 | } 219 | 220 | type ReturnTypeable = 221 | | FunctionDeclaration 222 | | FunctionExpression 223 | | TSDeclareFunction 224 | | ArrowFunctionExpression 225 | | ObjectMethod 226 | | ClassMethod 227 | | ClassPrivateMethod 228 | | TSDeclareMethod; 229 | 230 | export function assignReturnType( 231 | node: T, 232 | returnType: TypeAnnotation | TSTypeAnnotation | Noop | null | undefined 233 | ): T { 234 | return Object.assign(node, { 235 | returnType, 236 | }); 237 | } 238 | 239 | type Paramable = 240 | | FunctionDeclaration 241 | | FunctionExpression 242 | | ArrowFunctionExpression 243 | | TSDeclareFunction 244 | | ObjectMethod 245 | | ClassMethod 246 | | ClassPrivateMethod 247 | | TSDeclareMethod 248 | | ClassDeclaration 249 | | ClassExpression 250 | | TSCallSignatureDeclaration 251 | | TSConstructSignatureDeclaration 252 | | TSMethodSignature 253 | | TSFunctionType 254 | | TSConstructorType 255 | | TSInterfaceDeclaration 256 | | TSTypeAliasDeclaration; 257 | 258 | export function assignTypeParameters( 259 | node: T, 260 | typeParameters: TSTypeParameterDeclaration | null | undefined 261 | ): T { 262 | return Object.assign(node, { 263 | typeParameters, 264 | }); 265 | } 266 | 267 | type Arguable = 268 | | CallExpression 269 | | NewExpression 270 | | TaggedTemplateExpression 271 | | OptionalCallExpression 272 | | JSXOpeningElement 273 | | TSTypeReference 274 | | TSTypeQuery 275 | | TSExpressionWithTypeArguments 276 | | TSInstantiationExpression 277 | | TSImportType; 278 | 279 | export function assignTypeArguments( 280 | node: T, 281 | typeParameters: TSTypeParameterInstantiation | null | undefined 282 | ): T { 283 | return Object.assign(node, { 284 | typeParameters, 285 | }); 286 | } 287 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "lib": ["esnext"], 5 | "useDefineForClassFields": true, 6 | "module": "nodenext", 7 | "noEmit": true, 8 | "isolatedModules": true, 9 | "esModuleInterop": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "strict": true, 12 | "exactOptionalPropertyTypes": true, 13 | "noUncheckedIndexedAccess": true, 14 | "noImplicitOverride": true, 15 | "noPropertyAccessFromIndexSignature": true, 16 | "skipLibCheck": true 17 | } 18 | } 19 | --------------------------------------------------------------------------------