Skip to content

defer - initial attempt at exposing required data to ssr implementations #9760

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 11 commits into from
Jan 11, 2023
5 changes: 5 additions & 0 deletions .changeset/eight-tomatoes-breathe.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@remix-run/router": minor
---

Expose deferred information from createStaticHandler
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@
},
"filesize": {
"packages/router/dist/router.umd.min.js": {
"none": "38 kB"
"none": "38.5 kB"
},
"packages/react-router/dist/react-router.production.min.js": {
"none": "12.5 kB"
Expand Down
291 changes: 274 additions & 17 deletions packages/router/__tests__/router-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
createRouter,
createStaticHandler,
defer,
UNSAFE_DEFERRED_SYMBOL,
ErrorResponse,
IDLE_FETCHER,
IDLE_NAVIGATION,
Expand All @@ -30,6 +31,7 @@ import type {
AgnosticIndexRouteObject,
AgnosticNonIndexRouteObject,
AgnosticRouteObject,
DeferredData,
TrackedPromise,
} from "../utils";
import {
Expand Down Expand Up @@ -157,8 +159,13 @@ function isRedirect(result: any) {
);
}

interface CustomMatchers<R = unknown> {
interface CustomMatchers<R = jest.Expect> {
trackedPromise(data?: any, error?: any, aborted?: boolean): R;
deferredData(
done: boolean,
status?: number,
headers?: Record<string, string>
): R;
}

declare global {
Expand All @@ -169,12 +176,40 @@ declare global {
}
}

// Custom matcher for asserting deferred promise results inside of `toEqual()`
// - expect.trackedPromise() => pending promise
// - expect.trackedPromise(value) => promise resolved with `value`
// - expect.trackedPromise(null, error) => promise rejected with `error`
// - expect.trackedPromise(null, null, true) => promise aborted
expect.extend({
// Custom matcher for asserting deferred promise results for static handler
// - expect(val).deferredData(false) => Unresolved promise
// - expect(val).deferredData(false) => Resolved promise
// - expect(val).deferredData(false, 201, { 'x-custom': 'yes' })
// => Unresolved promise with status + headers
// - expect(val).deferredData(true, 201, { 'x-custom': 'yes' })
// => Resolved promise with status + headers
deferredData(received, done, status = 200, headers = {}) {
let deferredData = received as DeferredData;

return {
message: () =>
`expected done=${String(
done
)}/status=${status}/headers=${JSON.stringify(headers)}, ` +
`instead got done=${String(deferredData.done)}/status=${
deferredData.init!.status || 200
}/headers=${JSON.stringify(
Object.fromEntries(new Headers(deferredData.init!.headers).entries())
)}`,
pass:
deferredData.done === done &&
(deferredData.init!.status || 200) === status &&
JSON.stringify(
Object.fromEntries(new Headers(deferredData.init!.headers).entries())
) === JSON.stringify(headers),
};
},
// Custom matcher for asserting deferred promise results inside of `toEqual()`
// - expect.trackedPromise() => pending promise
// - expect.trackedPromise(value) => promise resolved with `value`
// - expect.trackedPromise(null, error) => promise rejected with `error`
// - expect.trackedPromise(null, null, true) => promise aborted
trackedPromise(received, data, error, aborted = false) {
let promise = received as TrackedPromise;
let isTrackedPromise =
Expand Down Expand Up @@ -10948,14 +10983,32 @@ describe("a router", () => {
{
id: "deferred",
path: "deferred",
loader: () =>
defer({
loader: ({ request }) => {
if (new URL(request.url).searchParams.has("reject")) {
return defer({
critical: "loader",
lazy: new Promise((_, r) =>
setTimeout(() => r(new Error("broken!")), 10)
),
});
}
if (new URL(request.url).searchParams.has("status")) {
return defer(
{
critical: "loader",
lazy: new Promise((r) => setTimeout(() => r("lazy"), 10)),
},
{ status: 201, headers: { "X-Custom": "yes" } }
);
}
return defer({
critical: "loader",
lazy: new Promise((r) => setTimeout(() => r("lazy"), 10)),
}),
});
},
action: () =>
defer({
critical: "action",
critical: "critical",
lazy: new Promise((r) => setTimeout(() => r("lazy"), 10)),
}),
},
Expand Down Expand Up @@ -11112,8 +11165,7 @@ describe("a router", () => {
});
});

// Note: this is only until we wire up the remix streaming
it("should abort deferred data on load navigations (for now)", async () => {
it("should support document load navigations returning deferred", async () => {
let { query } = createStaticHandler(SSR_ROUTES);
let context = await query(createRequest("/parent/deferred"));
expect(context).toMatchObject({
Expand All @@ -11122,19 +11174,29 @@ describe("a router", () => {
parent: "PARENT LOADER",
deferred: {
critical: "loader",
lazy: expect.trackedPromise(null, null, true),
lazy: expect.trackedPromise(),
},
},
activeDeferreds: {
deferred: expect.deferredData(false),
},
errors: null,
location: { pathname: "/parent/deferred" },
matches: [{ route: { id: "parent" } }, { route: { id: "deferred" } }],
});

await new Promise((r) => setTimeout(r, 10));
expect(
(context as StaticHandlerContext).loaderData.deferred.lazy instanceof
Promise
).toBe(true);

expect(context).toMatchObject({
loaderData: {
deferred: {
lazy: expect.trackedPromise("lazy"),
},
},
activeDeferreds: {
deferred: expect.deferredData(true),
},
});
});

it("should support document submit navigations", async () => {
Expand Down Expand Up @@ -11685,6 +11747,127 @@ describe("a router", () => {
expect(arg(childStub).context.sessionId).toBe("12345");
});

describe("deferred", () => {
let { query } = createStaticHandler(SSR_ROUTES);

it("should return DeferredData on symbol", async () => {
let context = (await query(
createRequest("/parent/deferred")
)) as StaticHandlerContext;
expect(context).toMatchObject({
loaderData: {
parent: "PARENT LOADER",
deferred: {
critical: "loader",
lazy: expect.trackedPromise(),
},
},
activeDeferreds: {
deferred: expect.deferredData(false),
},
});
await new Promise((r) => setTimeout(r, 10));
expect(context).toMatchObject({
loaderData: {
parent: "PARENT LOADER",
deferred: {
critical: "loader",
lazy: expect.trackedPromise("lazy"),
},
},
activeDeferreds: {
deferred: expect.deferredData(true),
},
});
});

it("should return rejected DeferredData on symbol", async () => {
let context = (await query(
createRequest("/parent/deferred?reject")
)) as StaticHandlerContext;
expect(context).toMatchObject({
loaderData: {
parent: "PARENT LOADER",
deferred: {
critical: "loader",
lazy: expect.trackedPromise(),
},
},
activeDeferreds: {
deferred: expect.deferredData(false),
},
});
await new Promise((r) => setTimeout(r, 10));
expect(context).toMatchObject({
loaderData: {
parent: "PARENT LOADER",
deferred: {
critical: "loader",
lazy: expect.trackedPromise(undefined, new Error("broken!")),
},
},
activeDeferreds: {
deferred: expect.deferredData(true),
},
});
});

it("should return DeferredData on symbol with status + headers", async () => {
let context = (await query(
createRequest("/parent/deferred?status")
)) as StaticHandlerContext;
expect(context).toMatchObject({
loaderData: {
parent: "PARENT LOADER",
deferred: {
critical: "loader",
lazy: expect.trackedPromise(),
},
},
activeDeferreds: {
deferred: expect.deferredData(false, 201, {
"x-custom": "yes",
}),
},
});
await new Promise((r) => setTimeout(r, 10));
expect(context).toMatchObject({
loaderData: {
parent: "PARENT LOADER",
deferred: {
critical: "loader",
lazy: expect.trackedPromise("lazy"),
},
},
activeDeferreds: {
deferred: expect.deferredData(true, 201, {
"x-custom": "yes",
}),
},
});
});

it("does not support deferred on submissions", async () => {
let context = (await query(
createSubmitRequest("/parent/deferred")
)) as StaticHandlerContext;
expect(context.actionData).toEqual(null);
expect(context.loaderData).toEqual({
parent: null,
deferred: null,
});
expect(context.activeDeferreds).toEqual(null);
expect(context.errors).toEqual({
parent: new ErrorResponse(
400,
"Bad Request",
new Error("defer() is not supported in actions"),
true
),
});
});
});

describe("statusCode", () => {
it("should expose a 200 status code by default", async () => {
let { query } = createStaticHandler([
Expand Down Expand Up @@ -12661,6 +12844,80 @@ describe("a router", () => {
expect(arg(actionStub).context.sessionId).toBe("12345");
});

describe("deferred", () => {
let { queryRoute } = createStaticHandler(SSR_ROUTES);

it("should return DeferredData on symbol", async () => {
let result = await queryRoute(createRequest("/parent/deferred"));
expect(result).toMatchObject({
critical: "loader",
lazy: expect.trackedPromise(),
});
expect(result[UNSAFE_DEFERRED_SYMBOL]).deferredData(false);
await new Promise((r) => setTimeout(r, 10));
expect(result).toMatchObject({
critical: "loader",
lazy: expect.trackedPromise("lazy"),
});
expect(result[UNSAFE_DEFERRED_SYMBOL]).deferredData(true);
});

it("should return rejected DeferredData on symbol", async () => {
let result = await queryRoute(
createRequest("/parent/deferred?reject")
);
expect(result).toMatchObject({
critical: "loader",
lazy: expect.trackedPromise(),
});
expect(result[UNSAFE_DEFERRED_SYMBOL]).deferredData(false);
await new Promise((r) => setTimeout(r, 10));
expect(result).toMatchObject({
critical: "loader",
lazy: expect.trackedPromise(null, new Error("broken!")),
});
expect(result[UNSAFE_DEFERRED_SYMBOL]).deferredData(true);
});

it("should return DeferredData on symbol with status + headers", async () => {
let result = await queryRoute(
createRequest("/parent/deferred?status")
);
expect(result).toMatchObject({
critical: "loader",
lazy: expect.trackedPromise(),
});
expect(result[UNSAFE_DEFERRED_SYMBOL]).deferredData(false, 201, {
"x-custom": "yes",
});
await new Promise((r) => setTimeout(r, 10));
expect(result).toMatchObject({
critical: "loader",
lazy: expect.trackedPromise("lazy"),
});
expect(result[UNSAFE_DEFERRED_SYMBOL]).deferredData(true, 201, {
"x-custom": "yes",
});
});

it("does not support deferred on submissions", async () => {
try {
await queryRoute(createSubmitRequest("/parent/deferred"));
expect(false).toBe(true);
} catch (e) {
// eslint-disable-next-line jest/no-conditional-expect
expect(e).toEqual(
new ErrorResponse(
400,
"Bad Request",
new Error("defer() is not supported in actions"),
true
)
);
}
});
});

describe("Errors with Status Codes", () => {
/* eslint-disable jest/no-conditional-expect */
let { queryRoute } = createStaticHandler([
Expand Down
Loading