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

✨ Backport withResolvers() from v4 to v3 #963

Merged
merged 1 commit into from
Jan 14, 2025
Merged
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
1 change: 1 addition & 0 deletions lib/mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,4 @@ export * from "./queue.ts";
export * from "./signal.ts";
export * from "./ensure.ts";
export * from "./race.ts";
export * from "./with-resolvers.ts";
84 changes: 84 additions & 0 deletions lib/with-resolvers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { Err, Ok } from "./result.ts";
import { action } from "./instructions.ts";
import type { Operation, Result } from "./types.ts";

/**
* The return type of {@link withResolvers}. It contains an operation bundled with
* synchronous functions that determine its outcome.
*/
export interface WithResolvers<T> {
/*
* An {@link Operation} that will either produce a value or raise an
* exception when either `resolve` or `reject` is called. No matter
* how many times this operation is yielded to, it will always
* produce the same effect.
*/
operation: Operation<T>;

/**
* Cause {@link operation} to produce `value`. If either `resolve`
* or`reject` has been called before, this will have no effect.
*
* @param value - the value to produce
*/
resolve(value: T): void;

/**
* Cause {@link operation} to raise `Error`. Any calling operation
* waiting on `operation` will. Yielding to `operation` subsequently
* will also raise the same error. * If either `resolve` or`reject`
* has been called before, this will have no effect.
*
* @param error - the error to raise
*/
reject(error: Error): void;
}

/**
* Create an {link @Operation} and two functions to resolve or reject
* it, corresponding to the two parameters passed to the executor of
* the {@link action} constructor. This is the Effection equivalent of
* [Promise.withResolvers()]{@link
* https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/withResolvers}
*
* @returns an operation and its resolvers.
*/

export function withResolvers<T>(): WithResolvers<T> {
let continuations = new Set<(result: Result<T>) => void>();
let result: Result<T> | undefined = undefined;

let operation: Operation<T> = action<T>(
(resolve, reject) => {
let settle = (outcome: Result<T>) => {
if (outcome.ok) {
resolve(outcome.value);
} else {
reject(outcome.error);
}
};

if (result) {
settle(result);
return () => {};
} else {
continuations.add(settle);
return () => continuations.delete(settle);
}
},
);

let settle = (outcome: Result<T>) => {
if (!result) {
result = outcome;
}
for (let continuation of continuations) {
continuation(result);
}
};

let resolve = (value: T) => settle(Ok(value));
let reject = (error: Error) => settle(Err(error));

return { operation, resolve, reject };
}
8 changes: 5 additions & 3 deletions test/abort-signal.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { describe, expect, it, mock } from "./suite.ts";
import { describe, expect, it } from "./suite.ts";
import { run, useAbortSignal } from "../mod.ts";

describe("useAbortSignal()", () => {
it("aborts whenever it passes out of scope", async () => {
let abort = mock.fn();
let state = { aborted: false };

let abort = () => state.aborted = true;

let signal = await run(function* () {
let signal = yield* useAbortSignal();
Expand All @@ -12,6 +14,6 @@ describe("useAbortSignal()", () => {
return signal;
});
expect(signal.aborted).toBe(true);
expect(abort).toHaveBeenCalled();
expect(state).toEqual({ aborted: true });
});
});
2 changes: 1 addition & 1 deletion test/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,5 +165,5 @@ function* detect(
}
yield* sleep(10);
}
expect(buffer.content).toMatch(text);
expect(buffer.content).toMatch(new RegExp(text));
}
2 changes: 1 addition & 1 deletion test/suite.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
export * from "https://deno.land/std@0.163.0/testing/bdd.ts";
export { expect, mock } from "https://deno.land/x/expect@v0.3.0/mod.ts";
export { expect } from "jsr:@std/expect";
export { expectType } from "https://esm.sh/ts-expect@1.3.0?pin=v123";

import {
Expand Down
32 changes: 32 additions & 0 deletions test/with-resolvers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { run, withResolvers } from "../mod.ts";
import { describe, expect, it } from "./suite.ts";

describe("withResolvers()", () => {
it("resolves", async () => {
let { operation, resolve } = withResolvers<string>();
resolve("hello");
await expect(run(() => operation)).resolves.toEqual("hello");
});
it("resolves only once", async () => {
let { operation, resolve, reject } = withResolvers<string>();
resolve("hello");
reject(new Error("boom!"));
resolve("goodbye");
await expect(run(() => operation)).resolves.toEqual("hello");
});
it("rejects", async () => {
let { operation, reject } = withResolvers<string>();
reject(new Error("boom!"));
await expect(run(() => operation)).rejects.toMatchObject({
message: "boom!",
});
});
it("rejects only once", async () => {
let { operation, reject } = withResolvers<string>();
reject(new Error("boom!"));
reject(new Error("bam!"));
await expect(run(() => operation)).rejects.toMatchObject({
message: "boom!",
});
});
});
85 changes: 76 additions & 9 deletions www/docs/async-rosetta-stone.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,16 @@ know how to do it in Effection.
The congruence between vanilla JavaScript constructs and their Effection
counterparts is reflected in the “Async Rosetta Stone.”

| Async/Await | Effection |
| ---------------- | ----------------- |
| `await` | `yield*` |
| `async function` | `function*` |
| `Promise` | `Operation` |
| `new Promise()` | `action()` |
| `for await` | `for yield* each` |
| `AsyncIterable` | `Stream` |
| `AsyncIterator` | `Subscription` |
| Async/Await | Effection |
| ------------------------- | ----------------- |
| `await` | `yield*` |
| `async function` | `function*` |
| `Promise` | `Operation` |
| `new Promise()` | `action()` |
| `Promise.withResolvers()` | `withResolvers()` |
| `for await` | `for yield* each` |
| `AsyncIterable` | `Stream` |
| `AsyncIterator` | `Subscription` |

## `await` \<=> `yield*`

Expand Down Expand Up @@ -154,6 +155,72 @@ A key difference is that the promise body will be executing immediately, but the
action body is only executed when the action is evaluated. Also, it is executed
anew every time the action is evaluated.

## `Promise.withResolvers()` \<=> `withResolvers()`

Both `Promise` and `Operation` can be constructed ahead of time without needing to begin the process that will resolve it. To do this with
a `Promise`, use the `Promise.withResolvers()` function:

```ts
async function main() {
let { promise, resolve } = Promise.withResolvers();

setTimeout(resolve, 1000);

await promise;

console.log("done!")
}
```

In effection:

```ts
import { withResolvers } from "effection";

function* main() {
let { operation, resolve } = withResolvers();

setTimeout(resolve, 1000);

yield* operation;

console.log("done!");
};
```

## `Promise.withResolvers()` \<=> `withResolvers()`

Both `Promise` and `Operation` can be constructed ahead of time without needing to begin the process that will resolve it. To do this with
a `Promise`, use the `Promise.withResolvers()` function:

```ts
async function main() {
let { promise, resolve } = Promise.withResolvers();

setTimeout(resolve, 1000);

await promise;

console.log("done!")
}
```

In effection:

```ts
import { withResolvers } from "effection";

function* main() {
let { operation, resolve } = withResolvers();

setTimeout(resolve, 1000);

yield* operation;

console.log("done!");
};
```

## `for await` \<=> `for yield* each`

Loop over an AsyncIterable with `for await`:
Expand Down
Loading