-
-
Notifications
You must be signed in to change notification settings - Fork 10.4k
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
base: dev
Are you sure you want to change the base?
Conversation
🦋 Changeset detectedLatest commit: 1ea21fd The changes in this PR will be included in the next version bump. This PR includes changesets to release 11 packages
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 |
dataStrategy: ssrInfo.context.isSpaMode | ||
? undefined | ||
: getSingleFetchDataStrategy( | ||
ssrInfo.manifest, | ||
ssrInfo.routeModules, | ||
() => router | ||
), |
There was a problem hiding this comment.
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
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>>; |
There was a problem hiding this comment.
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( |
There was a problem hiding this comment.
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
filterMatchesToLoad?: (match: AgnosticDataRouteMatch) => boolean; | ||
skipLoaderErrorBubbling?: boolean; | ||
dataStrategy?: DataStrategyFunction; | ||
skipRevalidation?: boolean; | ||
dataStrategy?: DataStrategyFunction<unknown>; |
There was a problem hiding this comment.
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
unstable_respond?: ( | ||
staticContext: StaticHandlerContext | ||
) => Response | Promise<Response>; |
There was a problem hiding this comment.
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 }; |
There was a problem hiding this comment.
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)) { |
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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
if (!args.matches.some((m) => m.route.unstable_middleware)) { | ||
return defaultDataStrategy(args); | ||
} |
There was a problem hiding this comment.
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( |
There was a problem hiding this comment.
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
There was a problem hiding this 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 { |
There was a problem hiding this comment.
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
.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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< |
There was a problem hiding this comment.
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
.
There was a problem hiding this comment.
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];
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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`. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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`. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
- 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 = [ |
There was a problem hiding this comment.
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 👏
/** | ||
* Automatically split route modules into multiple chunks when possible. | ||
*/ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
/** | |
* Automatically split route modules into multiple chunks when possible. | |
*/ |
Middleware RFC: remix-run/remix#7642
Client Context RFC: #9856
Closes: #12695