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

Add support for client context and middleware (unstable) #12941

Draft
wants to merge 8 commits into
base: dev
Choose a base branch
from

Conversation

brophdawg11
Copy link
Contributor

Middleware RFC: remix-run/remix#7642
Client Context RFC: #9856
Closes: #12695

Copy link

changeset-bot bot commented Feb 3, 2025

🦋 Changeset detected

Latest commit: 1ea21fd

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 11 packages
Name Type
react-router Patch
@react-router/architect Patch
@react-router/cloudflare Patch
@react-router/dev Patch
react-router-dom Patch
@react-router/express Patch
@react-router/node Patch
@react-router/serve Patch
@react-router/fs-routes Patch
@react-router/remix-routes-option-adapter Patch
create-react-router Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

Comment on lines +177 to +183
dataStrategy: ssrInfo.context.isSpaMode
? undefined
: getSingleFetchDataStrategy(
ssrInfo.manifest,
ssrInfo.routeModules,
() => router
),
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SPA Mode never should have de-optimized revalidation the way single fetch does because there is no server

Comment on lines +159 to +168
return runMiddlewarePipeline(
args,
matches.findIndex((m) => m.shouldLoad),
false,
async (keyedResults) => {
let results = await singleFetchActionStrategy(request, matches);
Object.assign(keyedResults, results);
},
middlewareErrorHandler
) as Promise<Record<string, DataStrategyResult>>;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because middleware is part of the default data strategy, we have to re-implement it here in our custom data strategy and can do so using the same runMiddlewarePipeline API we use internally. I'm thinking we should make some form of this public API as well for userland dataStrategy implementation who want to use the normal middleware.

The current API is as follows - may be leaking some implementation details we could hide in the exported version though:

runMiddlewarePipeline(
  // Passthrough of { request, matches, context } from dataStrategy
  args, 

  // how deep?  I.e., what is the lowest handler to run
  matchIndexToRunMiddlewareTo, 

  // Should I bubble up a returned Response?  SSR only - always `false` in user client-side implementations
  false, 

  // callback to run the handlers and assign results to keyedResults
  // async (keyedResults: Record<string, DataStrategyResult>) { ... },

  // Error callback if a middleware throws an error - assign the error to keyedResults
  async (e: MiddlewareError, keyedResults: Record<string, DataStrategyResult>) { ... }  
)

Maybe we could pass it as an arg to dataStrategy? We could remove the boolean and handle that for them internally, and then instead of using an index we could just let them hand us the matches which they could .slice if they didn't want to run all the way down:

function dataStrategy({ request, params, context, matches, runMiddleware }) {
  return runMiddleware(
    { request, params, context }, 
    matches,
    (results) => { /* run handlers, assign to results */ },
    (e, results) => { /* handle error */ },
  );    
})

return singleFetchLoaderNavigationStrategy(

// Determine how deep to run middleware
let lowestLoadingIndex = getLowestLoadingIndex(
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We only run client middleware down to the lowest server loader we will run

Comment on lines +409 to +412
filterMatchesToLoad?: (match: AgnosticDataRouteMatch) => boolean;
skipLoaderErrorBubbling?: boolean;
dataStrategy?: DataStrategyFunction;
skipRevalidation?: boolean;
dataStrategy?: DataStrategyFunction<unknown>;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We used to use a dataStrategy on the server to filter out matches (for ?_routes support ) and to skip running loaders (for POST /path.data requests) but it was one more layer of abstraction and they felt like useful APIs to have built in anyway so filterMatchesToLoad/skipRevalidation bring that logic into .query so we can get rid of our server side dataStrategy entirely

Comment on lines +413 to +415
unstable_respond?: (
staticContext: StaticHandlerContext
) => Response | Promise<Response>;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the new API on the static handler to enable middleware. Currently we return a StaticHandlerContext from this method which is of the shape { loaderData, actionData, errors, ... }. But for middleware we need to generate a Response after running the loaders/actions that we can return from next and bubble back up the middleware chain. So we had 2 options - we could implement server side middleware in server-runtime code entirely separate from the staticHandler but that feels inconsistent with the client side implementation and also potentially means a separate set of code.

Instead, if we have the user tell us how to convert StaticHandlerContext -> Response then we can run all the middleware inside .query() and use all the same code and make .query a more useful API on it's own.

@@ -1581,6 +1597,9 @@ export function createRouter(init: RouterInit): Router {
pendingNavigationController.signal,
opts && opts.submission
);
// Create a new context per navigation that has references to all global
// contextual fields
let scopedContext = { ...unstable_RouterContext };
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Create a new context instance per navigation/fetcher call

return respond ? respond(staticContext) : staticContext;
}

if (respond && matches.some((m) => m.route.unstable_middleware)) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we received an unstable_respond method and one of our routes has middleware, run the new code path. This means that we won't run the new code for anyone not opted into middleware

@@ -3681,6 +3900,48 @@ export function createStaticHandler(
};
}

