diff --git a/.changeset/fifty-rockets-hide.md b/.changeset/fifty-rockets-hide.md new file mode 100644 index 0000000000..c765bee5fe --- /dev/null +++ b/.changeset/fifty-rockets-hide.md @@ -0,0 +1,11 @@ +--- +"@remix-run/router": minor +--- + +Add a new `unstable_data()` API for usage with Remix Single Fetch + +- This API is not intended for direct usage in React Router SPA applications +- It is primarily intended for usage with `createStaticHandler.query()` to allow loaders/actions to return arbitrary data + `status`/`headers` without forcing the serialization of data into a `Response` instance +- This allows for more advanced serialization tactics via `unstable_dataStrategy` such as serializing via `turbo-stream` in Remix Single Fetch +- ⚠️ This removes the `status` field from `HandlerResult` + - If you need to return a specific `status` from `unstable_dataStrategy` you should instead do so via `unstable_data()` diff --git a/package.json b/package.json index e4dbb0d9d2..20e1e13cd4 100644 --- a/package.json +++ b/package.json @@ -105,7 +105,7 @@ }, "filesize": { "packages/router/dist/router.umd.min.js": { - "none": "56.4 kB" + "none": "57.1 kB" }, "packages/react-router/dist/react-router.production.min.js": { "none": "14.9 kB" diff --git a/packages/router/index.ts b/packages/router/index.ts index 86b1c8b237..1c015883a2 100644 --- a/packages/router/index.ts +++ b/packages/router/index.ts @@ -37,6 +37,7 @@ export type { export { AbortedDeferredError, + data as unstable_data, defer, generatePath, getToPathname, diff --git a/packages/router/router.ts b/packages/router/router.ts index d82f5a0b55..d6b9c8ab62 100644 --- a/packages/router/router.ts +++ b/packages/router/router.ts @@ -36,6 +36,7 @@ import type { V7_FormMethod, V7_MutationFormMethod, AgnosticPatchRoutesOnMissFunction, + DataWithResponseInit, } from "./utils"; import { ErrorResponseImpl, @@ -4906,7 +4907,7 @@ async function callLoaderOrAction( async function convertHandlerResultToDataResult( handlerResult: HandlerResult ): Promise { - let { result, type, status } = handlerResult; + let { result, type } = handlerResult; if (isResponse(result)) { let data: any; @@ -4946,10 +4947,26 @@ async function convertHandlerResultToDataResult( } if (type === ResultType.error) { + if (isDataWithResponseInit(result)) { + if (result.data instanceof Error) { + return { + type: ResultType.error, + error: result.data, + statusCode: result.init?.status, + }; + } + + // Convert thrown unstable_data() to ErrorResponse instances + result = new ErrorResponseImpl( + result.init?.status || 500, + undefined, + result.data + ); + } return { type: ResultType.error, error: result, - statusCode: isRouteErrorResponse(result) ? result.status : status, + statusCode: isRouteErrorResponse(result) ? result.status : undefined, }; } @@ -4962,7 +4979,18 @@ async function convertHandlerResultToDataResult( }; } - return { type: ResultType.data, data: result, statusCode: status }; + if (isDataWithResponseInit(result)) { + return { + type: ResultType.data, + data: result.data, + statusCode: result.init?.status, + headers: result.init?.headers + ? new Headers(result.init.headers) + : undefined, + }; + } + + return { type: ResultType.data, data: result }; } // Support relative routing in internal redirects @@ -5476,6 +5504,19 @@ function isRedirectResult(result?: DataResult): result is RedirectResult { return (result && result.type) === ResultType.redirect; } +export function isDataWithResponseInit( + value: any +): value is DataWithResponseInit { + return ( + typeof value === "object" && + value != null && + "type" in value && + "data" in value && + "init" in value && + value.type === "DataWithResponseInit" + ); +} + export function isDeferredData(value: any): value is DeferredData { let deferred: DeferredData = value; return ( diff --git a/packages/router/utils.ts b/packages/router/utils.ts index 811eb90a6e..2383ea829d 100644 --- a/packages/router/utils.ts +++ b/packages/router/utils.ts @@ -68,8 +68,7 @@ export type DataResult = */ export interface HandlerResult { type: "data" | "error"; - result: unknown; // data, Error, Response, DeferredData - status?: number; + result: unknown; // data, Error, Response, DeferredData, DataWithResponseInit } type LowerCaseFormMethod = "get" | "post" | "put" | "patch" | "delete"; @@ -1375,6 +1374,28 @@ export const json: JsonFunction = (data, init = {}) => { }); }; +export class DataWithResponseInit { + type: string = "DataWithResponseInit"; + data: D; + init: ResponseInit | null; + + constructor(data: D, init?: ResponseInit) { + this.data = data; + this.init = init || null; + } +} + +/** + * Create "responses" that contain `status`/`headers` without forcing + * serialization into an actual `Response` - used by Remix single fetch + */ +export function data(data: D, init?: number | ResponseInit) { + return new DataWithResponseInit( + data, + typeof init === "number" ? { status: init } : init + ); +} + export interface TrackedPromise extends Promise { _tracked?: boolean; _data?: any;