Skip to content

Commit

Permalink
Create useAsync hooks
Browse files Browse the repository at this point in the history
  • Loading branch information
littensy committed Apr 23, 2023
1 parent 97ee874 commit f00f872
Show file tree
Hide file tree
Showing 13 changed files with 492 additions and 0 deletions.
3 changes: 3 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ export * from "./utils/testez";
export * from "./utils/motor";
export * from "./utils/binding";

export * from "./use-async";
export * from "./use-async-callback";
export * from "./use-async-effect";
export * from "./use-binding-listener";
export * from "./use-binding-state";
export * from "./use-camera";
Expand Down
59 changes: 59 additions & 0 deletions src/use-async-callback/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
## 🪝 `useAsyncCallback`

```ts
export function useAsyncCallback<T, U extends unknown[]>(
callback: AsyncCallback<T, U>,
): LuaTuple<[AsyncState<T>, AsyncCallback<T, U>]>;
```

A hook that wraps an async function and returns the current state and an executor.

Calling the executor will cancel any pending promises and start a new one.

If you want the callback to run on mount or with dependencies, see [`useAsync`](../use-async).

> **Warning:**
> Cancelling a promise that yielded using `await` does not prevent the thread from resuming.
> Avoid pairing `await` with functions that update state, as it might resume after unmount:
>
> ```ts
> useAsyncCallback(async () => {
> await Promise.delay(5);
> setState("Hello World!"); // unsafe
> });
> ```
### 📕 Parameters
- `callback` - The async function to call.
### 📗 Returns
- The current state of the async function.
- `status` - The status of the last promise.
- `value` - The value if the promise resolved.
- `message` - The error message if the promise rejected.
- A function that calls the async function.
### 📘 Example
```tsx
function GetBaseplate() {
const [state, getBaseplate] = useAsyncCallback(async () => {
return Workspace.WaitForChild("Baseplate");
});
useEffect(() => {
print("Baseplate", state.status, state.value);
}, [state]);
return (
<textbutton
Text={`Baseplate status: ${state.status}`}
Event={{
Activated: () => getBaseplate(),
}}
/>
);
}
```
1 change: 1 addition & 0 deletions src/use-async-callback/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./use-async-callback";
78 changes: 78 additions & 0 deletions src/use-async-callback/use-async-callback.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/// <reference types="@rbxts/testez/globals" />

import { renderHook } from "../utils/testez";
import { useAsyncCallback } from "./use-async-callback";

export = () => {
it("should return the current state and a callback", () => {
const { result } = renderHook(() => {
const [state, callback] = useAsyncCallback(() => Promise.resolve("foo"));
return { state, callback };
});

expect(result.current.state.status).to.be.equal(Promise.Status.Started);
expect(result.current.state.value).to.never.be.ok();
expect(result.current.state.message).to.never.be.ok();
expect(result.current.callback).to.be.a("function");
});

it("should update the state when the promise resolves", () => {
const { result } = renderHook(() => {
const [state, callback] = useAsyncCallback(() => Promise.resolve("foo"));
return { state, callback };
});

result.current.callback();
expect(result.current.state.status).to.be.equal(Promise.Status.Resolved);
expect(result.current.state.value).to.be.equal("foo");
expect(result.current.state.message).to.never.be.ok();
});

it("should update the state when the promise rejects", () => {
const { result } = renderHook(() => {
const [state, callback] = useAsyncCallback(() => Promise.reject("foo"));
return { state, callback };
});

result.current.callback();
expect(result.current.state.status).to.be.equal(Promise.Status.Rejected);
expect(result.current.state.value).to.never.be.ok();
expect(result.current.state.message).to.be.equal("foo");
});

it("should cancel the previous promise", () => {
let completions = 0;
const { result } = renderHook(() => {
const [state, callback] = useAsyncCallback(() => Promise.delay(0).then(() => ++completions));
return { state, callback };
});

result.current.callback();
result.current.callback();
result.current.callback();

expect(completions).to.be.equal(0);
expect(result.current.state.status).to.be.equal(Promise.Status.Started);
expect(result.current.state.value).to.never.be.ok();
expect(result.current.state.message).to.never.be.ok();

task.wait(0.03);
expect(completions).to.be.equal(1);
expect(result.current.state.status).to.be.equal(Promise.Status.Resolved);
expect(result.current.state.value).to.be.equal(1);
expect(result.current.state.message).to.never.be.ok();
});

it("should cancel when unmounting", () => {
let completions = 0;
const { result, unmount } = renderHook(() => {
const [state, callback] = useAsyncCallback(() => Promise.delay(0).then(() => ++completions));
return { state, callback };
});

result.current.callback();
unmount();
task.wait(0.03);
expect(completions).to.be.equal(0);
});
};
69 changes: 69 additions & 0 deletions src/use-async-callback/use-async-callback.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { useCallback, useMutable, useState } from "@rbxts/roact-hooked";
import { useUnmountEffect } from "../use-unmount-effect";

export type AsyncState<T> =
| {
status: PromiseConstructor["Status"]["Started"];
message?: undefined;
value?: undefined;
}
| {
status: PromiseConstructor["Status"]["Resolved"];
message?: undefined;
value: T;
}
| {
status: PromiseConstructor["Status"]["Cancelled"] | PromiseConstructor["Status"]["Rejected"];
message: unknown;
value?: undefined;
};

type AnyAsyncState<T> = {
status: Promise.Status;
message?: unknown;
value?: T;
};

export type AsyncCallback<T, U extends unknown[]> = (...args: U) => Promise<T>;