if (skipRevalidation) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

new skipRevalidation behavior - short circuits after running the action

Comment on lines +4905 to +4907
if (!args.matches.some((m) => m.route.unstable_middleware)) {
return defaultDataStrategy(args);
}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Run the old code if no middleware exists

propagateResult: boolean;
};

export async function runMiddlewarePipeline(
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the main implementation to run the middlewares and provide the next method

Copy link
Member

@mjackson mjackson left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I made a few comments to try and line up types between this work and the work we'll be launching later this year in Remix. It would be nice if we shared the same middleware API between RR and Remix.

* specifically so apps can leverage declaration merging to augment this type
* globally: https://www.typescriptlang.org/docs/handbook/declaration-merging.html
*/
export interface unstable_RouterContext {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here is the type of the context object in Remix: https://github.com/mjackson/remix-the-web/blob/router/packages/fetch-router/src/lib/context.ts

This does not establish a global namespace that requires declaration merging. Rather, it works more like React context where context keys are specific Context objects.

Note: the context object in middleware and loaders is actually an instance of ContextProvider.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was designed as a corollary of AppLoadContext for users already familiar with that on the server side so we have some consistency across server- and client-side middleware APIs. We didn't want to try to design/plumb through a new fully-typesafe context into the existing client-side router stuff and instead we were recommending folks use https://github.com/ryanflorence/async-provider if they want 100% type-safety. That way it didn't overcomplicate the internal implementation of the router as it stands today and it didn't stray from what the server side looks like and make them inconsistent.

If we want to use ContextProvider on the client then we will need to also use a future flag to deprecate context as AppLoadContext on the server and make the adapter layer ContextProvider aware as well.

Is ContextProvider replacing our task to implement async-provider as a standalone package?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Chatted with Michael - we are going to try to ship this in the first implementation of middleware behind the same future flag to avoid introducing this client side augmentable context just to replace it

/**
* Route middleware function arguments
*/
export type unstable_MiddlewareFunctionArgs<
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here is the Middleware type we're using in Remix: https://github.com/mjackson/remix-the-web/blob/router/packages/fetch-router/src/lib/middleware.ts

Note that the next function is a separate 2nd arg to the middeware function. We're calling the 1st arg the env.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could do that - that's how it was initially but we moved it into the first param to improve the DX and consistency with our typegen in framework mode:

// Current APIs
function headers({ loaderHeaders }: Route.HeadersArgs) { ... }
function action({ request, context }: Route.ActionArgs) { ... }
function loader({ request, context }: Route.LoaderArgs) { ... }
function Component({ loaderData }: Route.ComponentProps) { ... }
function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { ... }

// With `next` in the first arg we keep some nice consistency
function auth({ request, context, next }: Route.MiddlewareArgs) { ... }
export const middleware = [auth];

function clientLogger({ request, context, next }: Route.ClientMiddlewareArgs) { ... }
export const clientMiddleware = [clientLogger];


// With next as it's own param it's more verbose and a inconsistent
function auth(
  { request, context }: Route.MiddlewareArgs, 
  next: Route.MiddlewareNextFunction,
) { ... }
export const middleware = [auth];

function clientLogger(
  { request, context }: Route.ClientMiddlewareArgs, 
  next: Route.ClientMiddlewareNextFunction,
) { ... }
export const clientMiddleware = [clientLogger];

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Going to move next back to a second param and either const auth: Route.MiddlewareFunction = () => {} or satisfies Route.MiddlewareFunction should help ease the DX and also let us type the return values


### 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.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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.
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.

### 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`.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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`.
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 their own middleware approach - and the router was still running it's own middleware logic before handing off to `dataStrategy`.

}
```
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`.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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`.
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`.

### 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.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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.
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 time in `next`, which means middleware can only be run once _per request_ - not once for actions and once for loaders.

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:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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:
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 both 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:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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:
This means that there may be a slight difference in behavior of your middleware when it comes to loaders if you begin doing request-specific logic:

### 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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
- you should nt be relying on middleware to track how many users hit a certain page etc
- you should not be relying on middleware to track how many users hit a certain page etc

"unstable_middleware",
"headers",
];
const CLIENT_NON_COMPONENT_EXPORTS = [
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a really nice clean up 👏

Comment on lines +88 to +90
/**
* Automatically split route modules into multiple chunks when possible.
*/
Copy link

@jaschaio jaschaio Feb 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
/**
* Automatically split route modules into multiple chunks when possible.
*/

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants