diff --git a/.changeset/scan-jsx-all-environments.md b/.changeset/scan-jsx-all-environments.md new file mode 100644 index 00000000..a5401034 --- /dev/null +++ b/.changeset/scan-jsx-all-environments.md @@ -0,0 +1,5 @@ +--- +'@solidjs/vite-plugin': patch +--- + +The dependency scanner settings now reach every environment, not just `client` (#387). Vite seeds only the `client` environment from the top-level `optimizeDeps`, so an `ssr` (or other server) environment with discovery turned back on — as `@cloudflare/vite-plugin` does for workerd SSR — scanned Solid TSX with Rolldown's default React automatic runtime. The scan failed on an unresolvable `react/jsx-dev-runtime` and pre-bundling was skipped for that environment, which showed up as duplicate Solid instances in the app. `configEnvironment` now gives non-client environments the classic scanner JSX runtime (unless the app set its own per-environment `transform.jsx`), the `.tsrx` extension, and the tsrx scan plugin. diff --git a/examples/ssr/package.json b/examples/ssr/package.json index facb405f..421f0c78 100644 --- a/examples/ssr/package.json +++ b/examples/ssr/package.json @@ -8,7 +8,7 @@ "build:client": "vite build --outDir dist/client", "build:server": "vite build --ssr src/entry-server.tsx --outDir dist/server", "serve": "NODE_ENV=production node server.js", - "test": "node test/run.mjs && node test/boundary.mjs" + "test": "node test/run.mjs && node test/boundary.mjs && node test/scan.mjs" }, "devDependencies": { "vite": "^8.2.1", diff --git a/examples/ssr/test/scan.mjs b/examples/ssr/test/scan.mjs new file mode 100644 index 00000000..d60dca40 --- /dev/null +++ b/examples/ssr/test/scan.mjs @@ -0,0 +1,121 @@ +// Dependency-scan test for non-client environments (#387). Vite seeds only +// the `client` environment from the top-level `optimizeDeps`, so the scanner +// JSX setting the plugin returns from `config` never reached `ssr`: with +// discovery turned back on (as @cloudflare/vite-plugin does for workerd +// SSR), Rolldown scanned Solid TSX with React's automatic runtime and the +// scan failed on an unresolvable `react/jsx-dev-runtime`, skipping +// pre-bundling for that environment. Asserts, in one process: +// - config: the ssr environment resolves the classic scanner JSX runtime, +// the `.tsrx` extension and the tsrx scan plugin; a per-environment +// `transform.jsx` set by the app is left alone, +// - scan: a cold dev server with ssr discovery on (entries +// src/entry-server.tsx, whose graph includes JSX and import.meta.glob) +// completes both the client and the ssr scans without a failure banner. +// +// Requires the plugin built (pnpm build at the repo root). No browser. +// Usage: node test/scan.mjs + +import { fileURLToPath } from 'node:url'; +import { mkdtempSync, rmSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { createServer, resolveConfig } from 'vite'; + +const exampleDir = path.dirname(path.dirname(fileURLToPath(import.meta.url))); + +const results = []; +function record(name, ok, detail = '') { + results.push({ name, ok, detail }); + console.log(` [scan] ${ok ? 'PASS' : 'FAIL'} ${name}${detail && !ok ? ` — ${detail}` : ''}`); +} + +const ssrDiscovery = { noDiscovery: false, entries: ['src/entry-server.tsx'] }; + +// ---- Resolved config ------------------------------------------------------- +{ + const config = await resolveConfig( + { root: exampleDir, logLevel: 'silent', environments: { ssr: { optimizeDeps: ssrDiscovery } } }, + 'serve', + ); + const ssr = config.environments.ssr.optimizeDeps; + record( + 'ssr scanner uses the classic JSX runtime', + ssr.rolldownOptions?.transform?.jsx?.runtime === 'classic', + JSON.stringify(ssr.rolldownOptions?.transform), + ); + record('ssr scanner crawls .tsrx', !!ssr.extensions?.includes('.tsrx'), JSON.stringify(ssr.extensions)); + const plugins = [ssr.rolldownOptions?.plugins].flat(Infinity); + record( + 'ssr scanner registers the tsrx scan plugin', + plugins.some((p) => p?.name === 'solid:tsrx-dep-scan'), + ); + record( + 'client scanner keeps the classic JSX runtime', + config.environments.client.optimizeDeps.rolldownOptions?.transform?.jsx?.runtime === 'classic', + ); +} +{ + const config = await resolveConfig( + { + root: exampleDir, + logLevel: 'silent', + environments: { + ssr: { optimizeDeps: { rolldownOptions: { transform: { jsx: { runtime: 'automatic' } } } } }, + }, + }, + 'serve', + ); + record( + 'an app-set per-environment scanner JSX option wins', + config.environments.ssr.optimizeDeps.rolldownOptions?.transform?.jsx?.runtime === 'automatic', + ); +} + +// ---- Cold scans ------------------------------------------------------------ +{ + const cacheDir = mkdtempSync(path.join(os.tmpdir(), 'solid-ssr-scan-')); + const failed = {}; + const server = await createServer({ + root: exampleDir, + cacheDir, + logLevel: 'silent', + server: { port: 0 }, + environments: { ssr: { optimizeDeps: ssrDiscovery } }, + }); + try { + for (const [name, env] of Object.entries(server.environments)) { + const error = env.logger.error; + env.logger = { + ...env.logger, + error(msg, opts) { + if (String(msg).includes('Failed to run dependency scan')) failed[name] = String(msg); + return error.call(this, msg, opts); + }, + }; + } + await server.listen(); + for (const name of ['client', 'ssr']) { + const optimizer = server.environments[name]?.depsOptimizer; + if (!optimizer || optimizer.options.noDiscovery) { + record(`${name} scan ran`, false, 'discovery is off'); + continue; + } + for (let i = 0; i < 100 && !optimizer.scanProcessing; i++) { + await new Promise((r) => setTimeout(r, 50)); + } + await optimizer.scanProcessing; + record(`${name} scan completed (no failure banner)`, !failed[name], failed[name]?.slice(0, 300)); + } + } finally { + await server.close(); + rmSync(cacheDir, { recursive: true, force: true }); + } +} + +const failures = results.filter((r) => !r.ok); +console.log(`\n${results.length - failures.length}/${results.length} scan assertions passed`); +if (failures.length) { + console.log('\nFailures:'); + for (const f of failures) console.log(` ${f.name} — ${f.detail}`); +} +process.exit(failures.length ? 1 : 0); diff --git a/src/index.ts b/src/index.ts index 2fa1eab7..bf227ea3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1179,6 +1179,28 @@ export default function solidPlugin(options: Partial = {}): Plugin[] { let projectRoot = process.cwd(); let isTestMode = false; let serverTestPosture = false; + + // Lets the dependency scanner crawl `.tsrx` imports. Registered on every + // environment's `optimizeDeps` (top-level for client, configEnvironment + // for the rest). + const tsrxDepScanPlugin = { + name: 'solid:tsrx-dep-scan', + async transform(source: string, id: string) { + if (!isTsrxModule(id) || isTsrxCssModule(id)) return null; + const compiler = await loadNativeCompiler(); + const result = await compiler.transformAsync(source, { + ...getSolidOptions(options, false, replaceDev, observe, isTestMode), + filename: cleanModuleId(id), + sourceMap: false, + }); + const stripped = await transformWithOxc(result.code, cleanModuleId(id) + '.tsx', { + lang: 'tsx', + sourcemap: false, + target: 'esnext', + }); + return { code: stripped.code, map: null }; + }, + }; let isBuild = false; let isSsrBuild = false; let base = '/'; @@ -1580,26 +1602,7 @@ export default function solidPlugin(options: Partial = {}): Plugin[] { // Keep Solid TSX from injecting React's automatic runtime during scanning. rolldownOptions: { transform: { jsx: { runtime: 'classic' as const } }, - plugins: [ - { - name: 'solid:tsrx-dep-scan', - async transform(source: string, id: string) { - if (!isTsrxModule(id) || isTsrxCssModule(id)) return null; - const compiler = await loadNativeCompiler(); - const result = await compiler.transformAsync(source, { - ...getSolidOptions(options, false, replaceDev, observe, isTestMode), - filename: cleanModuleId(id), - sourceMap: false, - }); - const stripped = await transformWithOxc(result.code, cleanModuleId(id) + '.tsx', { - lang: 'tsx', - sourcemap: false, - target: 'esnext', - }); - return { code: stripped.code, map: null }; - }, - }, - ], + plugins: [tsrxDepScanPlugin], }, }, ...(Object.keys(test).length ? { test } : {}), @@ -1633,6 +1636,25 @@ export default function solidPlugin(options: Partial = {}): Plugin[] { ...config.resolve.conditions, ]; + // Vite seeds only the `client` environment from the top-level + // `optimizeDeps` set in `config`, so every other environment would + // scan Solid TSX with Rolldown's default (React's automatic runtime) + // and fail on an unresolvable `react/jsx-dev-runtime`. Server + // environments default to `noDiscovery: true`, but hosts that run SSR + // outside Node turn discovery back on (@cloudflare/vite-plugin), #387. + if (name !== 'client') { + const optimizeDeps = (config.optimizeDeps ??= {}); + if (!optimizeDeps.extensions?.includes('.tsrx')) { + optimizeDeps.extensions = [...(optimizeDeps.extensions ?? []), '.tsrx']; + } + const rolldownOptions = (optimizeDeps.rolldownOptions ??= {}); + const transform = (rolldownOptions.transform ??= {}); + transform.jsx ??= { runtime: 'classic' }; + rolldownOptions.plugins = rolldownOptions.plugins + ? [rolldownOptions.plugins, tsrxDepScanPlugin] + : [tsrxDepScanPlugin]; + } + // `resolve.conditions` above only governs modules Vite inlines. // Externalized server deps are resolved by `fetchModule` with // `resolve.externalConditions` (default `['node', 'module-sync']`) and