From ea32619acb8fea718bb899c2f61fdc6c93b9c732 Mon Sep 17 00:00:00 2001 From: hardfist Date: Wed, 17 Jun 2026 15:10:54 +0800 Subject: [PATCH 1/3] Replace Babel and esbuild with Yuku --- config/rslib.config.ts | 12 +- package.json | 19 +- pnpm-lock.yaml | 398 ++++++++++++++++++-- scripts/benchmark-yuku.mjs | 307 ++++++++++++++++ src/babel.ts | 69 +++- src/export-utils.ts | 194 ++++++++-- src/index.ts | 4 +- src/plugin-utils.ts | 722 ++++++++++++++++++++++++------------- src/route-chunks.ts | 681 +++++++++++++--------------------- 9 files changed, 1604 insertions(+), 802 deletions(-) create mode 100644 scripts/benchmark-yuku.mjs diff --git a/config/rslib.config.ts b/config/rslib.config.ts index b005996..44553e2 100644 --- a/config/rslib.config.ts +++ b/config/rslib.config.ts @@ -27,8 +27,8 @@ export const pluginCleanTscCache: RsbuildPlugin = { setup(api) { api.onBeforeBuild(() => { const tsbuildinfo = path.join( - api.context.rootPath, - 'tsconfig.tsbuildinfo', + api.context.rootPath, + 'tsconfig.tsbuildinfo' ); if (fs.existsSync(tsbuildinfo)) { fs.rmSync(tsbuildinfo); @@ -42,8 +42,8 @@ export const esmConfig: LibConfig = { syntax: 'es2021', shims: { esm: { - __dirname: true - } + __dirname: true, + }, }, dts: { build: true, @@ -51,10 +51,6 @@ export const esmConfig: LibConfig = { plugins: [pluginCleanTscCache], output: { minify: nodeMinifyConfig, - externals: { - '@babel/traverse': 'commonjs @babel/traverse', - '@babel/generator': 'commonjs @babel/generator', - } }, }; diff --git a/package.json b/package.json index 5e504c8..61e72a3 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "test": "rstest run", "test:watch": "rstest watch", "test:coverage": "rstest run --coverage", + "bench:yuku": "node scripts/benchmark-yuku.mjs --compare-head", "test:core": "rstest run -c ./rstest.config.ts", "test:core:watch": "rstest watch -c ./rstest.config.ts", "format": "prettier --write \"src/**/*.{js,jsx,ts,tsx}\"", @@ -64,16 +65,9 @@ "release:local": "pnpm build && changeset version && changeset publish && git add . && git commit -m \"chore: version packages\" && git push && git push --tags" }, "dependencies": { - "@babel/core": "^7.28.6", - "@babel/generator": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6", "@react-router/node": "^7.13.0", "@remix-run/node-fetch-server": "^0.13.0", "@rspack/plugin-react-refresh": "^2.0.2", - "babel-dead-code-elimination": "^1.0.12", - "esbuild": "^0.27.2", "execa": "^9.6.1", "fs-extra": "11.3.3", "isbot": "5.1.34", @@ -81,7 +75,10 @@ "jsesc": "^3.1.0", "pathe": "^2.0.3", "react-refresh": "^0.18.0", - "rspack-plugin-virtual-module": "^1.0.1" + "rspack-plugin-virtual-module": "^1.0.1", + "yuku-analyzer": "0.5.38", + "yuku-codegen": "0.5.38", + "yuku-parser": "0.5.38" }, "devDependencies": { "@changesets/cli": "^2.29.8", @@ -90,18 +87,14 @@ "@rsbuild/core": "2.0.15", "@rslib/core": "^0.22.1", "@rspack/core": "2.0.8", - "@swc/helpers": "^0.5.23", "@rstest/core": "^0.8.1", "@rstest/coverage-istanbul": "^0.2.0", - "@types/babel__core": "^7.20.5", - "@types/babel__generator": "^7.27.0", - "@types/babel__traverse": "^7.28.0", + "@swc/helpers": "^0.5.23", "@types/fs-extra": "11.0.4", "@types/jsesc": "^3.0.3", "@types/node": "^25.0.10", "@types/react": "^19.2.10", "@types/react-dom": "^19.2.3", - "es-module-lexer": "1.7.0", "kill-port": "^2.0.1", "playwright": "^1.58.0", "prettier": "3.8.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 596ac75..716ed84 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,21 +11,6 @@ importers: .: dependencies: - '@babel/core': - specifier: ^7.28.6 - version: 7.28.6 - '@babel/generator': - specifier: ^7.28.6 - version: 7.28.6 - '@babel/parser': - specifier: ^7.28.6 - version: 7.28.6 - '@babel/traverse': - specifier: ^7.28.6 - version: 7.28.6 - '@babel/types': - specifier: ^7.28.6 - version: 7.28.6 '@react-router/node': specifier: ^7.13.0 version: 7.13.0(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3) @@ -34,13 +19,7 @@ importers: version: 0.13.0 '@rspack/plugin-react-refresh': specifier: ^2.0.2 - version: 2.0.2(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(react-refresh@0.18.0) - babel-dead-code-elimination: - specifier: ^1.0.12 - version: 1.0.12 - esbuild: - specifier: ^0.27.2 - version: 0.27.2 + version: 2.0.2(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(@swc/helpers@0.5.23))(react-refresh@0.18.0) execa: specifier: ^9.6.1 version: 9.6.1 @@ -65,6 +44,15 @@ importers: rspack-plugin-virtual-module: specifier: ^1.0.1 version: 1.0.1 + yuku-analyzer: + specifier: 0.5.38 + version: 0.5.38 + yuku-codegen: + specifier: 0.5.38 + version: 0.5.38 + yuku-parser: + specifier: 0.5.38 + version: 0.5.38 devDependencies: '@changesets/cli': specifier: ^2.29.8 @@ -80,7 +68,7 @@ importers: version: 2.0.15(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(core-js@3.47.0) '@rslib/core': specifier: ^0.22.1 - version: 0.22.1(@module-federation/runtime-tools@2.5.1)(core-js@3.47.0)(typescript@5.9.3) + version: 0.22.1(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(core-js@3.47.0)(typescript@5.9.3) '@rspack/core': specifier: 2.0.8 version: 2.0.8(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(@swc/helpers@0.5.23) @@ -93,15 +81,6 @@ importers: '@swc/helpers': specifier: ^0.5.23 version: 0.5.23 - '@types/babel__core': - specifier: ^7.20.5 - version: 7.20.5 - '@types/babel__generator': - specifier: ^7.27.0 - version: 7.27.0 - '@types/babel__traverse': - specifier: ^7.28.0 - version: 7.28.0 '@types/fs-extra': specifier: 11.0.4 version: 11.0.4 @@ -117,9 +96,6 @@ importers: '@types/react-dom': specifier: ^19.2.3 version: 19.2.3(@types/react@19.2.10) - es-module-lexer: - specifier: 1.7.0 - version: 1.7.0 kill-port: specifier: ^2.0.1 version: 2.0.1 @@ -5141,6 +5117,174 @@ packages: '@xtuc/long@4.2.2': resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==} + '@yuku-analyzer/binding-darwin-arm64@0.5.38': + resolution: {integrity: sha512-ReQ6gxvR+fpPzaWXgovO/NPwUNL2MnllZlX3mEtUl3F7lC7ccLMzTJiYNSp8XBqqJtWW5N+jVQk744V5Y1IAgw==} + cpu: [arm64] + os: [darwin] + + '@yuku-analyzer/binding-darwin-x64@0.5.38': + resolution: {integrity: sha512-aG4J3j+rQ7UDR+BfY2ku3iurq2wCFJC3edbZCTztbkHkB6L7ScdwpVlJdIczDsxI6+U2J+nwB50VtW3asZlQow==} + cpu: [x64] + os: [darwin] + + '@yuku-analyzer/binding-freebsd-x64@0.5.38': + resolution: {integrity: sha512-3syI009LnQBNC+RdpEZZAP7P+rVe6Q2FQY3ZHlX5ejRb+eNjWqHoZbOSlqaON7WAOf1JQe5iLlEkMKZVBK5OiQ==} + cpu: [x64] + os: [freebsd] + + '@yuku-analyzer/binding-linux-arm-gnu@0.5.38': + resolution: {integrity: sha512-DjHt+fDqYnptWXLGCbIL4QW2pDTGzmzi45qMz063h+2PoY7xhUmGCkqTm8Teh3IZmnm2J2ZIz9HJNHm+VOujmg==} + cpu: [arm] + os: [linux] + + '@yuku-analyzer/binding-linux-arm-musl@0.5.38': + resolution: {integrity: sha512-4FJgBfmZuUWHzx/OTiBjk6ZmniacT9CawHeOaQBwEgBYmvjBtaOOUtvBqD5lrJKR3VTyLAsW/DF0zHmJvkAPBA==} + cpu: [arm] + os: [linux] + + '@yuku-analyzer/binding-linux-arm64-gnu@0.5.38': + resolution: {integrity: sha512-YMfGT7QSFe9QXwbwmGasuXN62coW6u+TC1jb/NroGaOHsDB79cKeRz+rG+HDppTmNHlYRJfzkg2QucmvpGWTHg==} + cpu: [arm64] + os: [linux] + + '@yuku-analyzer/binding-linux-arm64-musl@0.5.38': + resolution: {integrity: sha512-ujtqERprsVGIIQihk6wtsECSdE9XWe5Eij4/2aRbf/v0MilNECIqccC8cFElFNlJxOkZBNAL8UbF2IeCXU5+Tw==} + cpu: [arm64] + os: [linux] + + '@yuku-analyzer/binding-linux-x64-gnu@0.5.38': + resolution: {integrity: sha512-hDDWq+CDLxuntgGoitQAaCw/ueDXNhGV0yIaEbZDEv5AvwGPgh09gBF5+yx/5wBdNNRACeYeFyukeJuIFAndZQ==} + cpu: [x64] + os: [linux] + + '@yuku-analyzer/binding-linux-x64-musl@0.5.38': + resolution: {integrity: sha512-k8koEM7OacjdoAHDg0T0ZMtHoBAcRqyE8zIvYhjQnZfInyJ0t+WT4oeq/l//YMbcsF4wOg+QvuS+3OutyRcLTA==} + cpu: [x64] + os: [linux] + + '@yuku-analyzer/binding-win32-arm64@0.5.38': + resolution: {integrity: sha512-oLlk9JWH+E0qK+wc/jW2PN0ZdwNdGa0cMPN24hMmhiK6yPSFZNJUG6138XyynYq6iV//TkSzsYbweAjwtzmEAw==} + cpu: [arm64] + os: [win32] + + '@yuku-analyzer/binding-win32-x64@0.5.38': + resolution: {integrity: sha512-DthuKpARL1lciV1XQod2r68YgFqZ+JM+oeZ+/umNJ166+HuaO2UwKpQ5h5IO8tC0MPvlLDO+J+er3aNMMrsVTQ==} + cpu: [x64] + os: [win32] + + '@yuku-codegen/binding-darwin-arm64@0.5.38': + resolution: {integrity: sha512-2nOLgv6h5pDda+Ykqg2N+tcm+lYEdSIoStVxpUV2IlbNWSg7/q2iCtEl0qWo42o9H6Oivk6c6BftDJdZNsMIbg==} + cpu: [arm64] + os: [darwin] + + '@yuku-codegen/binding-darwin-x64@0.5.38': + resolution: {integrity: sha512-P1gUksBlW+q7MmOoLKwEkBcJ7sxlO8e4C00dWvuTFrrygTJZJfalN8WZ0DOrjkV06DLy4FIBn7FXdDy+yPbeaw==} + cpu: [x64] + os: [darwin] + + '@yuku-codegen/binding-freebsd-x64@0.5.38': + resolution: {integrity: sha512-jsF0g3FYkzeuGijBfTWuPVQo6xMjCWPrQihNjNJdrhObPeMFRW/jCcPsjre20aZBLF5gbzRH9BFaEiGJtXvo0Q==} + cpu: [x64] + os: [freebsd] + + '@yuku-codegen/binding-linux-arm-gnu@0.5.38': + resolution: {integrity: sha512-h5jGyr8fQ+zwmf3VzwyS+ltHfzm7iYXzkAy5TWOTT1x5GHGH5tVTBljPAmc/o+T19uj42aQgL5z3pzcMJ9g+MQ==} + cpu: [arm] + os: [linux] + + '@yuku-codegen/binding-linux-arm-musl@0.5.38': + resolution: {integrity: sha512-7w5OxDdSBantw40nFOa/5lejp3IPoAgAg3u3uTW95ipL9/cCHSyq8cKtPxSGr+8LHVx0fCO4a2tEB8SFLy8PXQ==} + cpu: [arm] + os: [linux] + + '@yuku-codegen/binding-linux-arm64-gnu@0.5.38': + resolution: {integrity: sha512-ZW4WJm4ygtzMv0VWwVvDa2TbkOAywggb6FGD+ZSYUNafGflMhlDvOsRTnNzlEsFqw3uU3RLEU795Ql0o2xrTxA==} + cpu: [arm64] + os: [linux] + + '@yuku-codegen/binding-linux-arm64-musl@0.5.38': + resolution: {integrity: sha512-jlZRCKfElmGMFNUweknPdGiqukSi9N4XiS10jJ/ROFg8ND6fk9NOI2r+pr6qN0U2GAkfu2mgT7RFSHHtgtLCIg==} + cpu: [arm64] + os: [linux] + + '@yuku-codegen/binding-linux-x64-gnu@0.5.38': + resolution: {integrity: sha512-RxrCajqnaeJqNzs9RM8Jze/RSk2PQFDRBEkxUovpJPNhiDj5Q4JbBz5ruZBmi7bgwHliFohhqzdReyovRLsvEg==} + cpu: [x64] + os: [linux] + + '@yuku-codegen/binding-linux-x64-musl@0.5.38': + resolution: {integrity: sha512-QzUiOPUICxzM46Au7f2T0rUE7b+gxuDyOETL1Iz8o0JKYLjt04m6dqpo9Ln2tF4jN8RW7flolzDN7gdVQXGQsw==} + cpu: [x64] + os: [linux] + + '@yuku-codegen/binding-win32-arm64@0.5.38': + resolution: {integrity: sha512-NknjHAtzzJKawpMzmJ/XVi/BNk6rGs00GWCBclQUk8XNN0fJ/1urZ8iCibBZwlUjd6Z77GOYsovNU4a/Rh8nBQ==} + cpu: [arm64] + os: [win32] + + '@yuku-codegen/binding-win32-x64@0.5.38': + resolution: {integrity: sha512-OFRc/vNo/3nsX0ARyxpwZPsVzbDA71YyCMhKYqnyeD5OZek0O88PAjtYCg8YrmIuNGLWYE0fvMpsZe51AjePTg==} + cpu: [x64] + os: [win32] + + '@yuku-parser/binding-darwin-arm64@0.5.38': + resolution: {integrity: sha512-Y6hexHekLYsOyPXJwYmLUhbwawYrHx4YfFNB72vyej/CkMtG0RLHpzJKTqAwn6JTR2zdvLx6sV8gx47dAmjWNg==} + cpu: [arm64] + os: [darwin] + + '@yuku-parser/binding-darwin-x64@0.5.38': + resolution: {integrity: sha512-/Y/GOsBUwLgcHdxzDZ6JoO4iH2NK94wDileNz8h1hPyUEAYPUo6x3+4JXMT7MHJRyoPuHIrQ/p2JZmPdDtjguQ==} + cpu: [x64] + os: [darwin] + + '@yuku-parser/binding-freebsd-x64@0.5.38': + resolution: {integrity: sha512-OLUvZAx1g+nDg0cPk/QEkOdc6d49DLCkEhsGjqyd3uit69CngK9Fs8154pOZc/3Y2QAy4jQAs8HnnediyIc5Bw==} + cpu: [x64] + os: [freebsd] + + '@yuku-parser/binding-linux-arm-gnu@0.5.38': + resolution: {integrity: sha512-LcyGYaBuBm1VYKH0qURqKRcMkW0/PaZdfFdTyHaStLNJzYrHzJLBE/wI5dm2q6NEc56NMnFMSrRzw/BUXv4V0g==} + cpu: [arm] + os: [linux] + + '@yuku-parser/binding-linux-arm-musl@0.5.38': + resolution: {integrity: sha512-d6fd0z96mmq85rm1w8+AUURQoW/R7JxNXx61oWusVAC+JdJmK6KUty5r1hTXiLGRAzdkZXeG3ZlnqlzjvC0wnQ==} + cpu: [arm] + os: [linux] + + '@yuku-parser/binding-linux-arm64-gnu@0.5.38': + resolution: {integrity: sha512-fz9emPmTQsupJR0H5s/oMHf9JrIMo6qaNXVR9ljY9PFICW0+FD7TMdAwoIN7pK/vZ3AgOKWMZcDjjpoA6qaEZQ==} + cpu: [arm64] + os: [linux] + + '@yuku-parser/binding-linux-arm64-musl@0.5.38': + resolution: {integrity: sha512-jvFgjPgoUo9kOebQD4mZUyQ2xMrsuOcTuzJ2rWLApqUTpnrOoVwWyL1MKNw3CdkZUh0h/nMoIPQmQXObmSRxNw==} + cpu: [arm64] + os: [linux] + + '@yuku-parser/binding-linux-x64-gnu@0.5.38': + resolution: {integrity: sha512-DFmydzH7fHMRlFC82dVbIcPugN8eq83B/t2Zjy3HRLnQWMQXhFvZNiv9NNinT3ccjzGeFKo/V3+N9/tGc98sGQ==} + cpu: [x64] + os: [linux] + + '@yuku-parser/binding-linux-x64-musl@0.5.38': + resolution: {integrity: sha512-PTwAGbC5I5Fj6VI38HtI4C3BDrNgpXZdtcK9Um3i/2Tv2R1AintQhIDMyl5ir6NI7AweWl83sHAkO9xAG7cEEw==} + cpu: [x64] + os: [linux] + + '@yuku-parser/binding-win32-arm64@0.5.38': + resolution: {integrity: sha512-3gxfBDo1G70Y1q2Ec8lAYQ2+BV3bA9i74lovmIVRmv6C55aiXfBzrJHwSvsBU/Js1r0MtzT13vdxUdx83BPsuw==} + cpu: [arm64] + os: [win32] + + '@yuku-parser/binding-win32-x64@0.5.38': + resolution: {integrity: sha512-CDZz3v7M6+PyZQJjAJFkZURbDmZtaJeOTgrvDudAk5FzMemcfuTJBKZ0hYp3OI/u0va0kDsre3eCRIwg5eMVbA==} + cpu: [x64] + os: [win32] + + '@yuku-toolchain/types@0.5.37': + resolution: {integrity: sha512-yaGadzsSgTqKXUFef9iUBP7tFXdkN+DWcZqU+MvixYajB3luC8HHCDfJZk/Dy/Hb8haAwJ3z0G9g7bjAG4nGJg==} + '@zeit/schemas@2.36.0': resolution: {integrity: sha512-7kjMwcChYEzMKjeex9ZFXkt1AyNov9R5HZtjBKVsmVpw7pa7ZtlCGvCBC2vnnXctaYN+aRI61HjIqeetZW5ROg==} @@ -9468,6 +9612,15 @@ packages: youch@4.1.0-beta.10: resolution: {integrity: sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==} + yuku-analyzer@0.5.38: + resolution: {integrity: sha512-uxVIPMomdry2zW2qvPbo44Rj72ucom2atQp6Cf5CCcW2wuLxwdHe9eLEo6qELZutsLNC0zZT+cYswyJ75G9q0g==} + + yuku-codegen@0.5.38: + resolution: {integrity: sha512-oaWapF6EiMec8UndXkxVrHiYrDhKEywNTKLoYHLBkbIOxopKd9jBKpLOiYu89NNszuuglGkpQ1z+iuGWYytLPQ==} + + yuku-parser@0.5.38: + resolution: {integrity: sha512-u2+4Vv948JFl+AiXWcKNoagrmZDL1jSvwBuRDoZq4pMTO/ZYJZp3lI2PuIXcLW1eL9eGxvBNvB+X5NgWgoFb0A==} + zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} @@ -10380,7 +10533,7 @@ snapshots: '@img/sharp-wasm32@0.34.5': dependencies: - '@emnapi/runtime': 1.8.1 + '@emnapi/runtime': 1.10.0 optional: true '@img/sharp-win32-arm64@0.34.5': @@ -12100,6 +12253,17 @@ snapshots: - '@rspack/core' - webpack + '@rslib/core@0.22.1(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(core-js@3.47.0)(typescript@5.9.3)': + dependencies: + '@rsbuild/core': 2.0.15(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(core-js@3.47.0) + rsbuild-plugin-dts: 0.22.1(@rsbuild/core@2.0.15(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(core-js@3.47.0))(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - '@module-federation/runtime-tools' + - '@typescript/native-preview' + - core-js + '@rslib/core@0.22.1(@module-federation/runtime-tools@2.5.1)(core-js@3.47.0)(typescript@5.9.3)': dependencies: '@rsbuild/core': 2.0.15(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(core-js@3.47.0) @@ -12226,7 +12390,7 @@ snapshots: optionalDependencies: '@rspack/core': 2.0.8(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(@swc/helpers@0.5.23) - '@rspack/plugin-react-refresh@2.0.2(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1)(@swc/helpers@0.5.23))(react-refresh@0.18.0)': + '@rspack/plugin-react-refresh@2.0.2(@rspack/core@2.0.8(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(@swc/helpers@0.5.23))(react-refresh@0.18.0)': dependencies: react-refresh: 0.18.0 optionalDependencies: @@ -13229,6 +13393,107 @@ snapshots: '@xtuc/long@4.2.2': {} + '@yuku-analyzer/binding-darwin-arm64@0.5.38': + optional: true + + '@yuku-analyzer/binding-darwin-x64@0.5.38': + optional: true + + '@yuku-analyzer/binding-freebsd-x64@0.5.38': + optional: true + + '@yuku-analyzer/binding-linux-arm-gnu@0.5.38': + optional: true + + '@yuku-analyzer/binding-linux-arm-musl@0.5.38': + optional: true + + '@yuku-analyzer/binding-linux-arm64-gnu@0.5.38': + optional: true + + '@yuku-analyzer/binding-linux-arm64-musl@0.5.38': + optional: true + + '@yuku-analyzer/binding-linux-x64-gnu@0.5.38': + optional: true + + '@yuku-analyzer/binding-linux-x64-musl@0.5.38': + optional: true + + '@yuku-analyzer/binding-win32-arm64@0.5.38': + optional: true + + '@yuku-analyzer/binding-win32-x64@0.5.38': + optional: true + + '@yuku-codegen/binding-darwin-arm64@0.5.38': + optional: true + + '@yuku-codegen/binding-darwin-x64@0.5.38': + optional: true + + '@yuku-codegen/binding-freebsd-x64@0.5.38': + optional: true + + '@yuku-codegen/binding-linux-arm-gnu@0.5.38': + optional: true + + '@yuku-codegen/binding-linux-arm-musl@0.5.38': + optional: true + + '@yuku-codegen/binding-linux-arm64-gnu@0.5.38': + optional: true + + '@yuku-codegen/binding-linux-arm64-musl@0.5.38': + optional: true + + '@yuku-codegen/binding-linux-x64-gnu@0.5.38': + optional: true + + '@yuku-codegen/binding-linux-x64-musl@0.5.38': + optional: true + + '@yuku-codegen/binding-win32-arm64@0.5.38': + optional: true + + '@yuku-codegen/binding-win32-x64@0.5.38': + optional: true + + '@yuku-parser/binding-darwin-arm64@0.5.38': + optional: true + + '@yuku-parser/binding-darwin-x64@0.5.38': + optional: true + + '@yuku-parser/binding-freebsd-x64@0.5.38': + optional: true + + '@yuku-parser/binding-linux-arm-gnu@0.5.38': + optional: true + + '@yuku-parser/binding-linux-arm-musl@0.5.38': + optional: true + + '@yuku-parser/binding-linux-arm64-gnu@0.5.38': + optional: true + + '@yuku-parser/binding-linux-arm64-musl@0.5.38': + optional: true + + '@yuku-parser/binding-linux-x64-gnu@0.5.38': + optional: true + + '@yuku-parser/binding-linux-x64-musl@0.5.38': + optional: true + + '@yuku-parser/binding-win32-arm64@0.5.38': + optional: true + + '@yuku-parser/binding-win32-x64@0.5.38': + optional: true + + '@yuku-toolchain/types@0.5.37': {} + '@zeit/schemas@2.36.0': {} accepts@1.3.8: @@ -15270,7 +15535,7 @@ snapshots: webidl-conversions: 8.0.1 whatwg-mimetype: 4.0.0 whatwg-url: 15.1.0 - ws: 8.19.0 + ws: 8.21.0 xml-name-validator: 5.0.0 transitivePeerDependencies: - '@noble/hashes' @@ -16469,6 +16734,13 @@ snapshots: transitivePeerDependencies: - supports-color + rsbuild-plugin-dts@0.22.1(@rsbuild/core@2.0.15(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(core-js@3.47.0))(typescript@5.9.3): + dependencies: + '@ast-grep/napi': 0.37.0 + '@rsbuild/core': 2.0.15(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(core-js@3.47.0) + optionalDependencies: + typescript: 5.9.3 + rsbuild-plugin-dts@0.22.1(@rsbuild/core@2.0.15(@module-federation/runtime-tools@2.5.1)(core-js@3.47.0))(typescript@5.9.3): dependencies: '@ast-grep/napi': 0.37.0 @@ -16776,7 +17048,7 @@ snapshots: dependencies: '@img/colour': 1.0.0 detect-libc: 2.1.2 - semver: 7.7.3 + semver: 7.8.4 optionalDependencies: '@img/sharp-darwin-arm64': 0.34.5 '@img/sharp-darwin-x64': 0.34.5 @@ -17795,4 +18067,52 @@ snapshots: cookie: 1.1.1 youch-core: 0.3.3 + yuku-analyzer@0.5.38: + dependencies: + '@yuku-toolchain/types': 0.5.37 + optionalDependencies: + '@yuku-analyzer/binding-darwin-arm64': 0.5.38 + '@yuku-analyzer/binding-darwin-x64': 0.5.38 + '@yuku-analyzer/binding-freebsd-x64': 0.5.38 + '@yuku-analyzer/binding-linux-arm-gnu': 0.5.38 + '@yuku-analyzer/binding-linux-arm-musl': 0.5.38 + '@yuku-analyzer/binding-linux-arm64-gnu': 0.5.38 + '@yuku-analyzer/binding-linux-arm64-musl': 0.5.38 + '@yuku-analyzer/binding-linux-x64-gnu': 0.5.38 + '@yuku-analyzer/binding-linux-x64-musl': 0.5.38 + '@yuku-analyzer/binding-win32-arm64': 0.5.38 + '@yuku-analyzer/binding-win32-x64': 0.5.38 + + yuku-codegen@0.5.38: + dependencies: + '@yuku-toolchain/types': 0.5.37 + optionalDependencies: + '@yuku-codegen/binding-darwin-arm64': 0.5.38 + '@yuku-codegen/binding-darwin-x64': 0.5.38 + '@yuku-codegen/binding-freebsd-x64': 0.5.38 + '@yuku-codegen/binding-linux-arm-gnu': 0.5.38 + '@yuku-codegen/binding-linux-arm-musl': 0.5.38 + '@yuku-codegen/binding-linux-arm64-gnu': 0.5.38 + '@yuku-codegen/binding-linux-arm64-musl': 0.5.38 + '@yuku-codegen/binding-linux-x64-gnu': 0.5.38 + '@yuku-codegen/binding-linux-x64-musl': 0.5.38 + '@yuku-codegen/binding-win32-arm64': 0.5.38 + '@yuku-codegen/binding-win32-x64': 0.5.38 + + yuku-parser@0.5.38: + dependencies: + '@yuku-toolchain/types': 0.5.37 + optionalDependencies: + '@yuku-parser/binding-darwin-arm64': 0.5.38 + '@yuku-parser/binding-darwin-x64': 0.5.38 + '@yuku-parser/binding-freebsd-x64': 0.5.38 + '@yuku-parser/binding-linux-arm-gnu': 0.5.38 + '@yuku-parser/binding-linux-arm-musl': 0.5.38 + '@yuku-parser/binding-linux-arm64-gnu': 0.5.38 + '@yuku-parser/binding-linux-arm64-musl': 0.5.38 + '@yuku-parser/binding-linux-x64-gnu': 0.5.38 + '@yuku-parser/binding-linux-x64-musl': 0.5.38 + '@yuku-parser/binding-win32-arm64': 0.5.38 + '@yuku-parser/binding-win32-x64': 0.5.38 + zod@3.25.76: {} diff --git a/scripts/benchmark-yuku.mjs b/scripts/benchmark-yuku.mjs new file mode 100644 index 0000000..eb3d21d --- /dev/null +++ b/scripts/benchmark-yuku.mjs @@ -0,0 +1,307 @@ +#!/usr/bin/env node +import { spawnSync } from 'node:child_process'; +import { mkdir, mkdtemp, readdir, symlink } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { createJiti } from 'jiti'; + +const iterations = Number(process.env.BENCH_ITERATIONS ?? 250); +const sampleCount = Number(process.env.BENCH_SAMPLES ?? 24); + +const exec = (cmd, args, options = {}) => { + const result = spawnSync(cmd, args, { + stdio: ['ignore', 'pipe', 'pipe'], + encoding: 'utf8', + ...options, + }); + if (result.status !== 0) { + throw new Error( + [`Command failed: ${cmd} ${args.join(' ')}`, result.stdout, result.stderr] + .filter(Boolean) + .join('\n') + ); + } + return result.stdout; +}; + +const createOldCheckout = async repoRoot => { + const dir = await mkdtemp(path.join(tmpdir(), 'rr-yuku-before-')); + const archive = path.join(dir, 'head.tar'); + exec('git', ['archive', 'HEAD', '-o', archive], { cwd: repoRoot }); + const checkout = path.join(dir, 'repo'); + exec('mkdir', ['-p', checkout]); + exec('tar', ['-xf', archive, '-C', checkout]); + await linkNodeModules(repoRoot, checkout); + return checkout; +}; + +const linkNodeModules = async (repoRoot, checkout) => { + const sourceNodeModules = path.join(repoRoot, 'node_modules'); + const targetNodeModules = path.join(checkout, 'node_modules'); + await mkdir(targetNodeModules, { recursive: true }); + + for (const entry of await readdir(sourceNodeModules, { + withFileTypes: true, + })) { + if (entry.name === '.pnpm') { + continue; + } + const source = path.join(sourceNodeModules, entry.name); + const target = path.join(targetNodeModules, entry.name); + if (entry.name.startsWith('@') && entry.isDirectory()) { + await mkdir(target, { recursive: true }); + for (const scoped of await readdir(source)) { + const scopedTarget = path.join(target, scoped); + if (!existsSync(scopedTarget)) { + await symlink(path.join(source, scoped), scopedTarget); + } + } + continue; + } + if (!existsSync(target)) { + await symlink(source, target); + } + } + + const oldOnlyPackages = [ + '@babel/core', + '@babel/generator', + '@babel/parser', + '@babel/traverse', + '@babel/types', + 'babel-dead-code-elimination', + 'es-module-lexer', + 'esbuild', + ]; + for (const packageName of oldOnlyPackages) { + await linkPnpmPackage(sourceNodeModules, targetNodeModules, packageName); + } +}; + +const linkPnpmPackage = async ( + sourceNodeModules, + targetNodeModules, + packageName +) => { + const source = findPnpmPackage(sourceNodeModules, packageName); + if (!source) { + throw new Error(`Could not find ${packageName} in node_modules/.pnpm`); + } + const segments = packageName.split('/'); + const target = + segments.length === 1 + ? path.join(targetNodeModules, packageName) + : path.join(targetNodeModules, segments[0], segments[1]); + await mkdir(path.dirname(target), { recursive: true }); + if (!existsSync(target)) { + await symlink(source, target); + } +}; + +const findPnpmPackage = (sourceNodeModules, packageName) => { + const pnpmDir = path.join(sourceNodeModules, '.pnpm'); + const encodedName = packageName.replace('/', '+'); + const entries = spawnSync( + 'find', + [pnpmDir, '-maxdepth', '1', '-type', 'd', '-name', `${encodedName}@*`], + { + encoding: 'utf8', + } + ); + const dir = entries.stdout.split('\n').filter(Boolean).sort().at(-1); + if (!dir) { + return null; + } + return path.join(dir, 'node_modules', packageName); +}; + +const loadModules = async repoRoot => { + const jiti = createJiti(pathToFileURL(path.join(repoRoot, 'bench.mjs')).href); + return { + exportUtils: await jiti.import(path.join(repoRoot, 'src/export-utils.ts')), + compiler: await jiti.import(path.join(repoRoot, 'src/babel.ts')), + pluginUtils: await jiti.import(path.join(repoRoot, 'src/plugin-utils.ts')), + routeChunks: await jiti.import(path.join(repoRoot, 'src/route-chunks.ts')), + }; +}; + +const createSamples = () => + Array.from({ length: sampleCount }, (_, index) => { + const shared = + index % 3 === 0 + ? `const shared${index} = (value: number) => value + ${index};` + : ''; + return { + path: `/app/routes/bench-${index}.tsx`, + code: ` + import { helper${index} } from "./helpers"; + import { serverOnly${index} } from "./data.server"; + ${shared} + + type LoaderData${index} = { value: number }; + + export const loader = async () => { + return serverOnly${index}(); + }; + + export const action = async () => { + return serverOnly${index}(); + }; + + export const clientLoader = async () => { + const value = helper${index}(${index}); + return ${shared ? `shared${index}(value)` : 'value'}; + }; + + export const clientAction = async () => { + return helper${index}(${index + 1}); + }; + + export function HydrateFallback() { + return
Loading
; + } + + export function ErrorBoundary() { + return
Error
; + } + + export default function Route(props: LoaderData${index}) { + return
{props.value}
; + } + `, + }; + }); + +const hrtimeMs = start => Number(process.hrtime.bigint() - start) / 1e6; + +const measure = async fn => { + const start = process.hrtime.bigint(); + await fn(); + return hrtimeMs(start); +}; + +const runForRepo = async (label, repoRoot) => { + const { exportUtils, compiler, pluginUtils, routeChunks } = + await loadModules(repoRoot); + const samples = createSamples(); + + for (let i = 0; i < 20; i++) { + const sample = samples[i % samples.length]; + const code = await exportUtils.transformToEsm(sample.code, sample.path); + await exportUtils.getExportNames(code); + } + + const transformed = new Map(); + const transformMs = await measure(async () => { + for (let i = 0; i < iterations; i++) { + const sample = samples[i % samples.length]; + const code = await exportUtils.transformToEsm(sample.code, sample.path); + transformed.set(sample.path, code); + } + }); + + const exportScanMs = await measure(async () => { + for (let i = 0; i < iterations; i++) { + const sample = samples[i % samples.length]; + await exportUtils.getExportNames(transformed.get(sample.path)); + } + }); + + const routeTransformMs = await measure(async () => { + for (let i = 0; i < iterations; i++) { + const sample = samples[i % samples.length]; + const code = transformed.get(sample.path); + const ast = compiler.parse(code, { sourceType: 'module' }); + pluginUtils.removeExports(ast, [ + 'loader', + 'action', + 'middleware', + 'headers', + ]); + pluginUtils.transformRoute(ast); + pluginUtils.removeUnusedImports(ast); + compiler.generate(ast, { sourceMaps: true, filename: sample.path }); + } + }); + + const routeChunkMs = await measure(async () => { + const cache = new Map(); + const config = { + splitRouteModules: true, + appDirectory: '/app', + rootRouteFile: 'root.tsx', + }; + for (let i = 0; i < iterations; i++) { + const sample = samples[i % samples.length]; + const code = transformed.get(sample.path); + await routeChunks.detectRouteChunksIfEnabled( + cache, + config, + sample.path, + code + ); + await routeChunks.getRouteChunkIfEnabled( + cache, + config, + sample.path, + 'main', + code + ); + await routeChunks.getRouteChunkIfEnabled( + cache, + config, + sample.path, + 'clientLoader', + code + ); + } + }); + + return { + label, + transformMs, + exportScanMs, + routeTransformMs, + routeChunkMs, + totalMs: transformMs + exportScanMs + routeTransformMs + routeChunkMs, + }; +}; + +const format = value => value.toFixed(2).padStart(10); + +const printComparison = (before, after) => { + const rows = [ + ['transform', before.transformMs, after.transformMs], + ['export scan', before.exportScanMs, after.exportScanMs], + ['route transform', before.routeTransformMs, after.routeTransformMs], + ['route chunks', before.routeChunkMs, after.routeChunkMs], + ['total', before.totalMs, after.totalMs], + ]; + console.log( + `Benchmark: ${iterations} iterations across ${sampleCount} TSX route samples` + ); + console.log(`Node: ${process.version}`); + console.log(''); + console.log('metric before ms after ms speedup'); + for (const [name, oldMs, newMs] of rows) { + const speedup = oldMs / newMs; + console.log( + `${name.padEnd(18)}${format(oldMs)}${format(newMs)}${`${speedup.toFixed(2)}x`.padStart(10)}` + ); + } +}; + +const repoRoot = process.cwd(); +const compareHead = process.argv.includes('--compare-head'); + +if (compareHead) { + const oldRepo = await createOldCheckout(repoRoot); + const before = await runForRepo('before', oldRepo); + const after = await runForRepo('after', repoRoot); + printComparison(before, after); +} else { + const result = await runForRepo('current', repoRoot); + console.log(JSON.stringify(result, null, 2)); +} diff --git a/src/babel.ts b/src/babel.ts index 4c52d9c..b3cdfb7 100644 --- a/src/babel.ts +++ b/src/babel.ts @@ -1,18 +1,55 @@ -import type { types as Babel } from '@babel/core'; -import generatorPkg from '@babel/generator'; -import { type ParseResult, parse } from '@babel/parser'; -/* eslint-disable @typescript-eslint/consistent-type-imports */ -import type { NodePath } from '@babel/traverse'; -import traversePkg from '@babel/traverse'; -import * as t from '@babel/types'; +import { + parse as yukuParse, + walk, + type ParseOptions, + type ParseResult, +} from 'yuku-parser'; +import { strip } from 'yuku-codegen'; -// Babel packages are CommonJS. Depending on the bundler/runtime interop mode, -// their "default" may either be the exported function or a module namespace. -// We normalize to always get the callable function. -const traverse: typeof import('@babel/traverse').default = - (traversePkg as any).default ?? (traversePkg as any); -const generate: typeof import('@babel/generator').default = - (generatorPkg as any).default ?? (generatorPkg as any); +export type Babel = any; +export type NodePath = T; -export { traverse, generate, parse, t }; -export type { Babel, NodePath, ParseResult }; +export const parse = ( + code: string, + options: ParseOptions = {} +): ParseResult => { + const result = yukuParse(code, { + sourceType: options.sourceType ?? 'module', + lang: options.lang ?? 'tsx', + preserveParens: false, + }); + const errors = result.diagnostics.filter( + diagnostic => diagnostic.severity === 'error' + ); + if (errors.length > 0) { + throw new Error(errors.map(error => error.message).join('\n')); + } + return result; +}; + +export const traverse: typeof walk = walk; + +export const generate = ( + ast: ParseResult | { type: 'Program' }, + options: { + sourceMaps?: boolean; + filename?: string; + sourceFileName?: string; + } = {} +): { code: string; map: any } => { + const result = 'program' in ast ? ast : { program: ast, lineStarts: [] }; + const generated = strip(result.program as any, { + comments: 'some', + sourceMaps: options.sourceMaps + ? { + lineStarts: result.lineStarts, + file: options.filename, + sourceFileName: options.sourceFileName, + } + : undefined, + }); + return { code: generated.code, map: generated.map as any }; +}; + +export const t = {}; +export type { ParseResult }; diff --git a/src/export-utils.ts b/src/export-utils.ts index d5f67d4..f7b0743 100644 --- a/src/export-utils.ts +++ b/src/export-utils.ts @@ -1,58 +1,174 @@ import { readFile } from 'node:fs/promises'; -import { extname } from 'pathe'; -import * as esbuild from 'esbuild'; -import { init, parse as parseExports } from 'es-module-lexer'; -import { JS_LOADERS } from './constants.js'; +import { langFromPath, parse } from 'yuku-parser'; +import { strip } from 'yuku-codegen'; -const getEsbuildLoader = (resourcePath: string): esbuild.Loader => { - const ext = extname(resourcePath) as keyof typeof JS_LOADERS; - return JS_LOADERS[ext] ?? 'js'; +type AnyNode = Record; + +const parseProgram = (code: string, resourcePath?: string) => { + const result = parse(code, { + sourceType: 'module', + lang: resourcePath ? langFromPath(resourcePath) : 'tsx', + preserveParens: false, + }); + const errors = result.diagnostics.filter( + diagnostic => diagnostic.severity === 'error' + ); + if (errors.length > 0) { + throw new Error(errors.map(error => error.message).join('\n')); + } + return result.program as AnyNode; +}; + +const getIdentifierNamesFromPattern = ( + pattern: AnyNode | null | undefined, + names: string[] = [] +): string[] => { + if (!pattern) { + return names; + } + if (pattern.type === 'Identifier') { + names.push(pattern.name); + return names; + } + if (pattern.type === 'RestElement') { + return getIdentifierNamesFromPattern(pattern.argument, names); + } + if (pattern.type === 'AssignmentPattern') { + return getIdentifierNamesFromPattern(pattern.left, names); + } + if (pattern.type === 'ArrayPattern') { + for (const element of pattern.elements ?? []) { + getIdentifierNamesFromPattern(element, names); + } + return names; + } + if (pattern.type === 'ObjectPattern') { + for (const property of pattern.properties ?? []) { + if (property.type === 'RestElement') { + getIdentifierNamesFromPattern(property.argument, names); + } else { + getIdentifierNamesFromPattern(property.value, names); + } + } + } + return names; +}; + +const getExportedName = (node: AnyNode): string | null => { + if (!node) { + return null; + } + if (node.type === 'Identifier') { + return node.name; + } + if (node.type === 'Literal' || node.type === 'StringLiteral') { + return String(node.value); + } + return null; +}; + +const isTypeOnlyExport = (node: AnyNode): boolean => + node.exportKind === 'type' || node.type === 'TSExportAssignment'; + +const collectExportNames = (program: AnyNode): string[] => { + const exportNames = new Set(); + for (const statement of program.body ?? []) { + if (statement.type === 'ExportAllDeclaration') { + const exported = getExportedName(statement.exported); + if (exported) { + exportNames.add(exported); + } + continue; + } + + if (statement.type === 'ExportDefaultDeclaration') { + exportNames.add('default'); + continue; + } + + if (statement.type !== 'ExportNamedDeclaration') { + continue; + } + if (isTypeOnlyExport(statement)) { + continue; + } + + const declaration = statement.declaration; + if (declaration) { + if (declaration.type === 'VariableDeclaration') { + for (const declarator of declaration.declarations ?? []) { + for (const name of getIdentifierNamesFromPattern(declarator.id)) { + exportNames.add(name); + } + } + } else if ( + (declaration.type === 'FunctionDeclaration' || + declaration.type === 'ClassDeclaration') && + declaration.id?.name + ) { + exportNames.add(declaration.id.name); + } + continue; + } + + for (const specifier of statement.specifiers ?? []) { + if (specifier.exportKind === 'type') { + continue; + } + const exported = getExportedName(specifier.exported); + if (exported) { + exportNames.add(exported); + } + } + } + return Array.from(exportNames); +}; + +const collectExportAllModules = (program: AnyNode): string[] => { + const modules: string[] = []; + for (const statement of program.body ?? []) { + if (statement.type !== 'ExportAllDeclaration') { + continue; + } + if (statement.exported) { + continue; + } + const source = statement.source?.value; + if (typeof source === 'string') { + modules.push(source); + } + } + return modules; }; export const transformToEsm = async ( code: string, resourcePath: string ): Promise => { - return ( - await esbuild.transform(code, { - jsx: 'automatic', - format: 'esm', - platform: 'neutral', - loader: getEsbuildLoader(resourcePath), - }) - ).code; + const result = parse(code, { + sourceType: 'module', + lang: langFromPath(resourcePath), + preserveParens: false, + }); + const transformed = strip(result.program, { comments: 'some' }); + if (transformed.errors.length > 0) { + throw new Error(transformed.errors.map(error => error.message).join('\n')); + } + return transformed.code; }; export const getExportNames = async (code: string): Promise => { - await init; - const [, exportSpecifiers] = await parseExports(code); - return Array.from( - new Set(exportSpecifiers.map(specifier => specifier.n).filter(Boolean)) - ); + return collectExportNames(parseProgram(code)); }; export const getExportNamesAndExportAll = async ( code: string ): Promise<{ exportNames: string[]; exportAllModules: string[] }> => { - await init; - const [imports, exportSpecifiers] = await parseExports(code); - const exportNames = new Set(); - for (const specifier of exportSpecifiers) { - if (specifier.n) { - exportNames.add(specifier.n); - } - } - const exportAllModules: string[] = []; - for (const entry of imports) { - if (!entry.n) { - continue; - } - const statement = code.slice(entry.ss, entry.se); - if (/^\s*export\s*\*\s*from\s*['"]/.test(statement)) { - exportAllModules.push(entry.n); - } - } - return { exportNames: Array.from(exportNames), exportAllModules }; + const program = parseProgram(code); + return { + exportNames: collectExportNames(program), + exportAllModules: collectExportAllModules(program), + }; }; export const getRouteModuleExports = async ( diff --git a/src/index.ts b/src/index.ts index 651343a..67ae7d9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1688,8 +1688,8 @@ export const pluginReactRouter = ( // In SPA mode, server-only route exports are invalid (except root `loader`), // and `HydrateFallback` is only allowed on the root route. // - // Important: `es-module-lexer` can't parse TS/TSX directly, so we scan - // the ESBuild-transformed JS output. + // Scan the Yuku-stripped output so TypeScript-only exports do not + // participate in route export validation. if (args.environment.name === 'web' && !ssr && isSpaMode) { const exportNames = await getExportNames(code); diff --git a/src/plugin-utils.ts b/src/plugin-utils.ts index 519c18a..836a733 100644 --- a/src/plugin-utils.ts +++ b/src/plugin-utils.ts @@ -1,24 +1,23 @@ -import { - deadCodeElimination, - findReferencedIdentifiers, -} from 'babel-dead-code-elimination'; import { normalize } from 'pathe'; import { existsSync } from 'node:fs'; -import type { Babel, NodePath, ParseResult } from './babel.js'; -import { t, traverse } from './babel.js'; +import { walk, type ParseResult } from 'yuku-parser'; import { NAMED_COMPONENT_EXPORTS, JS_EXTENSIONS } from './constants.js'; +type AnyNode = Record; + +const getProgram = (ast: ParseResult | AnyNode): AnyNode => + (ast as ParseResult).program ?? ast; + export function validateDestructuredExports( - id: Babel.ArrayPattern | Babel.ObjectPattern, + id: AnyNode, exportsToRemove: string[] ): void { if (id.type === 'ArrayPattern') { - for (const element of id.elements) { + for (const element of id.elements ?? []) { if (!element) { continue; } - // [ foo ] if ( element.type === 'Identifier' && exportsToRemove.includes(element.name) @@ -26,7 +25,6 @@ export function validateDestructuredExports( throw invalidDestructureError(element.name); } - // [ ...foo ] if ( element.type === 'RestElement' && element.argument.type === 'Identifier' && @@ -35,8 +33,6 @@ export function validateDestructuredExports( throw invalidDestructureError(element.argument.name); } - // [ [...] ] - // [ {...} ] if (element.type === 'ArrayPattern' || element.type === 'ObjectPattern') { validateDestructuredExports(element, exportsToRemove); } @@ -44,16 +40,12 @@ export function validateDestructuredExports( } if (id.type === 'ObjectPattern') { - for (const property of id.properties) { + for (const property of id.properties ?? []) { if (!property) { continue; } - if ( - property.type === 'ObjectProperty' && - property.key.type === 'Identifier' - ) { - // { foo } + if (property.type === 'Property') { if ( property.value.type === 'Identifier' && exportsToRemove.includes(property.value.name) @@ -61,8 +53,6 @@ export function validateDestructuredExports( throw invalidDestructureError(property.value.name); } - // { foo: [...] } - // { foo: {...} } if ( property.value.type === 'ArrayPattern' || property.value.type === 'ObjectPattern' @@ -71,7 +61,6 @@ export function validateDestructuredExports( } } - // { ...foo } if ( property.type === 'RestElement' && property.argument.type === 'Identifier' && @@ -87,14 +76,12 @@ export function invalidDestructureError(name: string): Error { return new Error(`Cannot remove destructured export "${name}"`); } -export function toFunctionExpression(decl: Babel.FunctionDeclaration): any { - return t.functionExpression( - decl.id, - decl.params, - decl.body, - decl.generator, - decl.async - ); +export function toFunctionExpression(decl: AnyNode): AnyNode { + return { + ...decl, + type: 'FunctionExpression', + declare: undefined, + }; } export function combineURLs(baseURL: string, relativeURL: string): string { @@ -173,278 +160,503 @@ export function generateWithProps() { `; } +const removeFromArray = (array: T[], value: T): void => { + const index = array.indexOf(value); + if (index >= 0) { + array.splice(index, 1); + } +}; + +const getPatternIdentifierNames = ( + pattern: AnyNode | null | undefined, + names = new Set() +): Set => { + if (!pattern) { + return names; + } + if (pattern.type === 'Identifier') { + names.add(pattern.name); + return names; + } + if (pattern.type === 'RestElement') { + return getPatternIdentifierNames(pattern.argument, names); + } + if (pattern.type === 'AssignmentPattern') { + return getPatternIdentifierNames(pattern.left, names); + } + if (pattern.type === 'ArrayPattern') { + for (const element of pattern.elements ?? []) { + getPatternIdentifierNames(element, names); + } + return names; + } + if (pattern.type === 'ObjectPattern') { + for (const property of pattern.properties ?? []) { + if (property.type === 'RestElement') { + getPatternIdentifierNames(property.argument, names); + } else { + getPatternIdentifierNames(property.value, names); + } + } + } + return names; +}; + +const getDeclaredNames = (node: AnyNode): Set => { + const names = new Set(); + if (node.type === 'VariableDeclaration') { + for (const declarator of node.declarations ?? []) { + getPatternIdentifierNames(declarator.id, names); + } + } else if ( + (node.type === 'FunctionDeclaration' || node.type === 'ClassDeclaration') && + node.id?.name + ) { + names.add(node.id.name); + } else if (node.type === 'ImportDeclaration') { + for (const specifier of node.specifiers ?? []) { + if (specifier.local?.name) { + names.add(specifier.local.name); + } + } + } + return names; +}; + +const isIdentifierDeclaration = (node: AnyNode, parent: AnyNode | null) => { + if (!parent || node.type !== 'Identifier') { + return false; + } + if ( + (parent.type === 'FunctionDeclaration' || + parent.type === 'FunctionExpression' || + parent.type === 'ClassDeclaration' || + parent.type === 'ClassExpression') && + parent.id === node + ) { + return true; + } + if (parent.type === 'VariableDeclarator') { + return getPatternIdentifierNames(parent.id).has(node.name); + } + if ( + (parent.type === 'ImportSpecifier' || + parent.type === 'ImportDefaultSpecifier' || + parent.type === 'ImportNamespaceSpecifier') && + parent.local === node + ) { + return true; + } + if ( + (parent.type === 'FunctionDeclaration' || + parent.type === 'FunctionExpression' || + parent.type === 'ArrowFunctionExpression') && + (parent.params ?? []).some((param: AnyNode) => + getPatternIdentifierNames(param).has(node.name) + ) + ) { + return true; + } + return false; +}; + +const isNonReferenceIdentifier = (node: AnyNode, parent: AnyNode | null) => { + if (!parent || node.type !== 'Identifier') { + return false; + } + if (isIdentifierDeclaration(node, parent)) { + return true; + } + if ( + parent.type === 'MemberExpression' && + parent.property === node && + !parent.computed + ) { + return true; + } + if ( + parent.type === 'Property' && + parent.key === node && + !parent.computed && + !parent.shorthand + ) { + return true; + } + if ( + parent.type === 'MethodDefinition' && + parent.key === node && + !parent.computed + ) { + return true; + } + if (parent.type === 'LabeledStatement' || parent.type === 'BreakStatement') { + return true; + } + return false; +}; + +const collectReferencedNames = (program: AnyNode): Set => { + const referenced = new Set(); + walk(program as any, { + Identifier(node: AnyNode, ctx: any) { + const parent = ctx.parent as AnyNode | null; + if (!isNonReferenceIdentifier(node, parent)) { + referenced.add(node.name); + } + }, + ExportSpecifier(node: AnyNode) { + if (node.local?.name && node.exportKind !== 'type') { + referenced.add(node.local.name); + } + }, + }); + return referenced; +}; + +const getExportedName = (specifier: AnyNode): string | null => { + const exported = specifier.exported; + if (!exported) { + return null; + } + if (exported.type === 'Identifier') { + return exported.name; + } + if (exported.type === 'Literal') { + return String(exported.value); + } + return null; +}; + +const collectExportedLocalNames = (program: AnyNode): Set => { + const names = new Set(); + for (const statement of program.body ?? []) { + if (statement.type === 'ExportDefaultDeclaration') { + if (statement.declaration?.id?.name) { + names.add(statement.declaration.id.name); + } + continue; + } + if (statement.type !== 'ExportNamedDeclaration') { + continue; + } + if (statement.declaration) { + for (const name of getDeclaredNames(statement.declaration)) { + names.add(name); + } + } + for (const specifier of statement.specifiers ?? []) { + if (specifier.local?.name && specifier.exportKind !== 'type') { + names.add(specifier.local.name); + } + } + } + return names; +}; + +const removeUnusedTopLevelDeclarations = (program: AnyNode): void => { + let changed = true; + while (changed) { + changed = false; + const referenced = collectReferencedNames(program); + const exported = collectExportedLocalNames(program); + for (const statement of [...program.body]) { + if (statement.type !== 'VariableDeclaration') { + if ( + (statement.type === 'FunctionDeclaration' || + statement.type === 'ClassDeclaration') && + statement.id?.name && + !referenced.has(statement.id.name) && + !exported.has(statement.id.name) + ) { + removeFromArray(program.body, statement); + changed = true; + } + continue; + } + statement.declarations = statement.declarations.filter( + (declarator: AnyNode) => { + const names = getPatternIdentifierNames(declarator.id); + return Array.from(names).some( + name => referenced.has(name) || exported.has(name) + ); + } + ); + if (statement.declarations.length === 0) { + removeFromArray(program.body, statement); + changed = true; + } + } + } +}; + export const removeExports = ( - ast: ParseResult, + ast: ParseResult | AnyNode, exportsToRemove: string[] ): void => { - const previouslyReferencedIdentifiers = findReferencedIdentifiers(ast); + const program = getProgram(ast); let exportsFiltered = false; - const markedForRemoval = new Set>(); - // Keep track of identifiers referenced by removed exports, - // e.g. export { localName as exportName }, export default function localName const removedExportLocalNames = new Set(); - traverse(ast, { - ExportDeclaration(path: NodePath) { - // export { foo }; - // export { bar } from "./module"; - if (path.node.type === 'ExportNamedDeclaration') { - if (path.node.specifiers.length) { - //@ts-ignore - path.node.specifiers = path.node.specifiers.filter( - ( - specifier: - | Babel.ExportSpecifier - | Babel.ExportDefaultSpecifier - | Babel.ExportNamespaceSpecifier - ) => { - // Filter out individual specifiers - if ( - specifier.type === 'ExportSpecifier' && - specifier.exported.type === 'Identifier' - ) { - if (exportsToRemove.includes(specifier.exported.name)) { - exportsFiltered = true; - // Track the local identifier if it's different from the exported name - if ( - specifier.local && - specifier.local.type === 'Identifier' && - specifier.local.name !== specifier.exported.name - ) { - removedExportLocalNames.add(specifier.local.name); - } - return false; - } - } + for (const statement of [...program.body]) { + if (statement.type === 'ExportNamedDeclaration') { + if (statement.specifiers?.length) { + statement.specifiers = statement.specifiers.filter( + (specifier: AnyNode) => { + if (specifier.type !== 'ExportSpecifier') { return true; } - ); - // Remove the entire export statement if all specifiers were removed - if (path.node.specifiers.length === 0) { - markedForRemoval.add(path); + const exportedName = getExportedName(specifier); + if (exportedName && exportsToRemove.includes(exportedName)) { + exportsFiltered = true; + if (specifier.local?.name) { + removedExportLocalNames.add(specifier.local.name); + } + return false; + } + return true; } + ); + if (statement.specifiers.length === 0 && !statement.declaration) { + removeFromArray(program.body, statement); } + } - // export const foo = ...; - // export const [ foo ] = ...; - if (path.node.declaration?.type === 'VariableDeclaration') { - const declaration = path.node.declaration; - declaration.declarations = declaration.declarations.filter( - (declaration: Babel.VariableDeclarator) => { - // export const foo = ...; - // export const foo = ..., bar = ...; - if ( - declaration.id.type === 'Identifier' && - exportsToRemove.includes(declaration.id.name) - ) { - // Filter out individual variables + const declaration = statement.declaration; + if (declaration?.type === 'VariableDeclaration') { + declaration.declarations = declaration.declarations.filter( + (declarator: AnyNode) => { + if (declarator.id.type === 'Identifier') { + if (exportsToRemove.includes(declarator.id.name)) { exportsFiltered = true; + removedExportLocalNames.add(declarator.id.name); return false; } - - // export const [ foo ] = ...; - // export const { foo } = ...; - if ( - declaration.id.type === 'ArrayPattern' || - declaration.id.type === 'ObjectPattern' - ) { - // NOTE: These exports cannot be safely removed, so instead we - // validate them to ensure that any exports that are intended to - // be removed are not present - validateDestructuredExports(declaration.id, exportsToRemove); - } - return true; } - ); - // Remove the entire export statement if all variables were removed - if (declaration.declarations.length === 0) { - markedForRemoval.add(path); - } - } - // export function foo() {} - if (path.node.declaration?.type === 'FunctionDeclaration') { - const id = path.node.declaration.id; - if (id && exportsToRemove.includes(id.name)) { - markedForRemoval.add(path); - } - } - - // export class Foo() {} - if (path.node.declaration?.type === 'ClassDeclaration') { - const id = path.node.declaration.id; - if (id && exportsToRemove.includes(id.name)) { - markedForRemoval.add(path); + validateDestructuredExports(declarator.id, exportsToRemove); + return true; } + ); + if (declaration.declarations.length === 0) { + removeFromArray(program.body, statement); } } - // export default ...; if ( - path.node.type === 'ExportDefaultDeclaration' && - exportsToRemove.includes('default') + (declaration?.type === 'FunctionDeclaration' || + declaration?.type === 'ClassDeclaration') && + declaration.id?.name && + exportsToRemove.includes(declaration.id.name) ) { - markedForRemoval.add(path); - // Track the identifier being exported as default - if (path.node.declaration) { - if (path.node.declaration.type === 'Identifier') { - removedExportLocalNames.add(path.node.declaration.name); - } else if ( - (path.node.declaration.type === 'FunctionDeclaration' || - path.node.declaration.type === 'ClassDeclaration') && - path.node.declaration.id - ) { - removedExportLocalNames.add(path.node.declaration.id.name); - } - } - } - }, - }); - - // Remove top-level property assignments to removed exports. Handles - // `clientLoader.hydrate = true`, `Component.displayName = "..."`, etc. - traverse(ast, { - ExpressionStatement(path: NodePath) { - // Only handle top-level statements - if (!path.parentPath.isProgram()) { - return; - } - - const expr = path.node.expression; - if (expr.type !== 'AssignmentExpression') { - return; + removedExportLocalNames.add(declaration.id.name); + removeFromArray(program.body, statement); } + } - const left = expr.left; - if ( - left.type === 'MemberExpression' && - left.object.type === 'Identifier' && - (exportsToRemove.includes(left.object.name) || - removedExportLocalNames.has(left.object.name)) - ) { - markedForRemoval.add(path as any); + if ( + statement.type === 'ExportDefaultDeclaration' && + exportsToRemove.includes('default') + ) { + const declaration = statement.declaration; + if (declaration?.type === 'Identifier') { + removedExportLocalNames.add(declaration.name); + } else if (declaration?.id?.name) { + removedExportLocalNames.add(declaration.id.name); } - }, - }); + removeFromArray(program.body, statement); + } + } - if (markedForRemoval.size > 0 || exportsFiltered) { - for (const path of markedForRemoval) { - path.remove(); + for (const statement of [...program.body]) { + const expression = + statement.type === 'ExpressionStatement' ? statement.expression : null; + const left = + expression?.type === 'AssignmentExpression' ? expression.left : null; + if ( + left?.type === 'MemberExpression' && + left.object?.type === 'Identifier' && + (exportsToRemove.includes(left.object.name) || + removedExportLocalNames.has(left.object.name)) + ) { + removeFromArray(program.body, statement); } + } - // Run dead code elimination on any newly unreferenced identifiers - deadCodeElimination(ast, previouslyReferencedIdentifiers); + if (exportsFiltered || removedExportLocalNames.size > 0) { + removeUnusedTopLevelDeclarations(program); } }; -export const removeUnusedImports = (ast: ParseResult): void => { - let scopeCrawled = false; - traverse(ast, { - Program(path: NodePath) { - if (!scopeCrawled) { - path.scope.crawl(); - scopeCrawled = true; - } - }, - ImportDeclaration(path: NodePath) { - if (path.node.specifiers.length === 0) { - return; - } - - const specifierPaths = path.get('specifiers') as NodePath< - | Babel.ImportSpecifier - | Babel.ImportDefaultSpecifier - | Babel.ImportNamespaceSpecifier - >[]; - - for (const specifierPath of specifierPaths) { - const local = specifierPath.node.local; - const binding = local ? path.scope.getBinding(local.name) : null; - if (!binding || !binding.referenced) { - specifierPath.remove(); +export const removeUnusedImports = (ast: ParseResult | AnyNode): void => { + const program = getProgram(ast); + const referenced = collectReferencedNames(program); + for (const statement of [...program.body]) { + if (statement.type !== 'ImportDeclaration') { + continue; + } + if ((statement.specifiers ?? []).length === 0) { + continue; + } + statement.specifiers = (statement.specifiers ?? []).filter( + (specifier: AnyNode) => { + if (specifier.importKind === 'type') { + return false; } + return !specifier.local?.name || referenced.has(specifier.local.name); } + ); + if (statement.specifiers.length === 0) { + removeFromArray(program.body, statement); + } + } +}; - if (path.node.specifiers.length === 0) { - path.remove(); - } +const identifier = (name: string): AnyNode => ({ + type: 'Identifier', + start: 0, + end: 0, + name, + decorators: [], + optional: false, + typeAnnotation: null, +}); + +const literal = (value: string): AnyNode => ({ + type: 'Literal', + start: 0, + end: 0, + value, + raw: JSON.stringify(value), +}); + +const callExpression = (callee: AnyNode, args: AnyNode[]): AnyNode => ({ + type: 'CallExpression', + start: 0, + end: 0, + callee, + arguments: args, + optional: false, +}); + +const importDeclaration = ( + specifiers: Array<{ local: string; imported: string }>, + source: string +): AnyNode => ({ + type: 'ImportDeclaration', + start: 0, + end: 0, + specifiers: specifiers.map(specifier => ({ + type: 'ImportSpecifier', + start: 0, + end: 0, + imported: identifier(specifier.imported), + local: identifier(specifier.local), + importKind: 'value', + })), + source: literal(source), + attributes: [], + phase: null, + importKind: 'value', +}); + +const variableDeclaration = (name: string, init: AnyNode): AnyNode => ({ + type: 'VariableDeclaration', + start: 0, + end: 0, + kind: 'const', + declare: false, + declarations: [ + { + type: 'VariableDeclarator', + start: 0, + end: 0, + id: identifier(name), + init, + definite: false, + }, + ], +}); + +const collectUsedNames = (program: AnyNode): Set => { + const names = new Set(); + walk(program as any, { + Identifier(node: AnyNode) { + names.add(node.name); }, }); + return names; }; -export const transformRoute = (ast: ParseResult): void => { - const hocs: Array<[string, Babel.Identifier]> = []; - function getHocUid(path: NodePath, hocName: string) { - const uid = path.scope.generateUidIdentifier(hocName); +export const transformRoute = (ast: ParseResult | AnyNode): void => { + const program = getProgram(ast); + const usedNames = collectUsedNames(program); + const hocs: Array<[string, string]> = []; + + function getHocUid(hocName: string) { + let uid = `_${hocName}`; + let index = 2; + while (usedNames.has(uid)) { + uid = `_${hocName}${index++}`; + } + usedNames.add(uid); hocs.push([hocName, uid]); - return uid; - } - - traverse(ast, { - ExportDeclaration(path: NodePath) { - if (path.isExportDefaultDeclaration()) { - const declaration = path.get('declaration'); - // prettier-ignore - const expr = - declaration.isExpression() ? declaration.node : - declaration.isFunctionDeclaration() ? toFunctionExpression(declaration.node) : - undefined - if (expr) { - const uid = getHocUid(path, 'withComponentProps'); - declaration.replaceWith(t.callExpression(uid, [expr]) as any); - } - return; - } + return identifier(uid); + } - if (path.isExportNamedDeclaration()) { - const decl = path.get('declaration'); - - if (decl.isVariableDeclaration()) { - // biome-ignore lint/complexity/noForEach: - decl.get('declarations').forEach((varDeclarator: NodePath) => { - const id = varDeclarator.get('id') as any; - const init = varDeclarator.get('init') as any; - const expr = init.node as any; - if (!expr) return; - if (!id.isIdentifier()) return; - const { name } = id.node; - if (!isNamedComponentExport(name)) return; - - const uid = getHocUid(path, `with${name}Props`); - init.replaceWith(t.callExpression(uid, [expr])); - }); - return; - } + for (const statement of program.body ?? []) { + if (statement.type === 'ExportDefaultDeclaration') { + const declaration = statement.declaration; + const expr = + declaration?.type === 'FunctionDeclaration' + ? toFunctionExpression(declaration) + : declaration; + if (expr && expr.type !== 'ClassDeclaration') { + const uid = getHocUid('withComponentProps'); + statement.declaration = callExpression(uid, [expr]); + } + continue; + } - if (decl.isFunctionDeclaration()) { - const { id } = decl.node; - if (!id) return; - const { name } = id; - if (!isNamedComponentExport(name)) return; - - const uid = getHocUid(path, `with${name}Props`); - decl.replaceWith( - t.variableDeclaration('const', [ - t.variableDeclarator( - t.identifier(name), - t.callExpression(uid, [toFunctionExpression(decl.node)]) - ), - ]) as any - ); + if (statement.type !== 'ExportNamedDeclaration') { + continue; + } + const declaration = statement.declaration; + if (declaration?.type === 'VariableDeclaration') { + for (const declarator of declaration.declarations ?? []) { + if ( + declarator.id?.type !== 'Identifier' || + !declarator.init || + !isNamedComponentExport(declarator.id.name) + ) { + continue; } + const uid = getHocUid(`with${declarator.id.name}Props`); + declarator.init = callExpression(uid, [declarator.init]); } - }, - }); + continue; + } + + if ( + declaration?.type === 'FunctionDeclaration' && + declaration.id?.name && + isNamedComponentExport(declaration.id.name) + ) { + const name = declaration.id.name; + const uid = getHocUid(`with${name}Props`); + statement.declaration = variableDeclaration( + name, + callExpression(uid, [toFunctionExpression(declaration)]) + ); + } + } if (hocs.length > 0) { - ast.program.body.unshift( - t.importDeclaration( - hocs.map(([name, identifier]) => - t.importSpecifier(identifier, t.identifier(name)) - ), - t.stringLiteral('virtual/react-router/with-props') - ) as any + program.body.unshift( + importDeclaration( + hocs.map(([name, local]) => ({ imported: name, local })), + 'virtual/react-router/with-props' + ) ); } }; diff --git a/src/route-chunks.ts b/src/route-chunks.ts index 2339729..32024f1 100644 --- a/src/route-chunks.ts +++ b/src/route-chunks.ts @@ -1,7 +1,14 @@ -import type { NodePath } from './babel.js'; -import { generate, parse, t, traverse } from './babel.js'; +import { + Analyzer, + type Module, + type Symbol as YukuSymbol, +} from 'yuku-analyzer'; +import { strip } from 'yuku-codegen'; +import { walk } from 'yuku-parser'; import { normalize, relative, resolve } from 'pathe'; +type AnyNode = Record; + export type RouteChunkExportName = | 'clientAction' | 'clientLoader' @@ -85,75 +92,114 @@ const getOrSetFromCache = ( return value; }; -const codeToAst = ( +type AnalyzedModule = { + module: Module; + program: AnyNode; +}; + +const analyzeCode = ( code: string, cache: RouteChunkCache | undefined, cacheKey: string -) => { - return structuredClone( - getOrSetFromCache(cache, `${cacheKey}::codeToAst`, code, () => - parse(code, { sourceType: 'module' }) - ) - ); +): AnalyzedModule => { + return getOrSetFromCache(cache, `${cacheKey}::analyzeCode`, code, () => { + const analyzer = new Analyzer(); + const module = analyzer.addFile(cacheKey, code, { + lang: 'tsx', + sourceType: 'module', + preserveParens: false, + }); + const errors = module.diagnostics.filter( + diagnostic => diagnostic.severity === 'error' + ); + if (errors.length > 0) { + throw new Error(errors.map(error => error.message).join('\n')); + } + return { module, program: module.ast as AnyNode }; + }); }; -const assertNodePath: ( - path: NodePath | NodePath[] | null | undefined -) => asserts path is NodePath = path => { - invariant( - path && !Array.isArray(path), - `Expected a Path, but got ${Array.isArray(path) ? 'an array' : path}` - ); +const cloneProgram = ( + code: string, + cache: RouteChunkCache | undefined, + cacheKey: string +): AnyNode => structuredClone(analyzeCode(code, cache, cacheKey).program); + +type ExportDependencies = { + topLevelStatements: Set; + topLevelNonModuleStatements: Set; + importedIdentifierNames: Set; + exportedVariableDeclarators: Set; }; -const isNodePathWithNode = (path: unknown): path is NodePath => { - if (!path || typeof path !== 'object' || Array.isArray(path)) { - return false; - } - if (!('node' in path)) { - return false; +const getTopLevelStatementForNode = ( + module: Module, + node: AnyNode +): AnyNode => { + let current: AnyNode = node; + let parent = module.parentOf(current as never) as AnyNode | null; + while (parent && parent.type !== 'Program') { + current = parent; + parent = module.parentOf(current as never) as AnyNode | null; } - return Boolean((path as { node?: unknown }).node); + invariant(parent?.type === 'Program', 'Expected node to be within Program'); + return current; }; -const assertNodePathIsStatement: ( - path: NodePath | NodePath[] | null | undefined -) => asserts path is NodePath = path => { - invariant( - path && !Array.isArray(path) && t.isStatement(path.node), - `Expected a Statement path, but got ${ - Array.isArray(path) ? 'an array' : path?.node?.type - }` - ); +const addTopLevelStatement = ( + module: Module, + dependencies: ExportDependencies, + node: AnyNode +) => { + const statement = getTopLevelStatementForNode(module, node); + dependencies.topLevelStatements.add(statement); + if ( + statement.type !== 'ImportDeclaration' && + !statement.type.startsWith('Export') + ) { + dependencies.topLevelNonModuleStatements.add(statement); + } }; -const assertNodePathIsVariableDeclarator: ( - path: NodePath | NodePath[] | null | undefined -) => asserts path is NodePath = path => { - invariant( - path && !Array.isArray(path) && t.isVariableDeclarator(path.node), - `Expected a VariableDeclarator path, but got ${ - Array.isArray(path) ? 'an array' : path?.node?.type - }` - ); +const getVariableDeclaratorForNode = ( + module: Module, + node: AnyNode +): AnyNode | null => { + let current: AnyNode | null = node; + while (current) { + if (current.type === 'VariableDeclarator') { + return current; + } + current = module.parentOf(current as never) as AnyNode | null; + } + return null; }; -const assertNodePathIsPattern: ( - path: NodePath | NodePath[] | null | undefined -) => asserts path is NodePath = path => { - invariant( - path && !Array.isArray(path) && t.isPattern(path.node), - `Expected a Pattern path, but got ${ - Array.isArray(path) ? 'an array' : path?.node?.type - }` - ); +const getExportedName = (exported: AnyNode): string => { + if (exported.type === 'Identifier') { + return exported.name; + } + return String(exported.value); }; -type ExportDependencies = { - topLevelStatements: Set; - topLevelNonModuleStatements: Set; - importedIdentifierNames: Set; - exportedVariableDeclarators: Set; +const sameNode = (left: AnyNode, right: AnyNode): boolean => + left.type === right.type && + left.start === right.start && + left.end === right.end; + +const setsIntersect = (set1: Set, set2: Set) => { + let smallerSet = set1; + let largerSet = set2; + if (set1.size > set2.size) { + smallerSet = set2; + largerSet = set1; + } + for (const element of smallerSet) { + if (largerSet.has(element)) { + return true; + } + } + return false; }; const getExportDependencies = ( @@ -166,296 +212,96 @@ const getExportDependencies = ( `${cacheKey}::getExportDependencies`, code, () => { + const { module } = analyzeCode(code, cache, cacheKey); const exportDependencies = new Map(); - const ast = codeToAst(code, cache, cacheKey); - function handleExport( + const handleExport = ( exportName: string, - exportPath: NodePath, - identifiersPath: NodePath = exportPath - ) { - const identifiers = getDependentIdentifiersForPath(identifiersPath); - const topLevelStatements = new Set([ - exportPath.node as t.Statement, - ...getTopLevelStatementsForPaths(identifiers), - ]); - const topLevelNonModuleStatements = new Set( - Array.from(topLevelStatements).filter( - statement => - !t.isImportDeclaration(statement) && - !t.isExportDeclaration(statement) - ) - ); - const importedIdentifierNames = new Set(); - for (const identifier of identifiers) { - if ( - t.isIdentifier(identifier.node) && - identifier.parentPath?.parentPath?.isImportDeclaration() - ) { - importedIdentifierNames.add(identifier.node.name); - } - } - const exportedVariableDeclarators = new Set(); - for (const identifier of identifiers) { - if (identifier.parentPath?.isVariableDeclarator()) { - const parentPath = identifier.parentPath; - if (parentPath.parentPath?.parentPath?.isExportNamedDeclaration()) { - exportedVariableDeclarators.add( - parentPath.node as t.VariableDeclarator - ); - continue; - } + exportNode: AnyNode, + localSymbol: YukuSymbol | null + ) => { + const dependencies: ExportDependencies = { + topLevelStatements: new Set(), + topLevelNonModuleStatements: new Set(), + importedIdentifierNames: new Set(), + exportedVariableDeclarators: new Set(), + }; + const visitedSymbols = new Set(); + const scannedStatements = new Set(); + + const scanStatement = (statement: AnyNode) => { + if (scannedStatements.has(statement)) { + return; } - const isWithinExportDestructuring = Boolean( - identifier.findParent(path => - Boolean( - path.isPattern() && - path.parentPath?.isVariableDeclarator() && - path.parentPath.parentPath?.parentPath?.isExportNamedDeclaration() - ) - ) - ); - if (isWithinExportDestructuring) { - let currentPath: NodePath | null = identifier; - while (currentPath) { - if ( - currentPath.parentPath?.isVariableDeclarator() && - currentPath.parentKey === 'id' - ) { - exportedVariableDeclarators.add( - currentPath.parentPath.node as t.VariableDeclarator - ); - break; + scannedStatements.add(statement); + walk(statement as any, { + Identifier(node: AnyNode) { + const reference = module.referenceOf(node as never); + if (reference?.symbol) { + visitSymbol(reference.symbol); } - currentPath = currentPath.parentPath; - } - } - } - exportDependencies.set(exportName, { - topLevelStatements, - topLevelNonModuleStatements, - importedIdentifierNames, - exportedVariableDeclarators, - }); - } + }, + }); + }; - traverse(ast, { - ExportDeclaration(exportPath) { - const { node } = exportPath; - if (t.isExportAllDeclaration(node)) { - return; - } - if (t.isExportDefaultDeclaration(node)) { - handleExport('default', exportPath); + const visitSymbol = (symbol: YukuSymbol) => { + if (visitedSymbols.has(symbol)) { return; } - const { declaration } = node; - if (t.isVariableDeclaration(declaration)) { - const { declarations } = declaration; - for (let i = 0; i < declarations.length; i++) { - const declarator = declarations[i]; - if (t.isIdentifier(declarator.id)) { - const declaratorPath = exportPath.get( - `declaration.declarations.${i}` - ); - assertNodePathIsVariableDeclarator(declaratorPath); - handleExport(declarator.id.name, exportPath, declaratorPath); - continue; - } - if (t.isPattern(declarator.id)) { - const exportedPatternPath = exportPath.get( - `declaration.declarations.${i}.id` - ); - assertNodePathIsPattern(exportedPatternPath); - const identifiers = - getIdentifiersForPatternPath(exportedPatternPath); - for (const identifier of identifiers) { - if (!t.isIdentifier(identifier.node)) { - continue; - } - handleExport(identifier.node.name, exportPath, identifier); - } - } + visitedSymbols.add(symbol); + + for (const declaration of symbol.declarations as AnyNode[]) { + const statement = getTopLevelStatementForNode(module, declaration); + addTopLevelStatement(module, dependencies, declaration); + if (statement.type === 'ImportDeclaration') { + dependencies.importedIdentifierNames.add(symbol.name); } - return; - } - if ( - t.isFunctionDeclaration(declaration) || - t.isClassDeclaration(declaration) - ) { - invariant( - declaration.id, - 'Expected exported function or class declaration to have a name when not the default export' + const declarator = getVariableDeclaratorForNode( + module, + declaration ); - handleExport(declaration.id.name, exportPath); - return; - } - if (t.isExportNamedDeclaration(node)) { - for (const specifier of node.specifiers) { - if (t.isIdentifier(specifier.exported)) { - const name = specifier.exported.name; - const specifierPath = exportPath - .get('specifiers') - .find(path => path.node === specifier); - invariant( - specifierPath, - `Expected to find specifier path for ${name}` - ); - handleExport(name, exportPath, specifierPath); - } + if ( + declarator && + getTopLevelStatementForNode(module, declarator).type === + 'ExportNamedDeclaration' + ) { + dependencies.exportedVariableDeclarators.add(declarator); } - return; + scanStatement(statement); } - throw new Error('Unknown export node type'); - }, - }); - return exportDependencies; - } - ); -}; + for (const reference of symbol.references as any[]) { + const statement = getTopLevelStatementForNode( + module, + reference.node + ); + addTopLevelStatement(module, dependencies, reference.node); + scanStatement(statement); + } + }; -const getDependentIdentifiersForPath = ( - path: NodePath, - state?: { visited: Set; identifiers: Set } -): Set => { - const { visited, identifiers } = state ?? { - visited: new Set(), - identifiers: new Set(), - }; - if (visited.has(path)) { - return identifiers; - } - visited.add(path); - path.traverse({ - Identifier(pathInner) { - if (identifiers.has(pathInner)) { - return; - } - identifiers.add(pathInner); - const binding = pathInner.scope.getBinding(pathInner.node.name); - if (!binding) { - return; - } - getDependentIdentifiersForPath(binding.path, { visited, identifiers }); - for (const reference of binding.referencePaths) { - if (reference.isExportNamedDeclaration()) { - continue; - } - getDependentIdentifiersForPath(reference, { visited, identifiers }); - } - for (const constantViolation of binding.constantViolations) { - getDependentIdentifiersForPath(constantViolation, { - visited, - identifiers, - }); - } - }, - }); - const topLevelStatement = getTopLevelStatementPathForPath(path); - const withinImportStatement = topLevelStatement.isImportDeclaration(); - const withinExportStatement = topLevelStatement.isExportDeclaration(); - if (!withinImportStatement && !withinExportStatement) { - getDependentIdentifiersForPath(topLevelStatement, { visited, identifiers }); - } - if ( - withinExportStatement && - path.isIdentifier() && - (t.isPattern(path.parentPath.node) || - t.isPattern(path.parentPath.parentPath?.node)) - ) { - const variableDeclarator = path.findParent(p => p.isVariableDeclarator()); - if (variableDeclarator) { - assertNodePath(variableDeclarator); - getDependentIdentifiersForPath(variableDeclarator, { - visited, - identifiers, - }); - } - } - return identifiers; -}; + addTopLevelStatement(module, dependencies, exportNode); -const getTopLevelStatementPathForPath = (path: NodePath) => { - const ancestry = path.getAncestry(); - const topLevelStatement = ancestry[ancestry.length - 2]; - assertNodePathIsStatement(topLevelStatement); - return topLevelStatement; -}; + if (localSymbol) { + visitSymbol(localSymbol); + } else { + const statement = getTopLevelStatementForNode(module, exportNode); + scanStatement(statement); + } -const getTopLevelStatementsForPaths = (paths: Set) => { - const topLevelStatements = new Set(); - for (const path of paths) { - const topLevelStatement = getTopLevelStatementPathForPath(path); - topLevelStatements.add(topLevelStatement.node as t.Statement); - } - return topLevelStatements; -}; + exportDependencies.set(exportName, dependencies); + }; -const getIdentifiersForPatternPath = ( - patternPath: NodePath, - identifiers: Set = new Set() -) => { - function walk(currentPath: NodePath) { - if (currentPath.isIdentifier()) { - identifiers.add(currentPath); - return; - } - if (currentPath.isObjectPattern()) { - const { properties } = currentPath.node; - for (let i = 0; i < properties.length; i++) { - const property = properties[i]; - if (t.isObjectProperty(property)) { - const valuePath = currentPath.get(`properties.${i}.value`); - if (isNodePathWithNode(valuePath)) { - walk(valuePath); - } - } else if (t.isRestElement(property)) { - const argumentPath = currentPath.get(`properties.${i}.argument`); - if (isNodePathWithNode(argumentPath)) { - walk(argumentPath); - } - } - } - } else if (currentPath.isArrayPattern()) { - const { elements } = currentPath.node; - for (let i = 0; i < elements.length; i++) { - const element = elements[i]; - if (element) { - const elementPath = currentPath.get(`elements.${i}`); - if (isNodePathWithNode(elementPath)) { - walk(elementPath); - } + for (const exp of module.exports as any[]) { + if (exp.typeOnly || exp.isStar || exp.isExportEquals) { + continue; } + handleExport(exp.name, exp.node as AnyNode, exp.local ?? null); } - } else if (currentPath.isRestElement()) { - const argumentPath = currentPath.get('argument'); - if (isNodePathWithNode(argumentPath)) { - walk(argumentPath); - } - } - } - walk(patternPath); - return identifiers; -}; - -const getExportedName = (exported: t.Identifier | t.StringLiteral) => { - return t.isIdentifier(exported) ? exported.name : exported.value; -}; -const setsIntersect = (set1: Set, set2: Set) => { - let smallerSet = set1; - let largerSet = set2; - if (set1.size > set2.size) { - smallerSet = set2; - largerSet = set1; - } - for (const element of smallerSet) { - if (largerSet.has(element)) { - return true; + return exportDependencies; } - } - return false; + ); }; const hasChunkableExport = ( @@ -516,18 +362,39 @@ const hasChunkableExport = ( ); }; +const generateCode = (program: AnyNode): string | undefined => { + if (program.body.length === 0) { + return undefined; + } + const result = strip(program as any, { comments: 'some' }); + if (result.errors.length > 0) { + throw new Error(result.errors.map(error => error.message).join('\n')); + } + return result.code; +}; + +const filterImportSpecifiers = ( + node: AnyNode, + shouldKeep: (importedName: string) => boolean +) => { + if (node.specifiers.length === 0) { + return node; + } + node.specifiers = node.specifiers.filter((specifier: AnyNode) => + shouldKeep(specifier.local.name) + ); + return node.specifiers.length > 0 ? node : null; +}; + const getChunkedExport = ( code: string, exportName: string, - generateOptions: Record = {}, cache: RouteChunkCache | undefined, cacheKey: string ): string | undefined => { return getOrSetFromCache( cache, - `${cacheKey}::getChunkedExport::${exportName}::${JSON.stringify( - generateOptions - )}`, + `${cacheKey}::getChunkedExport::${exportName}`, code, () => { if (!hasChunkableExport(code, exportName, cache, cacheKey)) { @@ -544,75 +411,60 @@ const getChunkedExport = ( dependencies.exportedVariableDeclarators ); - const ast = codeToAst(code, cache, cacheKey); - ast.program.body = ast.program.body - .filter(node => - topLevelStatementsArray.some(statement => - t.isNodesEquivalent(node, statement) - ) + const program = cloneProgram(code, cache, cacheKey); + program.body = program.body + .filter((node: AnyNode) => + topLevelStatementsArray.some(statement => sameNode(node, statement)) ) - .map(node => { - if (!t.isImportDeclaration(node)) { + .map((node: AnyNode) => { + if (node.type !== 'ImportDeclaration') { return node; } if (dependencies.importedIdentifierNames.size === 0) { return null; } - node.specifiers = node.specifiers.filter(specifier => - dependencies.importedIdentifierNames.has(specifier.local.name) - ); - invariant( - node.specifiers.length > 0, - 'Expected import statement to have used specifiers' + return filterImportSpecifiers(node, importedName => + dependencies.importedIdentifierNames.has(importedName) ); - return node; }) - .map(node => { - if (!t.isExportDeclaration(node)) { + .map((node: AnyNode | null) => { + if (!node || !node.type.startsWith('Export')) { return node; } - if (t.isExportAllDeclaration(node)) { + if (node.type === 'ExportAllDeclaration') { return null; } - if (t.isExportDefaultDeclaration(node)) { + if (node.type === 'ExportDefaultDeclaration') { return exportName === 'default' ? node : null; } const { declaration } = node; - if (t.isVariableDeclaration(declaration)) { + if (declaration?.type === 'VariableDeclaration') { declaration.declarations = declaration.declarations.filter( - declarationNode => + (declarationNode: AnyNode) => exportedVariableDeclaratorsArray.some(declarator => - t.isNodesEquivalent(declarationNode, declarator) + sameNode(declarationNode, declarator) ) ); - if (declaration.declarations.length === 0) { - return null; - } - return node; + return declaration.declarations.length > 0 ? node : null; } if ( - t.isFunctionDeclaration(node.declaration) || - t.isClassDeclaration(node.declaration) + declaration?.type === 'FunctionDeclaration' || + declaration?.type === 'ClassDeclaration' ) { - return node.declaration.id?.name === exportName ? node : null; + return declaration.id?.name === exportName ? node : null; } - if (t.isExportNamedDeclaration(node)) { - if (node.specifiers.length === 0) { - return null; - } + if (node.type === 'ExportNamedDeclaration') { node.specifiers = node.specifiers.filter( - specifier => getExportedName(specifier.exported) === exportName + (specifier: AnyNode) => + getExportedName(specifier.exported) === exportName ); - if (node.specifiers.length === 0) { - return null; - } - return node; + return node.specifiers.length > 0 ? node : null; } throw new Error('Unknown export node type'); }) - .filter(Boolean) as t.Statement[]; + .filter(Boolean) as AnyNode[]; - return generate(ast, generateOptions).code; + return generateCode(program); } ); }; @@ -620,15 +472,12 @@ const getChunkedExport = ( const omitChunkedExports = ( code: string, exportNames: string[], - generateOptions: Record = {}, cache: RouteChunkCache | undefined, cacheKey: string ): string | undefined => { return getOrSetFromCache( cache, - `${cacheKey}::omitChunkedExports::${exportNames.join(',')}::${JSON.stringify( - generateOptions - )}`, + `${cacheKey}::omitChunkedExports::${exportNames.join(',')}`, code, () => { const isChunkable = (exportName: string) => @@ -642,9 +491,8 @@ const omitChunkedExports = ( const omittedExportNames = allExportNames.filter(isOmitted); const retainedExportNames = allExportNames.filter(isRetained); - const omittedStatements = new Set(); - const omittedExportedVariableDeclarators = - new Set(); + const omittedStatements = new Set(); + const omittedExportedVariableDeclarators = new Set(); for (const omittedExportName of omittedExportNames) { const dependencies = exportDependencies.get(omittedExportName); @@ -660,100 +508,73 @@ const omitChunkedExports = ( } } - const ast = codeToAst(code, cache, cacheKey); const omittedStatementsArray = Array.from(omittedStatements); const omittedExportedVariableDeclaratorsArray = Array.from( omittedExportedVariableDeclarators ); - ast.program.body = ast.program.body - .filter(node => - omittedStatementsArray.every( - statement => !t.isNodesEquivalent(node, statement) - ) + + const program = cloneProgram(code, cache, cacheKey); + program.body = program.body + .filter((node: AnyNode) => + omittedStatementsArray.every(statement => !sameNode(node, statement)) ) - .map(node => { - if (!t.isImportDeclaration(node)) { + .map((node: AnyNode) => { + if (node.type !== 'ImportDeclaration') { return node; } - if (node.specifiers.length === 0) { - return node; - } - node.specifiers = node.specifiers.filter(specifier => { - const importedName = specifier.local.name; + return filterImportSpecifiers(node, importedName => { for (const retainedExportName of retainedExportNames) { const dependencies = exportDependencies.get(retainedExportName); - if (dependencies?.importedIdentifierNames?.has(importedName)) { + if (dependencies?.importedIdentifierNames.has(importedName)) { return true; } } for (const omittedExportName of omittedExportNames) { const dependencies = exportDependencies.get(omittedExportName); - if (dependencies?.importedIdentifierNames?.has(importedName)) { + if (dependencies?.importedIdentifierNames.has(importedName)) { return false; } } return true; }); - if (node.specifiers.length === 0) { - return null; - } - return node; }) - .map(node => { - if (!t.isExportDeclaration(node)) { + .map((node: AnyNode | null) => { + if (!node || !node.type.startsWith('Export')) { return node; } - if (t.isExportAllDeclaration(node)) { + if (node.type === 'ExportAllDeclaration') { return node; } - if (t.isExportDefaultDeclaration(node)) { + if (node.type === 'ExportDefaultDeclaration') { return isOmitted('default') ? null : node; } - if (t.isVariableDeclaration(node.declaration)) { + if (node.declaration?.type === 'VariableDeclaration') { node.declaration.declarations = - node.declaration.declarations.filter(declarationNode => + node.declaration.declarations.filter((declarationNode: AnyNode) => omittedExportedVariableDeclaratorsArray.every( - declarator => - !t.isNodesEquivalent(declarationNode, declarator) + declarator => !sameNode(declarationNode, declarator) ) ); - if (node.declaration.declarations.length === 0) { - return null; - } - return node; + return node.declaration.declarations.length > 0 ? node : null; } if ( - t.isFunctionDeclaration(node.declaration) || - t.isClassDeclaration(node.declaration) + node.declaration?.type === 'FunctionDeclaration' || + node.declaration?.type === 'ClassDeclaration' ) { - const declarationId = node.declaration.id; - invariant( - declarationId, - 'Expected exported function or class declaration to have a name when not the default export' - ); - return isOmitted(declarationId.name) ? null : node; + return isOmitted(node.declaration.id.name) ? null : node; } - if (t.isExportNamedDeclaration(node)) { - if (node.specifiers.length === 0) { - return node; - } - node.specifiers = node.specifiers.filter(specifier => { + if (node.type === 'ExportNamedDeclaration') { + node.specifiers = node.specifiers.filter((specifier: AnyNode) => { const exportedName = getExportedName(specifier.exported); return !isOmitted(exportedName); }); - if (node.specifiers.length === 0) { - return null; - } - return node; + return node.specifiers.length > 0 || node.declaration ? node : null; } throw new Error('Unknown node type'); }) - .filter(Boolean) as t.Statement[]; + .filter(Boolean) as AnyNode[]; - if (ast.program.body.length === 0) { - return undefined; - } - return generate(ast, generateOptions).code; + return generateCode(program); } ); }; @@ -792,9 +613,9 @@ export const getRouteChunkCode: ( cacheKey: string ) => { if (chunkName === 'main') { - return omitChunkedExports(code, routeChunkExportNames, {}, cache, cacheKey); + return omitChunkedExports(code, routeChunkExportNames, cache, cacheKey); } - return getChunkedExport(code, chunkName, {}, cache, cacheKey); + return getChunkedExport(code, chunkName, cache, cacheKey); }; export const getRouteChunkModuleId = ( From aa33f69ae627a7eb4b077a106436dcacce95211b Mon Sep 17 00:00:00 2001 From: hardfist Date: Wed, 17 Jun 2026 15:29:15 +0800 Subject: [PATCH 2/3] Preserve parens in Yuku transforms --- src/babel.ts | 2 +- src/export-utils.ts | 4 ++-- src/route-chunks.ts | 2 +- tests/export-utils.test.ts | 22 ++++++++++++++++++++++ 4 files changed, 26 insertions(+), 4 deletions(-) create mode 100644 tests/export-utils.test.ts diff --git a/src/babel.ts b/src/babel.ts index b3cdfb7..c8559a6 100644 --- a/src/babel.ts +++ b/src/babel.ts @@ -16,7 +16,7 @@ export const parse = ( const result = yukuParse(code, { sourceType: options.sourceType ?? 'module', lang: options.lang ?? 'tsx', - preserveParens: false, + preserveParens: true, }); const errors = result.diagnostics.filter( diagnostic => diagnostic.severity === 'error' diff --git a/src/export-utils.ts b/src/export-utils.ts index f7b0743..2fa300d 100644 --- a/src/export-utils.ts +++ b/src/export-utils.ts @@ -8,7 +8,7 @@ const parseProgram = (code: string, resourcePath?: string) => { const result = parse(code, { sourceType: 'module', lang: resourcePath ? langFromPath(resourcePath) : 'tsx', - preserveParens: false, + preserveParens: true, }); const errors = result.diagnostics.filter( diagnostic => diagnostic.severity === 'error' @@ -148,7 +148,7 @@ export const transformToEsm = async ( const result = parse(code, { sourceType: 'module', lang: langFromPath(resourcePath), - preserveParens: false, + preserveParens: true, }); const transformed = strip(result.program, { comments: 'some' }); if (transformed.errors.length > 0) { diff --git a/src/route-chunks.ts b/src/route-chunks.ts index 32024f1..1b76c59 100644 --- a/src/route-chunks.ts +++ b/src/route-chunks.ts @@ -107,7 +107,7 @@ const analyzeCode = ( const module = analyzer.addFile(cacheKey, code, { lang: 'tsx', sourceType: 'module', - preserveParens: false, + preserveParens: true, }); const errors = module.diagnostics.filter( diagnostic => diagnostic.severity === 'error' diff --git a/tests/export-utils.test.ts b/tests/export-utils.test.ts new file mode 100644 index 0000000..59ef389 --- /dev/null +++ b/tests/export-utils.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from '@rstest/core'; +import { parse } from '../src/babel'; +import { transformToEsm } from '../src/export-utils'; + +describe('export-utils', () => { + describe('transformToEsm', () => { + it('preserves arrow function object return parentheses', async () => { + const code = ` + const items = [{ pathname: '/', data: 'Home' }]; + export const labels = items.map((item) => ({ + to: item.pathname, + label: item.data, + })); + `; + + const transformed = await transformToEsm(code, 'route.tsx'); + + expect(transformed).toContain('=> ({'); + expect(() => parse(transformed, { sourceType: 'module' })).not.toThrow(); + }); + }); +}); From 9d2e7a6f8179c8a3ac4ad0cabefc406014fe85b6 Mon Sep 17 00:00:00 2001 From: hardfist Date: Wed, 17 Jun 2026 15:43:58 +0800 Subject: [PATCH 3/3] Keep JSX component references during DCE --- src/plugin-utils.ts | 19 +++++++++++++++++++ tests/remove-exports.test.ts | 26 +++++++++++++++++++++++++- 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/src/plugin-utils.ts b/src/plugin-utils.ts index 836a733..4c9ab61 100644 --- a/src/plugin-utils.ts +++ b/src/plugin-utils.ts @@ -295,6 +295,8 @@ const isNonReferenceIdentifier = (node: AnyNode, parent: AnyNode | null) => { return false; }; +const isUppercaseName = (name: string): boolean => /^[A-Z]/.test(name); + const collectReferencedNames = (program: AnyNode): Set => { const referenced = new Set(); walk(program as any, { @@ -304,6 +306,23 @@ const collectReferencedNames = (program: AnyNode): Set => { referenced.add(node.name); } }, + JSXIdentifier(node: AnyNode, ctx: any) { + const parent = ctx.parent as AnyNode | null; + if (!parent || !isUppercaseName(node.name)) { + return; + } + if ( + (parent.type === 'JSXOpeningElement' || + parent.type === 'JSXClosingElement') && + parent.name === node + ) { + referenced.add(node.name); + return; + } + if (parent.type === 'JSXMemberExpression' && parent.object === node) { + referenced.add(node.name); + } + }, ExportSpecifier(node: AnyNode) { if (node.local?.name && node.exportKind !== 'type') { referenced.add(node.local.name); diff --git a/tests/remove-exports.test.ts b/tests/remove-exports.test.ts index e907ca1..44f94d7 100644 --- a/tests/remove-exports.test.ts +++ b/tests/remove-exports.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from '@rstest/core'; -import { parse, traverse } from '../src/babel'; +import { generate, parse, traverse } from '../src/babel'; import { removeExports, removeUnusedImports } from '../src/plugin-utils'; function hasTopLevelAssignment(ast: any, textIncludes: string): boolean { @@ -73,4 +73,28 @@ describe('removeExports', () => { expect(hasThemeImport).toBe(false); }); + + it('keeps top-level declarations referenced from JSX after removing exports', () => { + const code = ` + export function loader() { + return null; + } + + function ProgressBar() { + return null; + } + + export default function Route() { + return ; + } + `; + + const ast = parse(code, { sourceType: 'module' }); + removeExports(ast, ['loader']); + + const result = generate(ast).code; + + expect(result).toContain('function ProgressBar'); + expect(result).toContain('