Skip to content

Commit 435fa15

Browse files
serhalppieh
andauthored
feat(@netlify/vite-plugin-react-router)!: support React Router middleware (#546)
Ultimately, the essence of this change is just to pass an instance of `ReactContextProvider` to the RR request handler; exporting a `netlifyContextProvider` additionally allows access to Netlify context in middleware, and the rest is docs and test changes. It's too much of a hassle to support all the combinations of scenarios if we support earlier versions where the RR `createContext` and `RouterContextProvider` exports don't exist at all and so on. Instead, this makes a breaking change that requires 7.9.0+. fix(deps): move react-router to dev deps fix(deps): remove unused @react-router/node dep --------- Co-authored-by: Michal Piechowiak <misiek.piechowiak@gmail.com>
1 parent 32fb7d5 commit 435fa15

File tree

22 files changed

+407
-81
lines changed

22 files changed

+407
-81
lines changed

packages/vite-plugin-react-router/README.md

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,3 +27,79 @@ export default defineConfig({
2727
],
2828
})
2929
```
30+
31+
### Load context
32+
33+
This plugin automatically includes all
34+
[Netlify context](https://docs.netlify.com/build/functions/api/#netlify-specific-context-object) fields on loader and
35+
action context.
36+
37+
If you're using TypeScript, `AppLoadContext` is automatically aware of these fields
38+
([via module augmentation](https://reactrouter.com/upgrading/remix#9-update-types-for-apploadcontext)).
39+
40+
For example:
41+
42+
```tsx
43+
import { useLoaderData } from 'react-router'
44+
import type { Route } from './+types/example'
45+
46+
export async function loader({ context }: Route.LoaderArgs) {
47+
return {
48+
country: context.geo?.country?.name ?? 'an unknown country',
49+
}
50+
}
51+
export default function Example() {
52+
const { country } = useLoaderData<typeof loader>()
53+
return <div>You are visiting from {country}</div>
54+
}
55+
```
56+
57+
If you've [opted in to the `future.v8_middleware` flag](https://reactrouter.com/how-to/middleware), you can still use
58+
the above access pattern for backwards compatibility, but loader and action context will now be an instance of the
59+
type-safe `RouterContextProvider`. Note that this requires requires v2.0.0+ of `@netlify/vite-plugin-react-router`.
60+
61+
For example:
62+
63+
```tsx
64+
import { netlifyRouterContext } from '@netlify/vite-plugin-react-router'
65+
import { useLoaderData } from 'react-router'
66+
import type { Route } from './+types/example'
67+
68+
export async function loader({ context }: Route.LoaderArgs) {
69+
return {
70+
country: context.get(netlifyRouterContext).geo?.country?.name ?? 'an unknown country',
71+
}
72+
}
73+
export default function Example() {
74+
const { country } = useLoaderData<typeof loader>()
75+
return <div>You are visiting from {country}</div>
76+
}
77+
```
78+
79+
### Middleware context
80+
81+
React Router introduced a stable middleware feature in 7.9.0.
82+
83+
To use middleware,
84+
[opt in to the feature via `future.v8_middleware` and follow the docs](https://reactrouter.com/how-to/middleware). Note
85+
that this requires requires v2.0.0+ of `@netlify/vite-plugin-react-router`.
86+
87+
To access the [Netlify context](https://docs.netlify.com/build/functions/api/#netlify-specific-context-object)
88+
specifically, you must import our `RouterContextProvider` instance:
89+
90+
```tsx
91+
import { netlifyRouterContext } from '@netlify/vite-plugin-react-router'
92+
93+
import type { Route } from './+types/home'
94+
95+
const logMiddleware: Route.MiddlewareFunction = async ({ request, context }) => {
96+
const country = context.get(netlifyRouterContext).geo?.country?.name ?? 'unknown'
97+
console.log(`Handling ${request.method} request to ${request.url} from ${country}`)
98+
}
99+
100+
export const middleware: Route.MiddlewareFunction[] = [logMiddleware]
101+
102+
export default function Home() {
103+
return <h1>Hello world</h1>
104+
}
105+
```

packages/vite-plugin-react-router/package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,20 +45,20 @@
4545
},
4646
"homepage": "https://github.com/netlify/remix-compute#readme",
4747
"dependencies": {
48-
"@react-router/node": "^7.0.1",
49-
"isbot": "^5.0.0",
50-
"react-router": "^7.0.1"
48+
"isbot": "^5.0.0"
5149
},
5250
"devDependencies": {
5351
"@netlify/functions": "^3.1.9",
5452
"@types/react": "^18.0.27",
5553
"@types/react-dom": "^18.0.10",
5654
"react": "^18.2.0",
5755
"react-dom": "^18.2.0",
56+
"react-router": "^7.9.4",
5857
"tsup": "^8.0.2",
5958
"vite": "^6.2.5"
6059
},
6160
"peerDependencies": {
61+
"react-router": ">=7.9.0",
6262
"vite": ">=5.0.0"
6363
},
6464
"engines": {
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
export type { GetLoadContextFunction, RequestHandler } from './server'
2-
export { createRequestHandler } from './server'
2+
export { createRequestHandler, netlifyRouterContext } from './server'
33

44
export { netlifyPlugin as default } from './plugin'

packages/vite-plugin-react-router/src/plugin.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@ import { createRequestHandler } from "@netlify/vite-plugin-react-router";
2424
import * as build from "virtual:react-router/server-build";
2525
export default createRequestHandler({
2626
build,
27-
getLoadContext: async (_req, ctx) => ctx,
2827
});
2928
`
3029