/**
* Returns a tuple containing the current state of the promise and a callback
* to start a new promise. Calling it will cancel any previous promise.
* @param callback The async callback.
* @returns The state and a new callback.
*/
export function useAsyncCallback<T, U extends unknown[]>(
callback: AsyncCallback<T, U>,
): LuaTuple<[AsyncState<T>, AsyncCallback<T, U>]> {
const currentPromise = useMutable<Promise<T>>();

const [state, setState] = useState<AnyAsyncState<T>>({
status: Promise.Status.Started,
});

const execute = useCallback(
(...args: U) => {
currentPromise.current?.cancel();

if (state.status !== Promise.Status.Started) {
setState({ status: Promise.Status.Started });
}

const promise = callback(...args);

promise.then(
(value) => setState({ status: promise.getStatus(), value }),
(message: unknown) => setState({ status: promise.getStatus(), message }),
);

return (currentPromise.current = promise);
},
[callback],
);

useUnmountEffect(() => {
currentPromise.current?.cancel();
});

return $tuple(state as AsyncState<T>, execute);
}
45 changes: 45 additions & 0 deletions src/use-async-effect/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
## 🪝 `useAsyncEffect`

```ts
function useAsyncEffect(effect: () => Promise<unknown>, deps?: unknown[]): void;
```

Runs an async effect and cancels the promise when unmounting the effect.

If you want to use the result or status of the callback, see [`useAsync`](../use-async).

> **Warning:**
> Cancelling a promise that yielded using `await` does not prevent the thread from resuming.
> Avoid pairing `await` with functions that update state, as it might resume after unmount:
>
> ```ts
> useAsyncEffect(async () => {
> await Promise.delay(5);
> setState("Hello World!"); // unsafe
> }, []);
> ```
### 📕 Parameters
- `effect` - The async effect to run.
- `deps` - The dependencies to watch for changes.
### 📗 Returns
- `void`
### 📘 Example
```tsx
function Countdown() {
const [counter, setCounter] = useState(0);
useAsyncEffect(async () => {
return setCountdown((countdown) => {
setCounter(countdown);
}, 10);
}, []);
return <textlabel Text={`Countdown: ${counter}`} />;
}
```
1 change: 1 addition & 0 deletions src/use-async-effect/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./use-async-effect";
55 changes: 55 additions & 0 deletions src/use-async-effect/use-async-effect.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/// <reference types="@rbxts/testez/globals" />

import { renderHook } from "../utils/testez";
import { useAsyncEffect } from "./use-async-effect";

export = () => {
it("should run the effect", () => {
let calls = 0;
renderHook(() => useAsyncEffect(async () => calls++, []));
expect(calls).to.equal(1);
});

it("should run the effect when the dependencies change", () => {
let calls = 0;
const { rerender } = renderHook((deps: unknown[]) => useAsyncEffect(async () => calls++, deps), {
initialProps: [0],
});

expect(calls).to.equal(1);
rerender([1]);
expect(calls).to.equal(2);
});

it("should cancel the effect when unmounting", () => {
let calls = 0;
const { unmount } = renderHook(() => {
useAsyncEffect(async () => {
calls++;
return Promise.delay(0).then(() => {
calls++;
});
}, []);
});

expect(calls).to.equal(1);
unmount();
expect(calls).to.equal(1);
});

it("should allow promises to complete", () => {
let calls = 0;
renderHook(() => {
useAsyncEffect(async () => {
calls++;
return Promise.delay(0).then(() => {
calls++;
});
}, []);
});

expect(calls).to.equal(1);
task.wait(0.03);
expect(calls).to.equal(2);
});
};
18 changes: 18 additions & 0 deletions src/use-async-effect/use-async-effect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { useEffect } from "@rbxts/roact-hooked";

/**
* Runs an async effect and cancels the promise when unmounting the effect.
* Note that effects paused by `await` still run while cancelled, so prefer
* to use promise chaining instead.
* @param effect The async effect to run.
* @param deps The dependencies to run the effect on.
*/
export function useAsyncEffect(effect: () => Promise<unknown>, deps?: unknown[]) {
useEffect(() => {
const promise = effect();

return () => {
promise.cancel();
};
}, deps);
}
50 changes: 50 additions & 0 deletions src/use-async/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
## 🪝 `useAsync`

```ts
export function useAsync<T>(
callback: () => Promise<T>,
deps: unknown[] = [],
): [result?: T, status?: Promise.Status, message?: unknown];
```

A hook that runs an async function and returns the result and status. Similar to `useAsyncCallback`, but the callback is run on mount and whenever the dependencies change.

Changing the dependencies will cancel any pending promises and start a new one.

> **Warning:**
> Cancelling a promise that yielded using `await` does not prevent the thread from resuming.
> Avoid pairing `await` with functions that update state, as it might resume after unmount:
>
> ```ts
> useAsync(async () => {
> await Promise.delay(5);
> setState("Hello World!"); // unsafe
> });
> ```
### 📕 Parameters
- `callback` - The async function to run.
- `deps` - The dependencies to watch for changes. Defaults to an empty array.
### 📗 Returns
- The result if the promise resolved.
- The status of the promise.
- The error message if the promise rejected or cancelled.
### 📘 Example
```tsx
function BaseplatePortal(props: Roact.PropsWithChildren) {
const [baseplate] = useAsync(async () => {
return Workspace.WaitForChild("Baseplate");
});
if (!baseplate) {
return undefined!;
}
return <Roact.Portal target={baseplate}>{props[Roact.Children]}</Roact.Portal>;
}
```
1 change: 1 addition & 0 deletions src/use-async/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./use-async";
Loading

0 comments on commit f00f872

Please sign in to comment.