Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Initial middleware implementation #12810

Merged
merged 22 commits into from
Jan 23, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
6392188
Client-side middleware implementation
brophdawg11 Dec 11, 2024
b8dc722
Add changeset
brophdawg11 Dec 12, 2024
914d37f
Wire up middleware to build, support clientMiddleware
brophdawg11 Dec 12, 2024
f16f386
Add tests and uodates for throwing from action middlewares
brophdawg11 Dec 13, 2024
9db0caf
Remove single fetch revalidation de-optimization
brophdawg11 Dec 17, 2024
e9247e3
Update single fetch data strategy to support middleware
brophdawg11 Dec 17, 2024
4fa703c
Tests for query/queryRoute, add respond() API
brophdawg11 Dec 20, 2024
d7ef9be
Wire up handleDocumentRequest to handler.respond
brophdawg11 Dec 20, 2024
f969fe6
Move server side to using new respond api and drop dataStrategy usage…
brophdawg11 Jan 8, 2025
8c264ce
Wire up server middleware e2e tests - reidrects still failing
brophdawg11 Jan 9, 2025
857fefd
Handle thrown redirects
brophdawg11 Jan 9, 2025
d611e25
temp
brophdawg11 Jan 10, 2025
526c871
Fix up single fetch data strategy stuff
brophdawg11 Jan 10, 2025
f6b92e9
Fix middleware on externally accessed resource routes
brophdawg11 Jan 14, 2025
5229fce
Fix up error handling in middleware
brophdawg11 Jan 15, 2025
bf0ab0c
Add a few more tests
brophdawg11 Jan 15, 2025
bb75e56
Collapse next function into MiddlewareArgs
brophdawg11 Jan 15, 2025
d8f15a5
Fix context typings
brophdawg11 Jan 22, 2025
04c8cf8
Update decision doc
brophdawg11 Jan 22, 2025
f08aebc
Loosen library mode types, framework mode will provide the focused types
brophdawg11 Jan 22, 2025
af2063e
Fix bad unit test
brophdawg11 Jan 23, 2025
dd8a6ad
Fix up E2E tests
brophdawg11 Jan 23, 2025
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
10 changes: 10 additions & 0 deletions .changeset/client-context.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"react-router": patch
---

Add `context` support to client side data routers (unstable)

- Library mode
- `createBrowserRouter(routes, { unstable_context })`
- Framework mode
- `<HydratedRouter unstable_context>`
5 changes: 5 additions & 0 deletions .changeset/fresh-buttons-sit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"react-router": patch
---

Don't apply Single Fetch revalidation de-optimization when in SPA mode since there is no server HTTP request
72 changes: 72 additions & 0 deletions .changeset/middleware.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
---
"react-router": patch
---

Support `middleware` on routes (unstable)

Routes can now define a `middleware` property accepting an array of functions that will run sequentially before route loader run in parallel. These functions accept the same arguments as `loader`/`action` and an additional `next` function to run the remaining data pipeline. This allows middlewares to perform logic before and after loaders/actions execute.

```tsx
// Framework mode
export const middleware = [logger, auth];

// Library mode
const routes = [
{
path: "/",
middleware: [logger, auth],
loader: rootLoader,
Component: Root,
},
];
```

Here's a simple example of a client-side logging middleware that can be placed on the root route:

```tsx
const logger: MiddlewareFunction = ({ request, params, context }, next) => {
let start = performance.now();

// Run the remaining middlewares and all route loaders
await next();

let duration = performance.now() - start;
console.log(request.status, request.method, request.url, `(${duration}ms)`);
};
```

You can throw a redirect from a middleware to short circuit any remaining processing:

```tsx
const auth: MiddlewareFunction = ({ request, params, context }, next) => {
let user = session.get("user");
if (!user) {
session.set("returnTo", request.url);
throw redirect("/login", 302);
}
context.user = user;
// No need to call next() if you don't need to do any post processing
};
```

Note that in the above example, the `next`/`middleware` functions don't return anything. This is by design as on the client there is no "response" to send over the network like there would be for middlewares running on the server. The data is all handled behind the scenes by the stateful `router`.

