From 46c4fc8851d7279baf13c4a1dea3903b3a56ebe7 Mon Sep 17 00:00:00 2001 From: Mike Cousins Date: Fri, 12 Jun 2026 01:42:24 +0000 Subject: [PATCH 1/2] fix(vite-plugin-react-router): generate server handler for Vite Environment API builds React Router builds that use the Vite Environment API (the default in React Router 8, opt-in via the future.unstable_viteEnvironmentApi / future.v8_viteEnvironmentApi flags in React Router 7) never set the legacy isSsrBuild config flag, so the plugin silently emitted no server handler function and deploys served static assets only. Add a configEnvironment hook that injects the function handler entry into React Router's server environments (ssr and ssrBundle_*), and detect server builds in writeBundle via the environment name, falling back to the legacy flag on older Vite versions. Also widen the react-router peer range to accept 8.x pre-releases. Fixes #698 --- .../vite-plugin-react-router/package.json | 2 +- .../src/plugin.test.ts | 118 ++++++++++++++++++ .../vite-plugin-react-router/src/plugin.ts | 68 +++++++++- 3 files changed, 184 insertions(+), 4 deletions(-) create mode 100644 packages/vite-plugin-react-router/src/plugin.test.ts diff --git a/packages/vite-plugin-react-router/package.json b/packages/vite-plugin-react-router/package.json index ad84a45f3..d3e912d1c 100644 --- a/packages/vite-plugin-react-router/package.json +++ b/packages/vite-plugin-react-router/package.json @@ -74,7 +74,7 @@ "vite": "^8.0.0" }, "peerDependencies": { - "react-router": ">=7.9.0", + "react-router": ">=7.9.0 || ^8.0.0-pre.0", "vite": ">=5.0.0" }, "engines": { diff --git a/packages/vite-plugin-react-router/src/plugin.test.ts b/packages/vite-plugin-react-router/src/plugin.test.ts new file mode 100644 index 000000000..906f9dc23 --- /dev/null +++ b/packages/vite-plugin-react-router/src/plugin.test.ts @@ -0,0 +1,118 @@ +import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { describe, it, expect, beforeEach, afterEach } from 'vitest' + +import { netlifyPlugin } from './plugin.js' + +// The plugin's hooks are written in function form, so we can call them directly. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const getHooks = (plugin: ReturnType) => plugin as any + +describe('configEnvironment', () => { + it('adds the function handler entry to the ssr environment for builds', () => { + const plugin = getHooks(netlifyPlugin()) + const result = plugin.configEnvironment( + 'ssr', + { build: { rollupOptions: { input: 'virtual:react-router/server-build' } } }, + { command: 'build', mode: 'production' }, + ) + expect(result?.build?.rollupOptions?.input).toEqual({ + 'server-build': 'virtual:react-router/server-build', + server: 'virtual:netlify-server', + }) + expect(result?.build?.rollupOptions?.output?.entryFileNames).toBeTypeOf('function') + }) + + it('adds the function handler entry to server bundle environments for builds', () => { + const plugin = getHooks(netlifyPlugin()) + const result = plugin.configEnvironment('ssrBundle_main', {}, { command: 'build', mode: 'production' }) + expect(result?.build?.rollupOptions?.input).toEqual({ + server: 'virtual:netlify-server', + }) + }) + + it('does not modify the client environment', () => { + const plugin = getHooks(netlifyPlugin()) + const result = plugin.configEnvironment('client', {}, { command: 'build', mode: 'production' }) + expect(result).toBeUndefined() + }) + + it('does not modify environments in dev', () => { + const plugin = getHooks(netlifyPlugin()) + const result = plugin.configEnvironment('ssr', {}, { command: 'serve', mode: 'development' }) + expect(result).toBeUndefined() + }) + + it('configures resolution for edge environments', () => { + const plugin = getHooks(netlifyPlugin({ edge: true })) + const result = plugin.configEnvironment('ssr', {}, { command: 'build', mode: 'production' }) + expect(result?.resolve?.conditions).toEqual(['worker', 'deno', 'browser']) + expect(result?.resolve?.noExternal).toBeInstanceOf(RegExp) + }) +}) + +describe('writeBundle', () => { + let root: string + + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'vite-plugin-react-router-test-')) + }) + + afterEach(async () => { + await rm(root, { recursive: true, force: true }) + }) + + const setUpPluginForBuild = async (options = {}) => { + const plugin = getHooks(netlifyPlugin(options)) + // Simulate an Environment API build, where `isSsrBuild` is never set. + plugin.config({}, { command: 'build', mode: 'production' }) + await plugin.configResolved.handler({ + root, + plugins: [], + build: { outDir: join(root, 'build', 'client') }, + }) + return plugin + } + + it('writes the server handler function when building the ssr environment', async () => { + const plugin = await setUpPluginForBuild() + await plugin.writeBundle.call({ + environment: { name: 'ssr', config: { build: { outDir: join(root, 'build', 'server') } } }, + }) + const generated = await readFile(join(root, '.netlify/v1/functions/react-router-server.mjs'), 'utf8') + expect(generated).toContain('export { default } from "../../../build/server/server.js"') + expect(generated).toContain('preferStatic: true') + }) + + it('resolves a relative environment outDir against the root', async () => { + const plugin = await setUpPluginForBuild() + await plugin.writeBundle.call({ + environment: { name: 'ssr', config: { build: { outDir: 'build/server' } } }, + }) + const generated = await readFile(join(root, '.netlify/v1/functions/react-router-server.mjs'), 'utf8') + expect(generated).toContain('export { default } from "../../../build/server/server.js"') + }) + + it('does not write the function when building the client environment', async () => { + const plugin = await setUpPluginForBuild() + await plugin.writeBundle.call({ + environment: { name: 'client', config: { build: { outDir: join(root, 'build', 'client') } } }, + }) + await expect(readFile(join(root, '.netlify/v1/functions/react-router-server.mjs'), 'utf8')).rejects.toThrow() + }) + + it('falls back to the legacy isSsrBuild flag when there is no environment', async () => { + const plugin = getHooks(netlifyPlugin()) + plugin.config({}, { command: 'build', mode: 'production', isSsrBuild: true }) + await plugin.configResolved.handler({ + root, + plugins: [], + build: { outDir: join(root, 'build', 'server') }, + }) + await plugin.writeBundle.call({ environment: undefined }) + const generated = await readFile(join(root, '.netlify/v1/functions/react-router-server.mjs'), 'utf8') + expect(generated).toContain('export { default } from "../../../build/server/server.js"') + }) +}) diff --git a/packages/vite-plugin-react-router/src/plugin.ts b/packages/vite-plugin-react-router/src/plugin.ts index ebdbf363b..ad06fc3f1 100644 --- a/packages/vite-plugin-react-router/src/plugin.ts +++ b/packages/vite-plugin-react-router/src/plugin.ts @@ -43,6 +43,15 @@ const RESOLVED_FUNCTION_HANDLER_MODULE_ID = `\0${FUNCTION_HANDLER_MODULE_ID}` const SERVER_ENTRY_MODULE_ID = 'virtual:netlify-server-entry' +// React Router names its server environments `ssr`, or `ssrBundle_*` when the +// `serverBundles` option is configured. +// See https://github.com/remix-run/react-router/blob/main/packages/react-router-dev/vite/plugin.ts +const SSR_ENVIRONMENT_NAME = 'ssr' +const SSR_BUNDLE_ENVIRONMENT_PREFIX = 'ssrBundle_' + +const isServerEnvironmentName = (name: string) => + name === SSR_ENVIRONMENT_NAME || name.startsWith(SSR_BUNDLE_ENVIRONMENT_PREFIX) + const toPosixPath = (path: string) => path.split(sep).join(posixSep) // Note: these are checked in order. The first match is used. @@ -174,6 +183,46 @@ export function netlifyPlugin(options: NetlifyPluginOptions = {}): Plugin { return configChanges } }, + // React Router builds with the Vite Environment API (the default in React Router 8, + // opt-in via the `future.unstable_viteEnvironmentApi`/`future.v8_viteEnvironmentApi` + // flags in React Router 7) never set the legacy `isSsrBuild` flag handled in `config` + // above, so we configure each server environment here instead. In legacy SSR builds on + // Vite 6+ this hook also runs, but the merges below are idempotent with the `config` + // hook's changes. + configEnvironment(name, environmentConfig, env) { + if (env.command !== 'build' || !isServerEnvironmentName(name)) { + return + } + + return { + build: { + rollupOptions: { + // Add our function handler entry, preserving any existing input entries (e.g. + // React Router's virtual server build module). + input: mergeRollupInput(environmentConfig.build?.rollupOptions?.input, { + [FUNCTION_HANDLER_CHUNK]: FUNCTION_HANDLER_MODULE_ID, + }), + output: { + // NOTE: must use function syntax here to work around Shopify CLI reading + // the config value literally (i.e. trying to stat `[name].js` as a filename). + entryFileNames: () => '[name].js', + }, + }, + }, + // Additional config needed for Edge Functions if enabled. This mirrors the legacy + // top-level `ssr` config returned from the `config` hook above. + ...(edge + ? { + resolve: { + // Bundle everything except Node.js built-ins (which are supported but must use the `node:` prefix): + // https://docs.netlify.com/build/edge-functions/api/#runtime-environment + noExternal: /^(?!node:).*$/, + conditions: ['worker', 'deno', 'browser'], + }, + } + : {}), + } + }, async resolveId(source, importer, options) { // Hydrogen sites provide their own server entry (server.ts) and entry.server.tsx, // so we skip resolution of our virtual modules. @@ -283,15 +332,28 @@ export function netlifyPlugin(options: NetlifyPluginOptions = {}): Plugin { }, // See https://rollupjs.org/plugin-development/#writebundle. async writeBundle() { - if (isProductionSsrBuild) { - const handlerPath = join(resolvedConfig.build.outDir, `${FUNCTION_HANDLER_CHUNK}.js`) + // In Environment API builds, `isProductionSsrBuild` is never set (see + // `configEnvironment`), so we detect server builds via the environment name instead. + // In legacy SSR builds, `this.environment` is the `ssr` environment on Vite 6+, and + // on older Vite versions (which don't define `this.environment`) we fall back to the + // legacy flag. + const isServerBuild = + currentCommand === 'build' && + (this.environment ? isServerEnvironmentName(this.environment.name) : isProductionSsrBuild) + + if (isServerBuild) { + const outDir = resolve( + resolvedConfig.root, + this.environment?.config.build.outDir ?? resolvedConfig.build.outDir, + ) + const handlerPath = join(outDir, `${FUNCTION_HANDLER_CHUNK}.js`) if (edge) { // Edge Functions do not have a `preferStatic` option, so we must exhaustively exclude // static files to serve them from the CDN without compute. // RR7's build out dir contains /server and /client subdirectories. This is documented and // not configurable, so the client out dir is always at ../client from the server out dir. - const clientDir = join(resolvedConfig.build.outDir, '..', 'client') + const clientDir = join(outDir, '..', 'client') const clientFiles = await glob('**/*', { cwd: clientDir, // We can't exclude entire directories because there could be `foo/bar.baz` in the From 71d7eec5bf59dc4d554365b3062f5baa286d507f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 12 Jun 2026 02:37:46 +0000 Subject: [PATCH 2/2] chore(vite-plugin-react-router): address review feedback Use full optional chaining when reading the environment outDir in writeBundle, and assert the generated function file is absent with existsSync instead of expecting readFile to reject. https://claude.ai/code/session_01Qds2Wb4PZnY6iSXQ4geN3m --- packages/vite-plugin-react-router/src/plugin.test.ts | 3 ++- packages/vite-plugin-react-router/src/plugin.ts | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/vite-plugin-react-router/src/plugin.test.ts b/packages/vite-plugin-react-router/src/plugin.test.ts index 906f9dc23..cdbbf4b98 100644 --- a/packages/vite-plugin-react-router/src/plugin.test.ts +++ b/packages/vite-plugin-react-router/src/plugin.test.ts @@ -1,3 +1,4 @@ +import { existsSync } from 'node:fs' import { mkdtemp, readFile, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -100,7 +101,7 @@ describe('writeBundle', () => { await plugin.writeBundle.call({ environment: { name: 'client', config: { build: { outDir: join(root, 'build', 'client') } } }, }) - await expect(readFile(join(root, '.netlify/v1/functions/react-router-server.mjs'), 'utf8')).rejects.toThrow() + expect(existsSync(join(root, '.netlify/v1/functions/react-router-server.mjs'))).toBe(false) }) it('falls back to the legacy isSsrBuild flag when there is no environment', async () => { diff --git a/packages/vite-plugin-react-router/src/plugin.ts b/packages/vite-plugin-react-router/src/plugin.ts index ad06fc3f1..c8c29017e 100644 --- a/packages/vite-plugin-react-router/src/plugin.ts +++ b/packages/vite-plugin-react-router/src/plugin.ts @@ -344,7 +344,7 @@ export function netlifyPlugin(options: NetlifyPluginOptions = {}): Plugin { if (isServerBuild) { const outDir = resolve( resolvedConfig.root, - this.environment?.config.build.outDir ?? resolvedConfig.build.outDir, + this.environment?.config?.build?.outDir ?? resolvedConfig.build.outDir, ) const handlerPath = join(outDir, `${FUNCTION_HANDLER_CHUNK}.js`)