Skip to content

Commit

Permalink
Merge pull request #930 from thefrontside/align-v4-actions
Browse files Browse the repository at this point in the history
✨introduce v4 action API
  • Loading branch information
taras authored Jan 10, 2025
2 parents 9ff0cf0 + d53e949 commit 3127200
Show file tree
Hide file tree
Showing 3 changed files with 112 additions and 37 deletions.
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
10 changes: 3 additions & 7 deletions lib/sleep.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { Operation } from "./types.ts";
import { action, suspend } from "./instructions.ts";
import { action } from "./instructions.ts";

/**
* Sleep for the given amount of milliseconds.
Expand All @@ -17,12 +17,8 @@ import { action, suspend } from "./instructions.ts";
* @param duration - the number of milliseconds to sleep
*/
export function sleep(duration: number): Operation<void> {
return action(function* sleep(resolve) {
return action((resolve) => {
let timeoutId = setTimeout(resolve, duration);
try {
yield* suspend();
} finally {
clearTimeout(timeoutId);
}
return () => clearTimeout(timeoutId);
});
}
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);
});
});
});

0 comments on commit 3127200

Please sign in to comment.