For a server-side middleware, the `next` function will return the HTTP `Response` that React Router will be sending across the wire, thus giving you a chance to make changes as needed. You may throw a new response to short circuit and respond immediately, or you may return a new or altered response to override the default returned by `next()`.

```tsx
const redirects: MiddlewareFunction({ request }, next) {
// attempt to handle the request
let response = await next();

// if it's a 404, check the CMS for a redirect, do it last
// because it's expensive
if (response.status === 404) {
let cmsRedirect = await checkCMSRedirects(request.url);
if (cmsRedirect) {
throw redirect(cmsRedirect, 302);
}
}

return response;
}
```
259 changes: 259 additions & 0 deletions decisions/0014-context-middleware.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,259 @@
# Middleware + Context

Date: 2025-01-22

Status: proposed

## Context

_Lol "context", get it 😉_

The [Middleware RFC][rfc] is the _most-upvoted_ RFC/Proposal in the React Router repo. We actually tried to build and ship it quite some time ago but realized that without single fetch it didn't make much sense in an SSR world for 2 reasons:

- With the individual HTTP requests per loader, middleware wouldn't actually reduce the # of queries to your DB/API's - it would just be a code convenience with no functional impact
- Individual HTTP requests meant a lack of a shared request scope across routes

We've done a lot of work since then to get us to a place where we could ship a middleware API we were happy with:

- Shipped [Single Fetch][single-fetch]
- Shipped [`dataStrategy`][data-strategy] for DIY middleware in React Router SPAs
- Iterated on middleware/context APIs in the [Remix the Web][remix-the-web] project
- Developed a non-invasive type-safe + composable [context][async-provider] API

## Decision

### Lean on existing `context` parameter for initial implementation

During our experiments we realized that we could offload type-safe context to an external package.This would result in a simpler implementation within React Router and avoid the need to try to patch on type-safety to our existing `context` API which was designed as a quick escape hatch to cross the bridge from your server (i.e., `express` `req`/`res`) to the Remix handlers.

Therefore, our recommendation for proper type-safe context for usage within middlewares and route handlers will be the [`@ryanflorence/async-provider`][async-provider] package.

If you don't want to adopt an extra package, or don't care about 100% type-safety and are happy with the module augmentation approach used by `AppLoadContext`, then you can use the existing `context` parameter passed to loaders and actions.

```ts
declare module "react-router" {
interface AppLoadContext {
user: User | null;
}
}

// root.tsx
function userMiddleware({ request, context }: Route.MiddlewareArgs) {
context.user = getUser(request);
}

export const middleware = [userMiddleware];

// In some other route
export async function loader({ context }: Route.LoaderArgs) {
let posts = await getPostsForUser(context.user);
return { posts };
}
```

#### Client Side Context

In order to support the same API on the client, we will need to add support for a client-side `context` value which is already a [long requested feature][client-context]. We can do so just like the server and let users use module augmentation to define their context shape. This will default to an empty object like the server, and can be passed to `<HydratedRouter>` in `entry.client` to provide up-front singleton values.

```ts
declare module "react-router" {
interface RouterContext {
logger(...args: unknown[]): void
}
}

// Singleton fields that don't change and are always available
let globalContext: RouterContext = { logger: getLogger() };

// ...
return <HydratedRouter context={globalContext}>
```

`context` on the server has the advantage of auto-cleanup since it's scoped to a request and thus automatically cleaned up after the request completes. In order to mimic this behavior on the client, we'll create a new object per navigation/fetch and spread in the properties from the global singleton context. Therefore, the context object you receive in your handlers will acually be something like:

```ts
let scopedContext = { ...globalContext };
```

This way, singleton values will always be there, but any new fields added to that object in middleware will only exist for that specific navigation or fetcher call and it will disappear once the request is complete.

### API

We wanted our middleware API to meet a handful of criteria:

- Allow users to perform logic sequentially top-down before handlers are called
- Allow users to modify he outgoing response bottom-up after handlers are called
- Allow multiple middlewares per route

The middleware API we landed on to ship looks as follows:

```ts
async function myMiddleware({ request, context, next }: Route.MiddlewareArgs) {
// Do stuff before the handlers are called
context.user = await getUser(request);
// Call handlers and generate the Response
let res = await next();
// Amend the response if needed
res.headers.set("X-Whatever", "stuff");
// Propagate the response up the middleware chain
return res;
}

// Export an array of middlewares per-route which will run left-to-right on
// the server
export const middleware = [myMiddleware];

// You can also export an array of client middlewares that run before/after
// `clientLoader`/`clientAction`
async function myClientMiddleware({
context,
next,
}: Route.ClientMiddlewareArgs) {
//...
}

export const clientMiddleware = [myClientSideMiddleware];
```

If you only want to perform logic _before_ the request, you can skip calling the `next` function and it'll be called and the response propagated upwards for you automatically:

```ts
async function myMiddleware({ request, context }: Route.MiddlewareArgs) {
context.user = await getUser(request);
// Look ma, no next!
}
```

The only nuance between server and client middleware is that on the server, we want to propagate a `Response` back up the middleware chain, so `next` must call the handlers _and_ generate the final response. In document requests, this will be the rendered HTML document,. and in data requests this will be the `turbo-stream` `Response`.

Client-side navigations don't really have this type of singular `Response` - they're just updating a stateful router and triggering a React re-render. Therefore, there is no response to bubble back up and the next function will run handlers but won't return anything so there's nothing to propagate back up the middleware chain.

### Client-side Implementation

For client side middleware, up until now we've been recommending that if folks want middleware they can add it themselves using `dataStrategy`. Therefore, we can leverage that API and add our middleware implementation inside our default `dataStrategy`. This has the primary advantage of being very simple to implement, but it also means that if folks decide to take control of their own `dataStrategy`, then they take control of the _entire_ data flow. It would have been confusing if a user provided a custom `dataStrategy` in which they wanted to do heir own middleware approach - and the router was still running it's own middleware logic before handing off to `dataStrategy`.

If users _want_ to take control over `loader`/`action` execution but still want to use our middleware flows, we should provide an API for them to do so. The current thought here is to pass them a utility into `dataStrategy` they can leverage:

```ts
async function dataStrategy({ request, matches, defaultMiddleware }) {
let results = await defaultMiddleware(() => {
// custom loader/action execution logic here
});
return results;
}
```

One consequence of implementing middleware as part of `dataStrategy` is that on client-side submission requests it will run once for the action and again for the loaders. We went back and forth on this a bit and decided this was the right approach because it mimics the current behavior of SPA navigations in a full-stack React Router app since actions and revalidations are separate HTTP requests and thus run the middleware chains independently. We don't expect this to be an issue except in expensive middlewares - and in those cases the context will be shared between the action/loader chains and the second execution can be skipped if necessary:

```ts
async function expensiveMiddleware({
request,
context,
}: Route.ClientMiddlewareArgs) {
// Guard this such that we use the existing value if it exists from the action pass
context.something = context.something ?? (await getExpensiveValue());
}
```

**Note:** This will make more sense after reading the next section, but it's worth noting that client middlewares _have_ to be run as part of `dataStrategy` to avoid running middlewares for loaders which have opted out of revalidation. The `shouldRevalidate` function decodes which loaders to run and does so using the `actionResult` as an input. so it's impossible to decide which loaders will be _prior_ to running the action. So we need to run middleware once for the action and again for the chosen loaders.

### Server-Side Implementation

Server-side middleware is a bit trickier because it needs to propagate a Response back upwards. This means that it _can't_ be done via `dataStrategy` because on document POST requests we need to know the results of _both_ the action and the loaders so we can render the HTML response. And we need to render the HTML response a single tim in `next`, which means middleware can only be run once _per request_ - not once for actions and once for loaders.

This is an important concept to grasp because it points out a nuance between document and data requests. GET navigations will behave the same because there is a single request/response for goth document and data GET navigations. POST navigations are different though:

