Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/vite-plugin-react-router/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
119 changes: 119 additions & 0 deletions packages/vite-plugin-react-router/src/plugin.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { existsSync } from 'node:fs'
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<typeof netlifyPlugin>) => 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') } } },
})
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 () => {
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"')
})
})
68 changes: 65 additions & 3 deletions packages/vite-plugin-react-router/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down