diff --git a/packages/plugin-rsc/e2e/basic.test.ts b/packages/plugin-rsc/e2e/basic.test.ts index 2955afce..68097556 100644 --- a/packages/plugin-rsc/e2e/basic.test.ts +++ b/packages/plugin-rsc/e2e/basic.test.ts @@ -63,7 +63,7 @@ test.describe('dev-non-optimized-cjs', () => { test('show warning', async ({ page }) => { await page.goto(f.url()) expect(f.proc().stderr()).toContain( - `[vite-rsc] found non-optimized CJS dependency in 'ssr' environment.`, + `Found non-optimized CJS dependency in 'ssr' environment.`, ) }) }) diff --git a/packages/plugin-rsc/package.json b/packages/plugin-rsc/package.json index 7fa20589..a4419ddb 100644 --- a/packages/plugin-rsc/package.json +++ b/packages/plugin-rsc/package.json @@ -44,7 +44,6 @@ "magic-string": "^0.30.17", "periscopic": "^4.0.2", "turbo-stream": "^3.1.0", - "use-sync-external-store": "^1.5.0", "vitefu": "^1.1.1" }, "devDependencies": { diff --git a/packages/plugin-rsc/src/plugin.ts b/packages/plugin-rsc/src/plugin.ts index 9a2a49c7..7bba9b1a 100644 --- a/packages/plugin-rsc/src/plugin.ts +++ b/packages/plugin-rsc/src/plugin.ts @@ -21,7 +21,7 @@ import { normalizePath, parseAstAsync, } from 'vite' -import { crawlFrameworkPkgs, findClosestPkgJsonPath } from 'vitefu' +import { crawlFrameworkPkgs } from 'vitefu' import vitePluginRscCore from './core/plugin' import { type TransformWrapExportFilter, @@ -33,6 +33,8 @@ import { import { generateEncryptionKey, toBase64 } from './utils/encryption-utils' import { createRpcServer } from './utils/rpc' import { normalizeViteImportAnalysisUrl, prepareError } from './vite-utils' +import { cjsModuleRunnerPlugin } from './plugins/cjs' +import { evalValue, parseIdQuery } from './plugins/utils' // state for build orchestration let serverReferences: Record = {} @@ -882,42 +884,10 @@ globalThis.AsyncLocalStorage = __viteRscAyncHooks.AsyncLocalStorage; ...(rscPluginOptions.validateImports !== false ? [validateImportPlugin()] : []), - ...vendorUseSyncExternalStorePlugin(), scanBuildStripPlugin(), - detectNonOptimizedCjsPlugin(), + ...cjsModuleRunnerPlugin(), ] } -function detectNonOptimizedCjsPlugin(): Plugin { - return { - name: 'rsc:detect-non-optimized-cjs', - apply: 'serve', - async transform(code, id) { - if ( - id.includes('/node_modules/') && - !id.startsWith(this.environment.config.cacheDir) && - /\b(require|exports)\b/.test(code) - ) { - id = parseIdQuery(id).filename - let isEsm = id.endsWith('.mjs') - if (id.endsWith('.js')) { - const pkgJsonPath = await findClosestPkgJsonPath(path.dirname(id)) - if (pkgJsonPath) { - const pkgJson = JSON.parse( - fs.readFileSync(pkgJsonPath, 'utf-8'), - ) as { type?: string } - isEsm = pkgJson.type === 'module' - } - } - if (!isEsm) { - this.warn( - `[vite-rsc] found non-optimized CJS dependency in '${this.environment.name}' environment. ` + - `It is recommended to manually add the dependency to 'environments.${this.environment.name}.optimizeDeps.include'.`, - ) - } - } - }, - } -} function scanBuildStripPlugin(): Plugin { return { @@ -1993,23 +1963,6 @@ function generateResourcesCode(depsCode: string) { ` } -// https://github.com/vitejs/vite/blob/ea9aed7ebcb7f4be542bd2a384cbcb5a1e7b31bd/packages/vite/src/node/utils.ts#L1469-L1475 -function evalValue(rawValue: string): T { - const fn = new Function(` - var console, exports, global, module, process, require - return (\n${rawValue}\n) - `) - return fn() -} - -// https://github.com/vitejs/vite-plugin-vue/blob/06931b1ea2b9299267374cb8eb4db27c0626774a/packages/plugin-vue/src/utils/query.ts#L13 -function parseIdQuery(id: string) { - if (!id.includes('?')) return { filename: id, query: {} } - const [filename, rawQuery] = id.split(`?`, 2) as [string, string] - const query = Object.fromEntries(new URLSearchParams(rawQuery)) - return { filename, query } -} - export async function transformRscCssExport(options: { ast: Awaited> code: string @@ -2125,41 +2078,6 @@ function validateImportPlugin(): Plugin { } } -function vendorUseSyncExternalStorePlugin(): Plugin[] { - // vendor and optimize use-sync-external-store out of the box - // since this is a common enough cjs, which tends to break - // other packages (e.g. swr, @tanstack/react-store) - - // https://github.com/facebook/react/blob/c499adf8c89bbfd884f4d3a58c4e510001383525/packages/use-sync-external-store/package.json#L5-L20 - const exports = [ - 'use-sync-external-store', - 'use-sync-external-store/with-selector', - 'use-sync-external-store/with-selector.js', - 'use-sync-external-store/shim', - 'use-sync-external-store/shim/index.js', - 'use-sync-external-store/shim/with-selector', - 'use-sync-external-store/shim/with-selector.js', - ] - - return [ - { - name: 'rsc:vendor-use-sync-external-store', - apply: 'serve', - config() { - return { - environments: { - ssr: { - optimizeDeps: { - include: exports.map((e) => `${PKG_NAME} > ${e}`), - }, - }, - }, - } - }, - }, - ] -} - function sortObject(o: T) { return Object.fromEntries( Object.entries(o).sort(([a], [b]) => a.localeCompare(b)), diff --git a/packages/plugin-rsc/src/plugins/cjs.ts b/packages/plugin-rsc/src/plugins/cjs.ts new file mode 100644 index 00000000..b2b9e85e --- /dev/null +++ b/packages/plugin-rsc/src/plugins/cjs.ts @@ -0,0 +1,87 @@ +import { parseAstAsync, type Plugin } from 'vite' +import { parseIdQuery } from './utils' +import { findClosestPkgJsonPath } from 'vitefu' +import path from 'node:path' +import fs from 'node:fs' +import * as esModuleLexer from 'es-module-lexer' +import { transformCjsToEsm } from '../transforms/cjs' + +export function cjsModuleRunnerPlugin(): Plugin[] { + // use-sync-external-store is known to work fine so don't show warning + const warnedPackages = new Set(['use-sync-external-store']) + + return [ + { + name: 'cjs-module-runner-transform', + apply: 'serve', + applyToEnvironment: (env) => env.config.dev.moduleRunnerTransform, + async transform(code, id) { + if ( + id.includes('/node_modules/') && + !id.startsWith(this.environment.config.cacheDir) && + /\b(require|exports)\b/.test(code) + ) { + id = parseIdQuery(id).filename + if (!/\.[cm]?js$/.test(id)) return + + // skip genuine esm + if (id.endsWith('.mjs')) return + if (id.endsWith('.js')) { + const pkgJsonPath = await findClosestPkgJsonPath(path.dirname(id)) + if (pkgJsonPath) { + const pkgJson = JSON.parse( + fs.readFileSync(pkgJsonPath, 'utf-8'), + ) as { type?: string } + if (pkgJson.type === 'module') return + } + } + + // skip faux esm (e.g. from "module" field) + const [, , , hasModuleSyntax] = esModuleLexer.parse(code) + if (hasModuleSyntax) return + + // warning once per package + const packageKey = extractPackageKey(id) + if (!warnedPackages.has(packageKey)) { + this.warn( + `Found non-optimized CJS dependency in '${this.environment.name}' environment. ` + + `It is recommended to add the dependency to 'environments.${this.environment.name}.optimizeDeps.include'.`, + ) + warnedPackages.add(packageKey) + } + + const ast = await parseAstAsync(code) + const result = transformCjsToEsm(code, ast) + const output = result.output + // TODO: can we use cjs-module-lexer to properly define named exports? + // for re-exports, we need to eagerly transform dependencies though. + // https://github.com/nodejs/node/blob/f3adc11e37b8bfaaa026ea85c1cf22e3a0e29ae9/lib/internal/modules/esm/translators.js#L382-L409 + output.append(`__vite_ssr_exportAll__(module.exports)`) + return { + code: output.toString(), + map: output.generateMap({ hires: 'boundary' }), + } + } + }, + }, + ] +} + +function extractPackageKey(id: string): string { + // .../.yarn/cache/abc/... => abc + const yarnMatch = id.match(/\/.yarn\/cache\/([^/]+)/) + if (yarnMatch) { + return yarnMatch[1]! + } + // .../node_modules/@x/y/... => @x/y + // .../node_modules/x/... => x + if (id.includes('/node_modules')) { + id = id.split('/node_modules/').at(-1)! + let [x, y] = id.split('/') + if (x!.startsWith('@')) { + return `${x}/${y}` + } + return x! + } + return id +} diff --git a/packages/plugin-rsc/src/plugins/utils.ts b/packages/plugin-rsc/src/plugins/utils.ts new file mode 100644 index 00000000..5628fad5 --- /dev/null +++ b/packages/plugin-rsc/src/plugins/utils.ts @@ -0,0 +1,21 @@ +// https://github.com/vitejs/vite/blob/ea9aed7ebcb7f4be542bd2a384cbcb5a1e7b31bd/packages/vite/src/node/utils.ts#L1469-L1475 +export function evalValue(rawValue: string): T { + const fn = new Function(` + var console, exports, global, module, process, require + return (\n${rawValue}\n) + `) + return fn() +} + +// https://github.com/vitejs/vite-plugin-vue/blob/06931b1ea2b9299267374cb8eb4db27c0626774a/packages/plugin-vue/src/utils/query.ts#L13 +export function parseIdQuery(id: string): { + filename: string + query: { + [k: string]: string + } +} { + if (!id.includes('?')) return { filename: id, query: {} } + const [filename, rawQuery] = id.split(`?`, 2) as [string, string] + const query = Object.fromEntries(new URLSearchParams(rawQuery)) + return { filename, query } +} diff --git a/packages/plugin-rsc/src/transforms/cjs.test.ts b/packages/plugin-rsc/src/transforms/cjs.test.ts new file mode 100644 index 00000000..659eff7d --- /dev/null +++ b/packages/plugin-rsc/src/transforms/cjs.test.ts @@ -0,0 +1,86 @@ +import { parseAstAsync } from 'vite' +import { describe, expect, it } from 'vitest' +import { debugSourceMap } from './test-utils' +import { transformCjsToEsm } from './cjs' + +describe(transformCjsToEsm, () => { + async function testTransform(input: string) { + const ast = await parseAstAsync(input) + const { output } = transformCjsToEsm(input, ast) + if (!output.hasChanged()) { + return + } + if (process.env['DEBUG_SOURCEMAP']) { + await debugSourceMap(output) + } + return output.toString() + } + + it('basic', async () => { + const input = `\ +exports.ok = true; +` + expect(await testTransform(input)).toMatchInlineSnapshot(` + "const exports = {}; const module = { exports }; + exports.ok = true; + " + `) + }) + + it('top-level re-export', async () => { + const input = `\ +if (true) { + module.exports = require('./cjs/use-sync-external-store.production.js'); +} else { + module.exports = require('./cjs/use-sync-external-store.development.js'); +} +` + expect(await testTransform(input)).toMatchInlineSnapshot(` + "const exports = {}; const module = { exports }; + if (true) { + module.exports = (await import('./cjs/use-sync-external-store.production.js')); + } else { + module.exports = (await import('./cjs/use-sync-external-store.development.js')); + } + " + `) + }) + + it('non top-level re-export', async () => { + const input = `\ +"production" !== process.env.NODE_ENV && (function() { + var React = require("react"); + var ReactDOM = require("react-dom"); + exports.useSyncExternalStoreWithSelector = function () {} +})() +` + expect(await testTransform(input)).toMatchInlineSnapshot(` + "const exports = {}; const module = { exports }; + const __cjs_to_esm_hoist_1 = await import("react-dom"); + const __cjs_to_esm_hoist_0 = await import("react"); + "production" !== process.env.NODE_ENV && (function() { + var React = __cjs_to_esm_hoist_0; + var ReactDOM = __cjs_to_esm_hoist_1; + exports.useSyncExternalStoreWithSelector = function () {} + })() + " + `) + }) + + it('local require', async () => { + const input = `\ +{ + const require = () => {}; + require("test"); +} +` + expect(await testTransform(input)).toMatchInlineSnapshot(` + "const exports = {}; const module = { exports }; + { + const require = () => {}; + require("test"); + } + " + `) + }) +}) diff --git a/packages/plugin-rsc/src/transforms/cjs.ts b/packages/plugin-rsc/src/transforms/cjs.ts new file mode 100644 index 00000000..455bb94b --- /dev/null +++ b/packages/plugin-rsc/src/transforms/cjs.ts @@ -0,0 +1,64 @@ +import type { Program, Node } from 'estree' +import MagicString from 'magic-string' +import { analyze } from 'periscopic' +import { walk } from 'estree-walker' + +export function transformCjsToEsm( + code: string, + ast: Program, +): { output: MagicString } { + const output = new MagicString(code) + const analyzed = analyze(ast) + + let parentNodes: Node[] = [] + let hoistIndex = 0 + walk(ast, { + enter(node) { + parentNodes.push(node) + if ( + node.type === 'CallExpression' && + node.callee.type === 'Identifier' && + node.callee.name === 'require' && + node.arguments.length === 1 + ) { + let isTopLevel = true + for (const parent of parentNodes) { + if ( + parent.type === 'FunctionExpression' || + parent.type === 'FunctionDeclaration' || + parent.type === 'ArrowFunctionExpression' + ) { + isTopLevel = false + } + // skip locally declared `require` + const scope = analyzed.map.get(parent) + if (scope && scope.declarations.has('require')) { + return + } + } + + if (isTopLevel) { + // top-level scope `require` to dynamic import + // (this allows handling react development/production re-export within top-level if branch) + output.update(node.start, node.callee.end, '(await import') + output.appendRight(node.end, ')') + } else { + // hoist non top-level `require` to top-level + const hoisted = `__cjs_to_esm_hoist_${hoistIndex}` + const importee = code.slice( + node.arguments[0]!.start, + node.arguments[0]!.end, + ) + output.prepend(`const ${hoisted} = await import(${importee});\n`) + output.update(node.start, node.end, hoisted) + hoistIndex++ + } + } + }, + leave() { + parentNodes.pop()! + }, + }) + output.prepend(`const exports = {}; const module = { exports };\n`) + return { output } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ec37ca6f..2b286c36 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -456,9 +456,6 @@ importers: turbo-stream: specifier: ^3.1.0 version: 3.1.0 - use-sync-external-store: - specifier: ^1.5.0 - version: 1.5.0(react@19.1.1) vitefu: specifier: ^1.1.1 version: 1.1.1(vite@7.0.6(@types/node@22.17.0)(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@2.7.1))