- A document POST navigation (JS unavailable) is a single request/response to call action+loaders and generate a single HTML response.
- A data POST navigation (JS available) is 2 separate request/response's - one to call the action and a second revalidation call for the loaders.

This means that there may be a slight different in behavior of your middleware when it comes to loaders if you begin doing request-specific logic:

```ts
function weirdMiddleware({ request }) {
if (request.method === "POST") {
// ✅ Runs before the action/loaders on document submissions
// ✅ Runs before the action on data submissions
// ❌ Does not runs before the loaders on data submission revalidations
}
}
```

Our suggestion is mostly to avoid doing request-specific logic in middlewares, and if you need to do so, be aware of the behavior differences between document and data requests.

### Scenarios

The below outlines a few sample scenarios to give you an idea of the flow through middleware chains.

The simplest scenario is a document `GET /a/b` request:

- Start a `middleware`
- Start b `middleware`
- Run a/b `loaders` in parallel
- Render HTML `Response` to bubble back up via `next()`
- Finish b `middleware`
- Finish a `middleware`

If we introduce `clientMiddleware` but no `clientLoader` and client-side navigate to `/a/b`:

- Start a `clientMiddleware`
- Start b `clientMiddleware`
- `GET /a/b.data`
- Start a `middleware`
- Start b `middleware`
- Run a/b `loaders` in parallel
- Render HTML `Response` to bubble back up via `next()`
- Finish b `middleware`
- Finish a `middleware`
- Respond to client
- Finish b `clientMiddleware`
- Finish a `clientMiddleware`

If we have `clientLoaders` and they don't call server `loaders` (SPA Mode):

- Start a `clientMiddleware`
- Start b `clientMiddleware`
- Run a/b `clientLoaders` in parallel
- _No Response to render here so we can either bubble up `undefined` or potentially a `Location`_
- `Location` feels maybe a bit weird and introduces another way to redirect instead of `throw redirect`...
- Finish b `clientMiddleware`
- Finish a `clientMiddleware`

If `clientLoaders` do call `serverLoaders` it gets trickier since they make individual server requests:

- Start a `clientMiddleware`
- Start b `clientMiddleware`
- Run a/b `clientLoaders` in parallel
- `a` `clientLoader` calls GET `/a/b.data?route=a`
- Start a `middleware`
- Run a loader
- Render turbo-stream `Response` to bubble back up via `next()`
- Finish a `middleware`
- `b` `clientLoader` calls GET `/a/b.data?route=b`
- Start a `middleware`
- Start b `middleware`
- Run b loader
- Render turbo-stream `Response` to bubble back up via `next()`
- Finish b `middleware`
- Finish a `middleware`
- Finish b `clientMiddleware`
- Finish a `clientMiddleware`

### Other Thoughts

- Middleware is data-focused, not an event system
- you should nt be relying on middleware to track how many users hit a certain page etc
- middleware may run once for actions and once for loaders
- middleware will run independently for navigational loaders and fetcher loaders
- middleware may run many times for revalidations
- middleware may not run for revalidation opt outs
- Middleware allows you to run logic specific to a branch of the tree before/after data fns
- logging
- auth/redirecting
- 404 handling

[rfc]: https://github.com/remix-run/react-router/discussions/9564
[client-context]: https://github.com/remix-run/react-router/discussions/9856
[single-fetch]: https://remix.run/docs/en/main/guides/single-fetch
[data-strategy]: https://reactrouter.com/v6/routers/create-browser-router#optsdatastrategy
[remix-the-web]: https://github.com/mjackson/remix-the-web
[async-provider]: https://github.com/ryanflorence/async-provider
9 changes: 1 addition & 8 deletions integration/error-data-request-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,14 +112,7 @@ test.describe("ErrorBoundary", () => {
);
expect(status).toBe(200);
expect(headers.has("X-Remix-Error")).toBe(false);
expect(data).toEqual({
root: {
data: null,
},
"routes/_index": {
data: null,
},
});
expect(data).toEqual({});
});

test("returns a 405 on a data fetch POST to a path with no action", async () => {
Expand Down
Loading
Loading