Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/unwrap-response.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@remix-run/router": minor
---

Add `unwrapResponse` option to allow unwrapping of more complex data formats than just json / text.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@
},
"filesize": {
"packages/router/dist/router.umd.min.js": {
"none": "49.3 kB"
"none": "49.4 kB"
},
"packages/react-router/dist/react-router.production.min.js": {
"none": "13.9 kB"
Expand Down
114 changes: 114 additions & 0 deletions packages/router/__tests__/router-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2714,4 +2714,118 @@ describe("a router", () => {
expect(B.loaders.tasks.signal.aborted).toBe(true);
});
});

describe("unwrapResponse", () => {
it("should unwrap json and text by default", async () => {
let t = setup({
routes: [
{
path: "/",
},
{
id: "json",
path: "/test",
loader: true,
children: [
{
id: "text",
index: true,
loader: true,
},
],
},
],
});

let A = await t.navigate("/test");
await A.loaders.json.resolve(
new Response(JSON.stringify({ message: "hello json" }), {
headers: {
"Content-Type": "application/json",
},
})
);
await A.loaders.text.resolve(new Response("hello text"));

expect(t.router.state.loaderData).toEqual({
json: { message: "hello json" },
text: "hello text",
});
});

it("should allow custom implementations to be provided", async () => {
let t = setup({
routes: [
{
path: "/",
},
{
id: "test",
path: "/test",
loader: true,
},
],
async unwrapResponse(response) {
if (
response.headers.get("Content-Type") ===
"application/x-www-form-urlencoded"
) {
let text = await response.text();
return new URLSearchParams(text);
}
throw new Error("Unknown Content-Type");
},
});

let A = await t.navigate("/test");
await A.loaders.test.resolve(
new Response(new URLSearchParams({ a: "1", b: "2" }).toString(), {
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
})
);

expect(t.router.state.loaderData.test).toBeInstanceOf(URLSearchParams);
expect(t.router.state.loaderData.test.toString()).toBe("a=1&b=2");
});

it("handles errors thrown from unwrapResponse at the proper boundary", async () => {
let t = setup({
routes: [
{
path: "/",
},
{
path: "/parent",
children: [
{
id: "child",
path: "child",
hasErrorBoundary: true,
children: [
{
id: "test",
index: true,
loader: true,
},
],
},
],
},
],
async unwrapResponse(response) {
throw new Error("Unable to unwrap response");
},
});

let A = await t.navigate("/parent/child");
await A.loaders.test.resolve(new Response("hello world"));

expect(t.router.state.loaderData.test).toBeUndefined();
expect(t.router.state.errors.child.message).toBe(
"Unable to unwrap response"
);
});
});
});
20 changes: 6 additions & 14 deletions packages/router/__tests__/utils/data-router-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,10 @@ import type {
AgnosticRouteMatch,
Fetcher,
RouterFetchOptions,
HydrationState,
InitialEntry,
Router,
RouterNavigateOptions,
FutureConfig,
RouterInit,
} from "../../index";
import {
createMemoryHistory,
Expand Down Expand Up @@ -138,11 +137,8 @@ export const TASK_ROUTES: TestRouteObject[] = [

type SetupOpts = {
routes: TestRouteObject[];
basename?: string;
initialEntries?: InitialEntry[];
initialIndex?: number;
hydrationData?: HydrationState;
future?: FutureConfig;
};

// We use a slightly modified version of createDeferred here that includes the
Expand Down Expand Up @@ -172,12 +168,10 @@ export function createDeferred() {

export function setup({
routes,
basename,
initialEntries,
initialIndex,
hydrationData,
future,
}: SetupOpts) {
...routerInit
}: Omit<RouterInit, "history" | "routes"> & SetupOpts) {
let guid = 0;
// Global "active" helpers, keyed by navType:guid:loaderOrAction:routeId.
// For example, the first navigation for /parent/foo would generate:
Expand Down Expand Up @@ -299,9 +293,9 @@ export function setup({
// jsdom is making more and more properties non-configurable, so we inject
// our own jest-friendly window.
let testWindow = {
...window,
...(routerInit.window || window),
location: {
...window.location,
...(routerInit.window || window).location,
assign: jest.fn(),
replace: jest.fn(),
},
Expand All @@ -312,11 +306,9 @@ export function setup({
jest.spyOn(history, "push");
jest.spyOn(history, "replace");
currentRouter = createRouter({
basename,
...routerInit,
history,
routes: enhanceRoutes(routes),
hydrationData,
future,
window: testWindow,
}).initialize();

Expand Down
52 changes: 39 additions & 13 deletions packages/router/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,8 @@ export interface FutureConfig {
v7_prependBasename: boolean;
}

export type UnwrapResponseFunction = (response: Response) => Promise<unknown>;

/**
* Initialization options for createRouter
*/
Expand All @@ -362,6 +364,7 @@ export interface RouterInit {
mapRouteProperties?: MapRoutePropertiesFunction;
future?: Partial<FutureConfig>;
hydrationData?: HydrationState;
unwrapResponse?: UnwrapResponseFunction;
window?: Window;
}

Expand Down Expand Up @@ -713,6 +716,17 @@ const defaultMapRouteProperties: MapRoutePropertiesFunction = (route) => ({
hasErrorBoundary: Boolean(route.hasErrorBoundary),
});

const defaultUnwrapResponse: UnwrapResponseFunction = (response) => {
let contentType = response.headers.get("Content-Type");
// Check between word boundaries instead of startsWith() due to the last
// paragraph of https://httpwg.org/specs/rfc9110.html#field.content-type
if (contentType && /\bapplication\/json\b/.test(contentType)) {
return response.json();
} else {
return response.text();
}
};

const TRANSITIONS_STORAGE_KEY = "remix-router-transitions";

//#endregion
Expand All @@ -735,6 +749,7 @@ export function createRouter(init: RouterInit): Router {
typeof routerWindow.document !== "undefined" &&
typeof routerWindow.document.createElement !== "undefined";
const isServer = !isBrowser;
const unwrapResponse = init.unwrapResponse || defaultUnwrapResponse;

invariant(
init.routes.length > 0,
Expand Down Expand Up @@ -1545,7 +1560,8 @@ export function createRouter(init: RouterInit): Router {
matches,
manifest,
mapRouteProperties,
basename
basename,
unwrapResponse
);

if (request.signal.aborted) {
Expand Down Expand Up @@ -1929,7 +1945,8 @@ export function createRouter(init: RouterInit): Router {
requestMatches,
manifest,
mapRouteProperties,
basename
basename,
unwrapResponse
);

if (fetchRequest.signal.aborted) {
Expand Down Expand Up @@ -2171,7 +2188,8 @@ export function createRouter(init: RouterInit): Router {
matches,
manifest,
mapRouteProperties,
basename
basename,
unwrapResponse
);

// Deferred isn't supported for fetcher loads, await everything and treat it
Expand Down Expand Up @@ -2367,7 +2385,8 @@ export function createRouter(init: RouterInit): Router {
matches,
manifest,
mapRouteProperties,
basename
basename,
unwrapResponse
)
),
...fetchersToLoad.map((f) => {
Expand All @@ -2379,7 +2398,8 @@ export function createRouter(init: RouterInit): Router {
f.matches,
manifest,
mapRouteProperties,
basename
basename,
unwrapResponse
);
} else {
let error: ErrorResult = {
Expand Down Expand Up @@ -2769,6 +2789,7 @@ export interface CreateStaticHandlerOptions {
*/
detectErrorBoundary?: DetectErrorBoundaryFunction;
mapRouteProperties?: MapRoutePropertiesFunction;
unwrapResponse?: UnwrapResponseFunction;
}

export function createStaticHandler(
Expand All @@ -2779,6 +2800,7 @@ export function createStaticHandler(
routes.length > 0,
"You must provide a non-empty routes array to createStaticHandler"
);
let unwrapResponse = opts?.unwrapResponse || defaultUnwrapResponse;

let manifest: RouteManifest = {};
let basename = (opts ? opts.basename : null) || "/";
Expand Down Expand Up @@ -3056,6 +3078,7 @@ export function createStaticHandler(
manifest,
mapRouteProperties,
basename,
unwrapResponse,
{ isStaticRequest: true, isRouteRequest, requestContext }
);

Expand Down Expand Up @@ -3224,6 +3247,7 @@ export function createStaticHandler(
manifest,
mapRouteProperties,
basename,
unwrapResponse,
{ isStaticRequest: true, isRouteRequest, requestContext }
)
),
Expand Down Expand Up @@ -3824,6 +3848,7 @@ async function callLoaderOrAction(
manifest: RouteManifest,
mapRouteProperties: MapRoutePropertiesFunction,
basename: string,
unwrapResponse: UnwrapResponseFunction,
opts: {
isStaticRequest?: boolean;
isRouteRequest?: boolean;
Expand Down Expand Up @@ -3983,14 +4008,15 @@ async function callLoaderOrAction(
throw queryRouteResponse;
}

let data: any;
let contentType = result.headers.get("Content-Type");
// Check between word boundaries instead of startsWith() due to the last
// paragraph of https://httpwg.org/specs/rfc9110.html#field.content-type
if (contentType && /\bapplication\/json\b/.test(contentType)) {
data = await result.json();
} else {
data = await result.text();
let data: unknown;
try {
data = await unwrapResponse(result);
} catch (e) {
resultType = ResultType.error;
return {
type: ResultType.error,
error: e,
};
}

if (resultType === ResultType.error) {
Expand Down