Skip to content
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

Add a new optional AbortController parameter to defer #10792

Draft
wants to merge 3 commits into
base: dev
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
124 changes: 124 additions & 0 deletions packages/router/__tests__/router-test.ts
Expand Up @@ -12597,6 +12597,130 @@ describe("a router", () => {
expect(shouldRevalidateSpy).not.toHaveBeenCalled();
});

it("should allow user to provide an AbortController for defer cancellations after navigation completes", async () => {
let t = setup({
routes: [
{
id: "index",
index: true,
loader: true,
},
{
id: "lazy",
path: "lazy",
loader: true,
},
],
hydrationData: { loaderData: { index: "INDEX" } },
});

let A = await t.navigate("/lazy");
let dfd = createDeferred();
let deferController = new AbortController();
await A.loaders.lazy.resolve(
defer({ lazy: dfd.promise }, null, deferController)
);

// Interrupt pending deferred's from /lazy navigation
let B = await t.navigate("/");
expect(A.loaders.lazy.signal.aborted).toBe(false);
expect(deferController.signal.aborted).toBe(true);

await B.loaders.index.resolve("INDEX*");
expect(t.router.state).toMatchObject({
location: { pathname: "/" },
navigation: IDLE_NAVIGATION,
});
});

it("should proxy navigation AbortController through to defer AbortController when request aborts prior to defer call", async () => {
let t = setup({
routes: [
{
id: "index",
index: true,
loader: true,
},
{
id: "lazy",
path: "lazy",
loader: true,
},
],
hydrationData: { loaderData: { index: "INDEX" } },
});

// Route to lazy
let A = await t.navigate("/lazy");

// Navigate away from /lazy before it's loader returns the defer()
let B = await t.navigate("/");
expect(A.loaders.lazy.signal.aborted).toBe(true);

let dfd = createDeferred();
let deferController = new AbortController();
await A.loaders.lazy.resolve(
defer({ lazy: dfd.promise }, null, deferController)
);
expect(deferController.signal.aborted).toBe(true);

await B.loaders.index.resolve("INDEX*");
expect(t.router.state).toMatchObject({
location: { pathname: "/" },
navigation: IDLE_NAVIGATION,
});
});

it("should proxy navigation AbortController through to defer AbortController when request aborts after defer call", async () => {
let t = setup({
routes: [
{
id: "index",
index: true,
loader: true,
},
{
id: "lazy",
path: "lazy",
loader: true,
children: [
{
id: "child",
path: "child",
loader: true,
},
],
},
],
hydrationData: { loaderData: { index: "INDEX" } },
});

// Route to lazy
let A = await t.navigate("/lazy/child");

let dfd = createDeferred();
let deferController = new AbortController();
await A.loaders.lazy.resolve(
defer({ lazy: dfd.promise }, null, deferController)
);
expect(deferController.signal.aborted).toBe(false);

// Navigate away from /lazy after it's loader returns the defer() but
// before /child's loader finishes so the request is aborted which gets
// proxied through to the lazy loaders already resolved defer() instance
let B = await t.navigate("/");
expect(A.loaders.lazy.signal.aborted).toBe(true);
expect(deferController.signal.aborted).toBe(true);

await A.loaders.child.resolve("CHILD");

await B.loaders.index.resolve("INDEX*");
expect(t.router.state).toMatchObject({
location: { pathname: "/" },
navigation: IDLE_NAVIGATION,
});
});

it("does not put resolved deferred's back into a loading state during revalidation", async () => {
let shouldRevalidateSpy = jest.fn(() => false);
let t = setup({
Expand Down
32 changes: 31 additions & 1 deletion packages/router/router.ts
Expand Up @@ -9,6 +9,7 @@ import {
} from "./history";
import type {
ActionFunction,
ActionFunctionArgs,
AgnosticDataRouteMatch,
AgnosticDataRouteObject,
AgnosticRouteMatch,
Expand All @@ -23,6 +24,7 @@ import type {
HTMLFormMethod,
ImmutableRouteKey,
LoaderFunction,
LoaderFunctionArgs,
MapRoutePropertiesFunction,
MutationFormMethod,
RedirectResult,
Expand Down Expand Up @@ -3627,8 +3629,25 @@ async function callLoaderOrAction(
let abortPromise = new Promise((_, r) => (reject = r));
onReject = () => reject();
request.signal.addEventListener("abort", onReject);

let runHandlerAndMaybeAbortDeferreds = async (
handler: ActionFunction | LoaderFunction,
args: LoaderFunctionArgs | ActionFunctionArgs
) => {
// Since we Promise.race the handler against the request signal, if the
// request is aborted we never get access to the returned value from the
// handler. If we get a defer() instance _after_ we've already aborted
// the request, we should proxy that cancellation along so the defer()
// controller also aborts.
let result = await handler(args);
if (args.request.signal.aborted && isDeferredData(result)) {
result.cancel();
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cancel defer() instances created after the request has already been aborted

}
return result;
};

return Promise.race([
handler({
runHandlerAndMaybeAbortDeferreds(handler, {
request,
params: match.params,
context: opts.requestContext,
Expand Down Expand Up @@ -3792,6 +3811,17 @@ async function callLoaderOrAction(
}

if (isDeferredData(result)) {
if (request.signal.aborted) {
// Don't think this is technically possible since we race the loader
// against the request signal and we would short circuit via the error path
// above on interruption. Included to be safe though :)
result.cancel();
} else {
// Abort the defer() instance if we abort while waiting on other loaders
// from this navigation
let deferResult = result;
request.signal.addEventListener("abort", () => deferResult.cancel());
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cancel defer() instanced created before the request is aborted

}
return {
type: ResultType.deferred,
deferredData: result,
Expand Down
23 changes: 16 additions & 7 deletions packages/router/utils.ts
Expand Up @@ -1276,7 +1276,11 @@ export class DeferredData {
init?: ResponseInit;
deferredKeys: string[] = [];

constructor(data: Record<string, unknown>, responseInit?: ResponseInit) {
constructor(
data: Record<string, unknown>,
responseInit: ResponseInit,
controller: AbortController
) {
invariant(
data && typeof data === "object" && !Array.isArray(data),
"defer() only accepts plain objects"
Expand All @@ -1286,7 +1290,7 @@ export class DeferredData {
// cancellation
let reject: (e: AbortedDeferredError) => void;
this.abortPromise = new Promise((_, r) => (reject = r));
this.controller = new AbortController();
this.controller = controller;
let onAbort = () =>
reject(new AbortedDeferredError("Deferred data aborted"));
this.unlistenAbortSignal = () =>
Expand Down Expand Up @@ -1455,13 +1459,18 @@ function unwrapTrackedPromise(value: any) {

export type DeferFunction = (
data: Record<string, unknown>,
init?: number | ResponseInit
init?: number | ResponseInit | null,
controller?: AbortController
) => DeferredData;

export const defer: DeferFunction = (data, init = {}) => {
let responseInit = typeof init === "number" ? { status: init } : init;

return new DeferredData(data, responseInit);
export const defer: DeferFunction = (data, init, controller) => {
let responseInit =
typeof init === "number" ? { status: init } : init != null ? init : {};
return new DeferredData(
data,
responseInit,
controller || new AbortController()
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use user controller if provided

);
};

export type RedirectFunction = (
Expand Down