From beda7bf7b81280d52905b9c434e185d24a2df8b8 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Fri, 8 Dec 2023 11:25:52 -0500 Subject: [PATCH 1/6] Add future flag to throw request.signal.reason for aborted requests --- .changeset/throw-abort-reason.md | 5 + docs/guides/api-development-strategy.md | 19 +++ docs/routers/create-static-handler.md | 27 +++- packages/router/__tests__/ssr-test.ts | 185 ++++++++++++++++++++++++ packages/router/router.ts | 27 +++- 5 files changed, 251 insertions(+), 12 deletions(-) create mode 100644 .changeset/throw-abort-reason.md diff --git a/.changeset/throw-abort-reason.md b/.changeset/throw-abort-reason.md new file mode 100644 index 0000000000..4062c4dd82 --- /dev/null +++ b/.changeset/throw-abort-reason.md @@ -0,0 +1,5 @@ +--- +"@remix-run/router": minor +--- + +Add `createStaticHandler` `future.v7_throwAbortReason` flag to instead throw `request.signal.reason` when a request is aborted instead of our own custom `new Error()` with a message such as `query() call aborted`/`queryRoute() call aborted`. diff --git a/docs/guides/api-development-strategy.md b/docs/guides/api-development-strategy.md index 1944448301..2983f859cf 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..d5ec8ac128 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")`. If you want to throw the native `AbortSignal.reason` (by default a `DOMException`) you can opt-in into the `future.v7_throwAbortReason` future flag. + ### `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("query() call aborted")`. If you want to throw the native `AbortSignal.reason` (by default a `DOMException`) you can opt-in into the `future.v7_throwAbortReason` future flag. + ### `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..55ac362bb9 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,95 @@ 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; + } + 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; + } + 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); + 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 +2044,98 @@ 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; + } + 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; + } + 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 a00f54e627..0037d1f30e 100644 --- a/packages/router/router.ts +++ b/packages/router/router.ts @@ -2823,6 +2823,7 @@ export const UNSAFE_DEFERRED_SYMBOL = Symbol("deferred"); */ export interface StaticHandlerFutureConfig { v7_relativeSplatPath: boolean; + v7_throwAbortReason: boolean; } export interface CreateStaticHandlerOptions { @@ -2861,6 +2862,7 @@ export function createStaticHandler( // Config driven behavior flags let future: StaticHandlerFutureConfig = { v7_relativeSplatPath: false, + v7_throwAbortReason: false, ...(opts ? opts.future : null), }; @@ -3130,10 +3132,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); } } @@ -3301,10 +3300,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 @@ -3369,6 +3365,21 @@ export function getStaticContextFromError( return newContext; } +function throwStaticHandlerAbortedError( + request: Request, + isRouteRequest: boolean, + future: StaticHandlerFutureConfig +) { + let method = isRouteRequest ? "queryRoute" : "query"; + if (future.v7_throwAbortReason && request.signal.reason !== undefined) { + throw request.signal.reason; + } else { + throw new Error( + `${method}() call aborted: ${request.method} ${request.url}` + ); + } +} + function isSubmissionNavigation( opts: BaseNavigateOrFetchOptions ): opts is SubmissionNavigateOptions { From 4e43b7e8388c3242c84bcfddd05d9c67b28081e3 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Fri, 8 Dec 2023 11:29:39 -0500 Subject: [PATCH 2/6] Dup comment in test --- packages/router/__tests__/ssr-test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/router/__tests__/ssr-test.ts b/packages/router/__tests__/ssr-test.ts index 55ac362bb9..cb26a6a2d2 100644 --- a/packages/router/__tests__/ssr-test.ts +++ b/packages/router/__tests__/ssr-test.ts @@ -735,6 +735,9 @@ describe("ssr", () => { 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; From 9463fb5eb1772b9ba0fc2958e18f11f1cfd40308 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Fri, 8 Dec 2023 11:54:35 -0500 Subject: [PATCH 3/6] Note about DOMException not existing in Node 16 --- .changeset/throw-abort-reason.md | 4 +++- docs/routers/create-static-handler.md | 4 ++-- packages/router/__tests__/ssr-test.ts | 24 ++++++++++++++++++++---- 3 files changed, 25 insertions(+), 7 deletions(-) diff --git a/.changeset/throw-abort-reason.md b/.changeset/throw-abort-reason.md index 4062c4dd82..fc64e2ea01 100644 --- a/.changeset/throw-abort-reason.md +++ b/.changeset/throw-abort-reason.md @@ -2,4 +2,6 @@ "@remix-run/router": minor --- -Add `createStaticHandler` `future.v7_throwAbortReason` flag to instead throw `request.signal.reason` when a request is aborted instead of our own custom `new Error()` with a message such as `query() call aborted`/`queryRoute() call aborted`. +Add `createStaticHandler` `future.v7_throwAbortReason` flag to instead throw `request.signal.reason` (defaults to a `DOMException`) when a request is aborted instead of our own custom `new Error()` with a message such as `query() call aborted`/`queryRoute() call aborted` + +- 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/routers/create-static-handler.md b/docs/routers/create-static-handler.md index d5ec8ac128..54f377fff6 100644 --- a/docs/routers/create-static-handler.md +++ b/docs/routers/create-static-handler.md @@ -101,7 +101,7 @@ 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")`. If you want to throw the native `AbortSignal.reason` (by default a `DOMException`) you can opt-in into the `future.v7_throwAbortReason` future flag. +If a request is aborted, `query` will throw an error such as `Error("query() call aborted")`. 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` @@ -132,7 +132,7 @@ 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("query() call aborted")`. If you want to throw the native `AbortSignal.reason` (by default a `DOMException`) you can opt-in into the `future.v7_throwAbortReason` future flag. +If a request is aborted, `query` will throw an error such as `Error("query() call aborted")`. 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` diff --git a/packages/router/__tests__/ssr-test.ts b/packages/router/__tests__/ssr-test.ts index cb26a6a2d2..0a0e0dcb44 100644 --- a/packages/router/__tests__/ssr-test.ts +++ b/packages/router/__tests__/ssr-test.ts @@ -681,7 +681,11 @@ describe("ssr", () => { } catch (_e) { e = _e; } - expect(e).toBeInstanceOf(DOMException); + // 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"); }); @@ -711,7 +715,11 @@ describe("ssr", () => { } catch (_e) { e = _e; } - expect(e).toBeInstanceOf(DOMException); + // 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"); }); @@ -2072,7 +2080,11 @@ describe("ssr", () => { } catch (_e) { e = _e; } - expect(e).toBeInstanceOf(DOMException); + // 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"); }); @@ -2102,7 +2114,11 @@ describe("ssr", () => { } catch (_e) { e = _e; } - expect(e).toBeInstanceOf(DOMException); + // 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"); }); From e7ce895957b911a15a0708d3b7e899e80eff90b7 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Thu, 25 Jan 2024 13:43:33 -0500 Subject: [PATCH 4/6] Update docs and minor impl change --- docs/routers/create-static-handler.md | 4 ++-- packages/router/router.ts | 8 +++----- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/docs/routers/create-static-handler.md b/docs/routers/create-static-handler.md index 54f377fff6..8da3b77dc6 100644 --- a/docs/routers/create-static-handler.md +++ b/docs/routers/create-static-handler.md @@ -101,7 +101,7 @@ 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")`. 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. +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` @@ -132,7 +132,7 @@ 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("query() call aborted")`. 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. +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` diff --git a/packages/router/router.ts b/packages/router/router.ts index 81fa1f2987..fc322496d2 100644 --- a/packages/router/router.ts +++ b/packages/router/router.ts @@ -3381,14 +3381,12 @@ function throwStaticHandlerAbortedError( isRouteRequest: boolean, future: StaticHandlerFutureConfig ) { - let method = isRouteRequest ? "queryRoute" : "query"; if (future.v7_throwAbortReason && request.signal.reason !== undefined) { throw request.signal.reason; - } else { - throw new Error( - `${method}() call aborted: ${request.method} ${request.url}` - ); } + + let method = isRouteRequest ? "queryRoute" : "query"; + throw new Error(`${method}() call aborted: ${request.method} ${request.url}`); } function isSubmissionNavigation( From 15b6ce2da02f311dd07d62d9db4780275d34113f Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Thu, 25 Jan 2024 14:16:42 -0500 Subject: [PATCH 5/6] Update changelog --- .changeset/throw-abort-reason.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/throw-abort-reason.md b/.changeset/throw-abort-reason.md index fc64e2ea01..f998c98b88 100644 --- a/.changeset/throw-abort-reason.md +++ b/.changeset/throw-abort-reason.md @@ -2,6 +2,6 @@ "@remix-run/router": minor --- -Add `createStaticHandler` `future.v7_throwAbortReason` flag to instead throw `request.signal.reason` (defaults to a `DOMException`) when a request is aborted instead of our own custom `new Error()` with a message such as `query() call aborted`/`queryRoute() call aborted` +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. From 219def25de8e8c86c0dd8a24f37618c0840bf211 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Thu, 25 Jan 2024 15:02:55 -0500 Subject: [PATCH 6/6] Update docs --- docs/guides/api-development-strategy.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/guides/api-development-strategy.md b/docs/guides/api-development-strategy.md index 2983f859cf..13c25cfd85 100644 --- a/docs/guides/api-development-strategy.md +++ b/docs/guides/api-development-strategy.md @@ -83,10 +83,10 @@ const handler = createStaticHandler(routes, { }); ``` -| 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 | +| 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