Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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.5 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