packages/vite-plugin-react-router/src/server.ts

Lines changed: 52 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,47 @@
11
import type { AppLoadContext, ServerBuild } from 'react-router'
2-
import { createRequestHandler as createReactRouterRequestHandler } from 'react-router'
2+
import {
3+
createContext,
4+
RouterContextProvider,
5+
createRequestHandler as createReactRouterRequestHandler,
6+
} from 'react-router'
37
import type { Context as NetlifyContext } from '@netlify/functions'
48

5-
type LoadContext = AppLoadContext & NetlifyContext
9+
// Augment the user's `AppLoadContext` to include Netlify context fields
10+
// This is the recommended approach: https://reactrouter.com/upgrading/remix#9-update-types-for-apploadcontext.
11+
declare module 'react-router' {
12+
interface AppLoadContext extends NetlifyContext {}
13+
}
614

715
/**
8-
* A function that returns the value to use as `context` in route `loader` and
9-
* `action` functions.
16+
* A function that returns the value to use as `context` in route `loader` and `action` functions.
17+
*
18+
* You can think of this as an escape hatch that allows you to pass environment/platform-specific
19+
* values through to your loader/action.
1020
*
11-
* You can think of this as an escape hatch that allows you to pass
12-
* environment/platform-specific values through to your loader/action.
21+
* NOTE: v7.9.0 introduced a breaking change when the user opts in to `future.v8_middleware`. This
22+
* requires returning an instance of `RouterContextProvider` instead of a plain object. We have a
23+
* peer dependency on >=7.9.0 so we can safely *import* these, but we cannot assume the user has
24+
* opted in to the flag.
1325
*/
14-
export type GetLoadContextFunction = (request: Request, context: NetlifyContext) => Promise<LoadContext> | LoadContext
26+
export type GetLoadContextFunction = GetLoadContextFunction_V7 | GetLoadContextFunction_V8
27+
export type GetLoadContextFunction_V7 = (
28+
request: Request,
29+
context: NetlifyContext,
30+
) => Promise<AppLoadContext> | AppLoadContext
31+
export type GetLoadContextFunction_V8 = (
32+
request: Request,
33+
context: NetlifyContext,
34+
) => Promise<RouterContextProvider> | RouterContextProvider
1535

16-
export type RequestHandler = (request: Request, context: LoadContext) => Promise<Response | void>
36+
export type RequestHandler = (request: Request, context: NetlifyContext) => Promise<Response>
37+
38+
/**
39+
* An instance of `ReactContextProvider` providing access to
40+
* [Netlify request context]{@link https://docs.netlify.com/build/functions/api/#netlify-specific-context-object}
41+
*
42+
* @example context.get(netlifyRouterContext).geo?.country?.name
43+
*/
44+
export const netlifyRouterContext = createContext<NetlifyContext>()
1745

1846
/**
1947
* Given a build and a callback to get the base loader context, this returns
@@ -32,13 +60,26 @@ export function createRequestHandler({
3260
}): RequestHandler {
3361
const reactRouterHandler = createReactRouterRequestHandler(build, mode)
3462

35-
return async (request: Request, netlifyContext: NetlifyContext): Promise<Response | void> => {
63+
return async (request: Request, netlifyContext: NetlifyContext): Promise<Response> => {
3664
const start = Date.now()
3765
console.log(`[${request.method}] ${request.url}`)
3866
try {
39-
const mergedLoadContext = (await getLoadContext?.(request, netlifyContext)) || netlifyContext
67+
const getDefaultReactRouterContext = () => {
68+
const ctx = new RouterContextProvider()
69+
ctx.set(netlifyRouterContext, netlifyContext)
70+
71+
// Provide backwards compatibility with previous plain object context
72+
// See https://reactrouter.com/how-to/middleware#migration-from-apploadcontext.
73+
Object.assign(ctx, netlifyContext)
74+
75+
return ctx
76+
}
77+
const reactRouterContext = (await getLoadContext?.(request, netlifyContext)) ?? getDefaultReactRouterContext()
4078

41-
const response = await reactRouterHandler(request, mergedLoadContext)
79+
// @ts-expect-error -- I don't think there's any way to type this properly. We're passing a
80+
// union of the two types here, but this function accepts a conditional type based on the
81+
// presence of the `future.v8_middleware` flag in the user's config, which we don't have access to.
82+
const response = await reactRouterHandler(request, reactRouterContext)
4283

4384
// A useful header for debugging
4485
response.headers.set('x-nf-runtime', 'Node')

0 commit comments

Comments
 (0)