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

✨introduce v4 action API #930

Open
wants to merge 1 commit into
base: v3
Choose a base branch
from
Open
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
59 changes: 29 additions & 30 deletions lib/instructions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,52 +54,42 @@ function Suspend(frame: Frame) {
* Create an {@link Operation} that can be either resolved (or rejected) with
* a synchronous callback. This is the Effection equivalent of `new Promise()`.
*
* The action body is itself an operation that runs in a new scope that is
* destroyed completely before program execution returns to the point where the
* action was yielded to.
* The action body is a function that enters the effect, and returns a function that
* will be called to exit the action..
*
* For example:
*
* ```js
* let five = yield* action(function*(resolve, reject) {
* setTimeout(() => {
* let five = yield* action((resolve, reject) => {
* let timeout = setTimeout(() => {
* if (Math.random() > 5) {
* resolve(5)
* } else {
* reject(new Error("bad luck!"));
* }
* }, 1000);
* });
*
* ```
*
* However, it is customary to explicitly {@link suspend} inside the body of the
* action so that whenever the action resolves, appropriate cleanup code can
* run. The preceeding example would be more correctly written as:
*
* ```js
* let five = yield* action(function*(resolve) {
* let timeoutId = setTimeout(() => {
* if (Math.random() > 5) {
* resolve(5)
* } else {
* reject(new Error("bad luck!"));
* }
* }, 1000);
* try {
* yield* suspend();
* } finally {
* clearTimout(timeoutId);
* }
* return () => clearTimeout(timeout);
* });
* ```
*
* @typeParam T - type of the action's result.
* @param operation - body of the action
* @param body - enter and exit the action
* @returns an operation producing the resolved value, or throwing the rejected error
*/
export function action<T>(
enter: (resolve: Resolve<T>, reject: Reject) => () => void,
): Operation<T>;

/**
* @deprecated `action()` used with an operation will be removed in v4.
*/
export function action<T>(
operation: (resolve: Resolve<T>, reject: Reject) => Operation<void>,
): Operation<T>;
export function action<T>(
operation:
| ((resolve: Resolve<T>, reject: Reject) => Operation<void>)
| ((resolve: Resolve<T>, reject: Reject) => () => void),
): Operation<T> {
return instruction(function Action(frame) {
return shift<Result<T>>(function* (k) {
Expand All @@ -119,8 +109,17 @@ export function action<T>(
let reject: Reject = (error) => settle(Err(error));

let child = frame.createChild(function* () {
yield* operation(resolve, reject);
yield* suspend();
let iterable = operation(resolve, reject);
if (typeof iterable === "function") {
try {
yield* suspend();
} finally {
iterable();
}
} else {
yield* iterable;
yield* suspend();
}
});

yield* reset(function* () {
Expand Down
80 changes: 80 additions & 0 deletions test/action.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,4 +119,84 @@ describe("action", () => {
});
expect(didReach).toEqual(false);
});

describe("v4 api", () => {
it("can resolve", async () => {
let didClear = false;
let task = run(() =>
action<number>((resolve) => {
let timeout = setTimeout(() => resolve(42), 5);
return () => {
didClear = true;
clearTimeout(timeout);
};
})
);

await expect(task).resolves.toEqual(42);
expect(didClear).toEqual(true);
});

it("can reject", async () => {
let didClear = false;
let error = new Error("boom");
let task = run(() =>
action<number>((_, reject) => {
let timeout = setTimeout(() => reject(error), 5);
return () => {
didClear = true;
clearTimeout(timeout);
};
})
);

await expect(task).rejects.toEqual(error);
expect(didClear).toEqual(true);
});

it("can resolve without ever suspending", async () => {
let result = await run(() =>
action<string>((resolve) => {
resolve("hello");
return () => {};
})
);

expect(result).toEqual("hello");
});

it("can reject without ever suspending", async () => {
let error = new Error("boom");
let task = run(() =>
action((_, reject) => {
reject(error);
return () => {};
})
);
await expect(task).rejects.toEqual(error);
});

it("fails if the operation fails", async () => {
let task = run(() =>
action(() => {
throw new Error("boom");
})
);
await expect(task).rejects.toHaveProperty("message", "boom");
});

it("fails if the shutdown fails", async () => {
let error = new Error("boom");
let task = run(() =>
action((resolve) => {
let timeout = setTimeout(resolve, 5);
return () => {
clearTimeout(timeout);
throw error;
};
})
);
await expect(task).rejects.toEqual(error);
});
});
});
Loading