Skip to content

Commit

Permalink
feat(cancellation): implement cancellation in the FunPromise itself i…
Browse files Browse the repository at this point in the history
…nstead of in Deferral

Turns out that deferrals aren't sufficient for some use cases: we reeally want that logic up in
FunPromises.

BREAKING CHANGE: The Deferral class no longer has cancellation.
  • Loading branch information
RobertFischer committed Dec 9, 2020
1 parent c73f39a commit 4da84e7
Show file tree
Hide file tree
Showing 4 changed files with 109 additions and 68 deletions.
39 changes: 0 additions & 39 deletions src/deferral.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,43 +53,4 @@ describe("Deferral", () => {
);
});
});

describe("cancellation", () => {
it("is initially not cancelled", () => {
expect(new Deferral()).toHaveProperty("isCancelled", false);
});

it("is cancelled after calling 'cancel'", () => {
const deferral = new Deferral();
deferral.cancel();
expect(deferral).toHaveProperty("isCancelled", true);
});

it("is safe to call 'cancel' multiple times", () => {
const deferral = new Deferral();
deferral.cancel();
deferral.cancel();
expect(deferral).toHaveProperty("isCancelled", true);
});

it("prevents resolve from doing anything", () => {
let sawThen = false;
const deferral = new Deferral();
deferral.promise.then(() => {
sawThen = true;
});
deferral.cancel();
deferral.resolve(true);
expect(sawThen).toBe(false);
});

it("rejects with a known message", async () => {
const deferral = new Deferral();
deferral.cancel();
await expect(deferral.promise).rejects.toHaveProperty(
"message",
"Deferral was cancelled"
);
});
});
});
30 changes: 2 additions & 28 deletions src/deferral.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,21 +33,15 @@ export default class Deferral<T> {
* Resolves `promise` with the given value.
*/
resolve(it) {
const { resolver } = this;
this.resolver = null;
this.rejector = null;
if (resolver) resolver(it);
this.resolver(it);
return this.promise;
}

/**
* Rejects `promise` with the given cause.
*/
reject(e: Error) {
const { rejector } = this;
this.resolver = null;
this.rejector = null;
if (rejector) rejector(e);
this.rejector(e);
return this.promise;
}

Expand All @@ -63,24 +57,4 @@ export default class Deferral<T> {
})
);
}

/**
* Whether or not the deferral is cancelled.
*/
get isCancelled() {
return this.resolver === null;
}

/**
* Cancels the deferral. If the deferral is not settled, its callbacks will
* never be called. If the deferral is settled or cancelled, this is a noop.
*/
cancel() {
if (!this.isCancelled) {
this.promise.catch(_noop); // Suppress "UnhandledException" errors.
this.reject(new Error(`Deferral was cancelled`));
this.resolver = null;
this.rejector = null;
}
}
}
63 changes: 63 additions & 0 deletions src/fun-promise.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -591,4 +591,67 @@ describe("FunPromise", () => {
).resolves.toEqual(values);
});
});

describe("cancellation", () => {
it("initially reports not cancelled", () => {
expect(FunPromise.resolve(true).isCancelled()).toBe(false);
});

it("reports cancelled after cancel is called", () => {
expect(FunPromise.resolve(true).cancel().isCancelled()).toBe(true);
});

it("prevents resolution after cancellation", () => {
let resolver;
let sawResolve = false;
const promise = new Promise((resolve) => {
resolver = resolve;
}).then(() => {
sawResolve = true;
});
expect(resolver).not.toBeNil();
const cancelled = new FunPromise(promise).cancel();
resolver(true);
expect(sawResolve).toBe(false);
});

it("prevents rejection after cancellation", () => {
let rejector;
let sawReject = false;
const promise = new Promise((resolve, reject) => {
rejector = reject;
}).catch(() => {
sawReject = true;
});
expect(rejector).not.toBeNil();
const cancelled = new FunPromise(promise).cancel();
rejector("BOOM!");
expect(sawReject).toBe(false);
});

it("prevents rejection when resolving throws after cancellation", () => {
let resolver;
let sawThen = true;
let sawCatch = false;
let doCancel;
const promise = new Promise((resolve) => {
resolver = resolve;
}).then(() => {
sawThen = true;
doCancel();
throw "BOOM!";
});
expect(resolver).not.toBeNil();
const toCancel = new FunPromise(promise);
doCancel = () => {
toCancel.cancel();
};
toCancel.catch((e) => {
sawCatch = true;
});
resolver(true);
expect(sawThen).toBe(true);
expect(sawCatch).toBe(false);
});
});
});
45 changes: 44 additions & 1 deletion src/fun-promise.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,42 @@ import _toArray from "lodash/toArray";
// import Debug from "debug";
// const debug = Debug("fun-promises");

const rejection = (() => {
const it = Promise.reject("FunPromise has been cancelled");
it.catch(_noop); // Disable UnhandledException errors
return it;
})();

/**
* The class that you should use instead of `Promise`. It implements the `Promise` API, so it should be a drop-in replacement.
*/
export default class FunPromise<T> implements Promise<T> {
/**
* Whether or not this FunPromise has been cancelled.
*/
private _isCancelled: boolean = false;

/**
* The promise that was wrapped after attaching our custom logic.
*/
protected readonly wrapped: Promise<T>;

/**
* Constructor, which takes the promise to wrap.
*/
constructor(protected readonly wrapped: Promise<T>) {}
constructor(wrapped: Promise<T>) {
this.wrapped = new Promise(async (resolve, reject) => {
let resolved = null;
try {
resolved = await wrapped;
} catch (e) {
if (this._isCancelled) return;
reject(e);
}
if (this._isCancelled) return;
resolve(resolved);
});
}

/**
* Takes a value (or a promise of a value) and returns a promise wrapping
Expand Down Expand Up @@ -678,4 +706,19 @@ export default class FunPromise<T> implements Promise<T> {
}
});
}

/**
* Cancel the FunPromise. A cancelled FunPromise will silently disregard any resolution or rejection which occurs after the cancellation.
*/
cancel() {
this._isCancelled = true;
return this;
}

/**
* Returns whether or not the promise has been cancelled. See `cancel()` for more details.
*/
isCancelled() {
return this._isCancelled;
}
}

0 comments on commit 4da84e7

Please sign in to comment.