diff --git a/.changeset/throw-abort-reason.md b/.changeset/throw-abort-reason.md new file mode 100644 index 0000000000..f998c98b88 --- /dev/null +++ b/.changeset/throw-abort-reason.md @@ -0,0 +1,7 @@ +--- +"@remix-run/router": minor +--- + +Add a `createStaticHandler` `future.v7_throwAbortReason` flag to throw `request.signal.reason` (defaults to a `DOMException`) when a request is aborted instead of an `Error` such as `new Error("query() call aborted: GET /path")` + +- Please note that `DOMException` was added in Node v17 so you will not get a `DOMException` on Node 16 and below. diff --git a/docs/guides/api-development-strategy.md b/docs/guides/api-development-strategy.md index 1944448301..13c25cfd85 100644 --- a/docs/guides/api-development-strategy.md +++ b/docs/guides/api-development-strategy.md @@ -71,6 +71,23 @@ const router = createBrowserRouter(routes, { | `v7_prependBasename` | Prepend the router basename to navigate/fetch paths | | [`v7_relativeSplatPath`][relativesplatpath] | Fix buggy relative path resolution in splat routes | +#### `createStaticHandler` Future Flags + +These flags are only applicable when [SSR][ssr]-ing a React Router app: + +```js +const handler = createStaticHandler(routes, { + future: { + v7_throwAbortReason: true, + }, +}); +``` + +| Flag | Description | +| ------------------------------------------- | ----------------------------------------------------------------------- | +| [`v7_relativeSplatPath`][relativesplatpath] | Fix buggy relative path resolution in splat routes | +| [`v7_throwAbortReason`][abortreason] | Throw `request.signal.reason` if a `query`/`queryRoute` call is aborted | + ### React Router Future Flags These flags apply to both Data and non-Data Routers and are passed to the rendered React component: @@ -98,3 +115,5 @@ These flags apply to both Data and non-Data Routers and are passed to the render [starttransition]: https://react.dev/reference/react/startTransition [partialhydration]: ../routers/create-browser-router#partial-hydration-data [relativesplatpath]: ../hooks/use-resolved-path#splat-paths +[ssr]: ../guides/ssr +[abortreason]: ../routers/create-static-handler#handlerqueryrequest-opts diff --git a/docs/routers/create-static-handler.md b/docs/routers/create-static-handler.md index fc60d18f3d..8da3b77dc6 100644 --- a/docs/routers/create-static-handler.md +++ b/docs/routers/create-static-handler.md @@ -54,12 +54,27 @@ export async function renderHtml(req) { ```ts declare function createStaticHandler( - routes: RouteObject[], - opts?: { - basename?: string; - } + routes: AgnosticRouteObject[], + opts?: CreateStaticHandlerOptions ): StaticHandler; +interface CreateStaticHandlerOptions { + basename?: string; + future?: Partial; + mapRouteProperties?: MapRoutePropertiesFunction; +} + +interface StaticHandlerFutureConfig { + v7_relativeSplatPath: boolean; + v7_throwAbortReason: boolean; +} + +interface MapRoutePropertiesFunction { + (route: AgnosticRouteObject): { + hasErrorBoundary: boolean; + } & Record; +} + interface StaticHandler { dataRoutes: AgnosticDataRouteObject[]; query( @@ -86,6 +101,8 @@ These are the same `routes`/`basename` you would pass to [`createBrowserRouter`] The `handler.query()` method takes in a Fetch request, performs route matching, and executes all relevant route action/loader methods depending on the request. The return `context` value contains all of the information required to render the HTML document for the request (route-level `actionData`, `loaderData`, `errors`, etc.). If any of the matched routes return or throw a redirect response, then `query()` will return that redirect in the form of Fetch `Response`. +If a request is aborted, `query` will throw an error such as `Error("query() call aborted: GET /path")`. If you want to throw the native `AbortSignal.reason` (by default a `DOMException`) you can opt-in into the `future.v7_throwAbortReason` future flag. `DOMException` was added in Node 17 so you must be on Node 17 or higher for this to work properly. + ### `opts.requestContext` If you need to pass information from your server into Remix actions/loaders, you can do so with `opts.requestContext` and it will show up in your actions/loaders in the context parameter. @@ -115,6 +132,8 @@ export async function render(req: express.Request) { The `handler.queryRoute` is a more-targeted version that queries a singular route and runs it's loader or action based on the request. By default, it will match the target route based on the request URL. The return value is the values returned from the loader or action, which is usually a `Response` object. +If a request is aborted, `query` will throw an error such as `Error("queryRoute() call aborted: GET /path")`. If you want to throw the native `AbortSignal.reason` (by default a `DOMException`) you can opt-in into the `future.v7_throwAbortReason` future flag. `DOMException` was added in Node 17 so you must be on Node 17 or higher for this to work properly. + ### `opts.routeId` If you need to call a specific route action/loader that doesn't exactly correspond to the URL (for example, a parent route loader), you can specify a `routeId`: diff --git a/packages/router/__tests__/ssr-test.ts b/packages/router/__tests__/ssr-test.ts index 64e58f4bc1..0a0e0dcb44 100644 --- a/packages/router/__tests__/ssr-test.ts +++ b/packages/router/__tests__/ssr-test.ts @@ -1,3 +1,7 @@ +/** + * @jest-environment node + */ + import type { StaticHandler, StaticHandlerContext } from "../router"; import { UNSAFE_DEFERRED_SYMBOL, createStaticHandler } from "../router"; import { @@ -652,6 +656,106 @@ describe("ssr", () => { ); }); + it("should handle aborted load requests (v7_throwAbortReason=true)", async () => { + let dfd = createDeferred(); + let controller = new AbortController(); + let { query } = createStaticHandler( + [ + { + id: "root", + path: "/path", + loader: () => dfd.promise, + }, + ], + { future: { v7_throwAbortReason: true } } + ); + let request = createRequest("/path?key=value", { + signal: controller.signal, + }); + let e; + try { + let contextPromise = query(request); + controller.abort(); + // This should resolve even though we never resolved the loader + await contextPromise; + } catch (_e) { + e = _e; + } + // DOMException added in node 17 + if (process.versions.node.split(".").map(Number)[0] >= 17) { + // eslint-disable-next-line jest/no-conditional-expect + expect(e).toBeInstanceOf(DOMException); + } + expect(e.name).toBe("AbortError"); + expect(e.message).toBe("This operation was aborted"); + }); + + it("should handle aborted submit requests (v7_throwAbortReason=true)", async () => { + let dfd = createDeferred(); + let controller = new AbortController(); + let { query } = createStaticHandler( + [ + { + id: "root", + path: "/path", + action: () => dfd.promise, + }, + ], + { future: { v7_throwAbortReason: true } } + ); + let request = createSubmitRequest("/path?key=value", { + signal: controller.signal, + }); + let e; + try { + let contextPromise = query(request); + controller.abort(); + // This should resolve even though we never resolved the loader + await contextPromise; + } catch (_e) { + e = _e; + } + // DOMException added in node 17 + if (process.versions.node.split(".").map(Number)[0] >= 17) { + // eslint-disable-next-line jest/no-conditional-expect + expect(e).toBeInstanceOf(DOMException); + } + expect(e.name).toBe("AbortError"); + expect(e.message).toBe("This operation was aborted"); + }); + + it("should handle aborted requests (v7_throwAbortReason=true + custom reason)", async () => { + let dfd = createDeferred(); + let controller = new AbortController(); + let { query } = createStaticHandler( + [ + { + id: "root", + path: "/path", + loader: () => dfd.promise, + }, + ], + { future: { v7_throwAbortReason: true } } + ); + let request = createRequest("/path?key=value", { + signal: controller.signal, + }); + let e; + try { + let contextPromise = query(request); + // Note this works in Node 18+ - but it does not work if using the + // `abort-controller` polyfill which doesn't yet support a custom `reason` + // See: /~https://github.com/mysticatea/abort-controller/issues/33 + controller.abort(new Error("Oh no!")); + // This should resolve even though we never resolved the loader + await contextPromise; + } catch (_e) { + e = _e; + } + expect(e).toBeInstanceOf(Error); + expect(e.message).toBe("Oh no!"); + }); + it("should assign signals to requests by default (per the", async () => { let { query } = createStaticHandler(SSR_ROUTES); let request = createRequest("/", { signal: undefined }); @@ -1951,6 +2055,106 @@ describe("ssr", () => { ); }); + it("should handle aborted load requests (v7_throwAbortReason=true)", async () => { + let dfd = createDeferred(); + let controller = new AbortController(); + let { queryRoute } = createStaticHandler( + [ + { + id: "root", + path: "/path", + loader: () => dfd.promise, + }, + ], + { future: { v7_throwAbortReason: true } } + ); + let request = createRequest("/path?key=value", { + signal: controller.signal, + }); + let e; + try { + let statePromise = queryRoute(request, { routeId: "root" }); + controller.abort(); + // This should resolve even though we never resolved the loader + await statePromise; + } catch (_e) { + e = _e; + } + // DOMException added in node 17 + if (process.versions.node.split(".").map(Number)[0] >= 17) { + // eslint-disable-next-line jest/no-conditional-expect + expect(e).toBeInstanceOf(DOMException); + } + expect(e.name).toBe("AbortError"); + expect(e.message).toBe("This operation was aborted"); + }); + + it("should handle aborted submit requests (v7_throwAbortReason=true)", async () => { + let dfd = createDeferred(); + let controller = new AbortController(); + let { queryRoute } = createStaticHandler( + [ + { + id: "root", + path: "/path", + action: () => dfd.promise, + }, + ], + { future: { v7_throwAbortReason: true } } + ); + let request = createSubmitRequest("/path?key=value", { + signal: controller.signal, + }); + let e; + try { + let statePromise = queryRoute(request, { routeId: "root" }); + controller.abort(); + // This should resolve even though we never resolved the loader + await statePromise; + } catch (_e) { + e = _e; + } + // DOMException added in node 17 + if (process.versions.node.split(".").map(Number)[0] >= 17) { + // eslint-disable-next-line jest/no-conditional-expect + expect(e).toBeInstanceOf(DOMException); + } + expect(e.name).toBe("AbortError"); + expect(e.message).toBe("This operation was aborted"); + }); + + it("should handle aborted load requests (v7_throwAbortReason=true + custom reason)", async () => { + let dfd = createDeferred(); + let controller = new AbortController(); + let { queryRoute } = createStaticHandler( + [ + { + id: "root", + path: "/path", + loader: () => dfd.promise, + }, + ], + { future: { v7_throwAbortReason: true } } + ); + let request = createRequest("/path?key=value", { + signal: controller.signal, + }); + let e; + try { + let statePromise = queryRoute(request, { routeId: "root" }); + // Note this works in Node 18+ - but it does not work if using the + // `abort-controller` polyfill which doesn't yet support a custom `reason` + // See: /~https://github.com/mysticatea/abort-controller/issues/33 + controller.abort(new Error("Oh no!")); + // This should resolve even though we never resolved the loader + await statePromise; + } catch (_e) { + e = _e; + } + expect(e).toBeInstanceOf(Error); + expect(e.message).toBe("Oh no!"); + }); + it("should assign signals to requests by default (per the spec)", async () => { let { queryRoute } = createStaticHandler(SSR_ROUTES); let request = createRequest("/", { signal: undefined }); diff --git a/packages/router/router.ts b/packages/router/router.ts index 447b12214d..fc322496d2 100644 --- a/packages/router/router.ts +++ b/packages/router/router.ts @@ -2834,6 +2834,7 @@ export const UNSAFE_DEFERRED_SYMBOL = Symbol("deferred"); */ export interface StaticHandlerFutureConfig { v7_relativeSplatPath: boolean; + v7_throwAbortReason: boolean; } export interface CreateStaticHandlerOptions { @@ -2872,6 +2873,7 @@ export function createStaticHandler( // Config driven behavior flags let future: StaticHandlerFutureConfig = { v7_relativeSplatPath: false, + v7_throwAbortReason: false, ...(opts ? opts.future : null), }; @@ -3141,10 +3143,7 @@ export function createStaticHandler( ); if (request.signal.aborted) { - let method = isRouteRequest ? "queryRoute" : "query"; - throw new Error( - `${method}() call aborted: ${request.method} ${request.url}` - ); + throwStaticHandlerAbortedError(request, isRouteRequest, future); } } @@ -3312,10 +3311,7 @@ export function createStaticHandler( ]); if (request.signal.aborted) { - let method = isRouteRequest ? "queryRoute" : "query"; - throw new Error( - `${method}() call aborted: ${request.method} ${request.url}` - ); + throwStaticHandlerAbortedError(request, isRouteRequest, future); } // Process and commit output from loaders @@ -3380,6 +3376,19 @@ export function getStaticContextFromError( return newContext; } +function throwStaticHandlerAbortedError( + request: Request, + isRouteRequest: boolean, + future: StaticHandlerFutureConfig +) { + if (future.v7_throwAbortReason && request.signal.reason !== undefined) { + throw request.signal.reason; + } + + let method = isRouteRequest ? "queryRoute" : "query"; + throw new Error(`${method}() call aborted: ${request.method} ${request.url}`); +} + function isSubmissionNavigation( opts: BaseNavigateOrFetchOptions ): opts is SubmissionNavigateOptions {