Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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
118 changes: 118 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,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<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') } } },
})
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"')
})
})
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