Skip to content

Commit 3e99fb2

Browse files
authored
fix: proxy defer resolve/reject values through tracked promises (#9200)
* fix: proxy defer resolve/reject values through tracked promises * Add changeset * remove blank line * Simplify approach * add AbortedDeferredError class * race against abort signal * bump, bump, bump it up
1 parent 15daab8 commit 3e99fb2

File tree

5 files changed

+72
-13
lines changed

5 files changed

+72
-13
lines changed

.changeset/young-pumas-destroy.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@remix-run/router": patch
3+
---
4+
5+
fix: proxy defer resolve/reject values through tracked promises (#9200)

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@
101101
},
102102
"filesize": {
103103
"packages/router/dist/router.js": {
104-
"none": "99 kB"
104+
"none": "100 kB"
105105
},
106106
"packages/react-router/dist/react-router.production.min.js": {
107107
"none": "12 kB"

packages/router/__tests__/router-test.ts

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ import {
2828
} from "../index";
2929

3030
// Private API
31-
import { TrackedPromise } from "../utils";
31+
import { AbortedDeferredError, TrackedPromise } from "../utils";
3232

3333
///////////////////////////////////////////////////////////////////////////////
3434
//#region Types and Utils
@@ -2948,7 +2948,7 @@ describe("a router", () => {
29482948
expect(t.router.state.errors).toBe(null);
29492949
expect(A.loaders.parent.stub.mock.calls.length).toBe(1); // called again for revalidation
29502950
expect(A.loaders.child.stub.mock.calls.length).toBe(1); // called because it's above error
2951-
expect(A.loaders.grandchild.stub.mock.calls.length).toBe(0); // dont call due to error
2951+
expect(A.loaders.grandchild.stub.mock.calls.length).toBe(0); // don't call due to error
29522952
await A.loaders.parent.resolve("PARENT DATA*");
29532953
await A.loaders.child.resolve("CHILD DATA");
29542954
expect(t.router.state.loaderData).toEqual({
@@ -7637,6 +7637,12 @@ describe("a router", () => {
76377637
lazy3: expect.trackedPromise("3"),
76387638
},
76397639
});
7640+
7641+
// Should proxy values through
7642+
let data = t.router.state.loaderData.lazy;
7643+
await expect(data.lazy1).resolves.toBe("Immediate data");
7644+
await expect(data.lazy2).resolves.toBe("2");
7645+
await expect(data.lazy3).resolves.toBe("3");
76407646
});
76417647

76427648
it("should cancel outstanding deferreds on a new navigation", async () => {
@@ -7670,7 +7676,17 @@ describe("a router", () => {
76707676
);
76717677

76727678
// Interrupt pending deferred's from /lazy navigation
7673-
let B = await t.navigate("/");
7679+
let navPromise = t.navigate("/");
7680+
7681+
// Cancelled promises should reject immediately
7682+
let data = t.router.state.loaderData.lazy;
7683+
await expect(data.lazy1).rejects.toBeInstanceOf(AbortedDeferredError);
7684+
await expect(data.lazy2).rejects.toBeInstanceOf(AbortedDeferredError);
7685+
await expect(data.lazy1).rejects.toThrowError("Deferred data aborted");
7686+
await expect(data.lazy2).rejects.toThrowError("Deferred data aborted");
7687+
7688+
let B = await navPromise;
7689+
76747690
// During navigation - deferreds remain as promises
76757691
expect(t.router.state.loaderData).toEqual({
76767692
lazy: {
@@ -7817,6 +7833,10 @@ describe("a router", () => {
78177833
lazy: expect.trackedPromise(undefined, new Error("Kaboom!")),
78187834
},
78197835
});
7836+
7837+
// should proxy the error through
7838+
let data = t.router.state.loaderData.lazy;
7839+
await expect(data.lazy).rejects.toEqual(new Error("Kaboom!"));
78207840
});
78217841

78227842
it("should cancel all outstanding deferreds on router.revalidate()", async () => {
@@ -8941,7 +8961,7 @@ describe("a router", () => {
89418961
lazy: dfd.promise,
89428962
})
89438963
);
8944-
await dfd.reject(new Error("Kaboom!")).catch(() => {});
8964+
await dfd.reject(new Error("Kaboom!"));
89458965
expect(t.router.state.errors).toMatchObject({
89468966
index: new Error("Kaboom!"),
89478967
});

packages/router/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ export type {
3535
} from "./utils";
3636

3737
export {
38+
AbortedDeferredError,
3839
ErrorResponse,
3940
defer,
4041
generatePath,

packages/router/utils.ts

Lines changed: 41 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -895,9 +895,13 @@ export interface TrackedPromise extends Promise<any> {
895895
_error?: any;
896896
}
897897

898+
export class AbortedDeferredError extends Error {}
899+
898900
export class DeferredData {
899901
private pendingKeys: Set<string | number> = new Set<string | number>();
900-
private cancelled: boolean = false;
902+
private controller: AbortController;
903+
private abortPromise: Promise<void>;
904+
private unlistenAbortSignal: () => void;
901905
private subscriber?: (aborted: boolean) => void = undefined;
902906
data: Record<string, unknown>;
903907

@@ -906,6 +910,18 @@ export class DeferredData {
906910
data && typeof data === "object" && !Array.isArray(data),
907911
"defer() only accepts plain objects"
908912
);
913+
914+
// Set up an AbortController + Promise we can race against to exit early
915+
// cancellation
916+
let reject: (e: AbortedDeferredError) => void;
917+
this.abortPromise = new Promise((_, r) => (reject = r));
918+
this.controller = new AbortController();
919+
let onAbort = () =>
920+
reject(new AbortedDeferredError("Deferred data aborted"));
921+
this.unlistenAbortSignal = () =>
922+
this.controller.signal.removeEventListener("abort", onAbort);
923+
this.controller.signal.addEventListener("abort", onAbort);
924+
909925
this.data = Object.entries(data).reduce(
910926
(acc, [key, value]) =>
911927
Object.assign(acc, {
@@ -927,10 +943,15 @@ export class DeferredData {
927943

928944
// We store a little wrapper promise that will be extended with
929945
// _data/_error props upon resolve/reject
930-
let promise: TrackedPromise = value.then(
946+
let promise: TrackedPromise = Promise.race([value, this.abortPromise]).then(
931947
(data) => this.onSettle(promise, key, null, data as unknown),
932948
(error) => this.onSettle(promise, key, error as unknown)
933949
);
950+
951+
// Register rejection listeners to avoid uncaught promise rejections on
952+
// errors or aborted deferred values
953+
promise.catch(() => {});
954+
934955
Object.defineProperty(promise, "_tracked", { get: () => true });
935956
return promise;
936957
}
@@ -940,27 +961,39 @@ export class DeferredData {
940961
key: string | number,
941962
error: unknown,
942963
data?: unknown
943-
): void {
944-
if (this.cancelled) {
945-
return;
964+
): unknown {
965+
if (
966+
this.controller.signal.aborted &&
967+
error instanceof AbortedDeferredError
968+
) {
969+
this.unlistenAbortSignal();
970+
return Promise.reject(error);
946971
}
972+
947973
this.pendingKeys.delete(key);
948974

975+
if (this.done) {
976+
// Nothing left to abort!
977+
this.unlistenAbortSignal();
978+
}
979+
949980
if (error) {
950981
Object.defineProperty(promise, "_error", { get: () => error });
951-
} else {
952-
Object.defineProperty(promise, "_data", { get: () => data });
982+
this.subscriber?.(false);
983+
return Promise.reject(error);
953984
}
954985

986+
Object.defineProperty(promise, "_data", { get: () => data });
955987
this.subscriber?.(false);
988+
return data;
956989
}
957990

958991
subscribe(fn: (aborted: boolean) => void) {
959992
this.subscriber = fn;
960993
}
961994

962995
cancel() {
963-
this.cancelled = true;
996+
this.controller.abort();
964997
this.pendingKeys.forEach((v, k) => this.pendingKeys.delete(k));
965998
this.subscriber?.(true);
966999
}

0 commit comments

Comments
 (0)