diff --git a/src/index.ts b/src/index.ts index 78acdd7..fc5d618 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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"; diff --git a/src/use-async-callback/README.md b/src/use-async-callback/README.md new file mode 100644 index 0000000..361d224 --- /dev/null +++ b/src/use-async-callback/README.md @@ -0,0 +1,59 @@ +## 🪝 `useAsyncCallback` + +```ts +export function useAsyncCallback( + callback: AsyncCallback, +): LuaTuple<[AsyncState, AsyncCallback]>; +``` + +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 ( + getBaseplate(), + }} + /> + ); +} +``` diff --git a/src/use-async-callback/index.ts b/src/use-async-callback/index.ts new file mode 100644 index 0000000..9ff9005 --- /dev/null +++ b/src/use-async-callback/index.ts @@ -0,0 +1 @@ +export * from "./use-async-callback"; diff --git a/src/use-async-callback/use-async-callback.spec.ts b/src/use-async-callback/use-async-callback.spec.ts new file mode 100644 index 0000000..4dd91c9 --- /dev/null +++ b/src/use-async-callback/use-async-callback.spec.ts @@ -0,0 +1,78 @@ +/// + +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); + }); +}; diff --git a/src/use-async-callback/use-async-callback.ts b/src/use-async-callback/use-async-callback.ts new file mode 100644 index 0000000..94868f4 --- /dev/null +++ b/src/use-async-callback/use-async-callback.ts @@ -0,0 +1,69 @@ +import { useCallback, useMutable, useState } from "@rbxts/roact-hooked"; +import { useUnmountEffect } from "../use-unmount-effect"; + +export type AsyncState = + | { + 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 = { + status: Promise.Status; + message?: unknown; + value?: T; +}; + +export type AsyncCallback = (...args: U) => Promise; + +/** + * 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( + callback: AsyncCallback, +): LuaTuple<[AsyncState, AsyncCallback]> { + const currentPromise = useMutable>(); + + const [state, setState] = useState>({ + 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, execute); +} diff --git a/src/use-async-effect/README.md b/src/use-async-effect/README.md new file mode 100644 index 0000000..87e7e7c --- /dev/null +++ b/src/use-async-effect/README.md @@ -0,0 +1,45 @@ +## 🪝 `useAsyncEffect` + +```ts +function useAsyncEffect(effect: () => Promise, 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 ; +} +``` diff --git a/src/use-async-effect/index.ts b/src/use-async-effect/index.ts new file mode 100644 index 0000000..d472ec9 --- /dev/null +++ b/src/use-async-effect/index.ts @@ -0,0 +1 @@ +export * from "./use-async-effect"; diff --git a/src/use-async-effect/use-async-effect.spec.ts b/src/use-async-effect/use-async-effect.spec.ts new file mode 100644 index 0000000..3e937f6 --- /dev/null +++ b/src/use-async-effect/use-async-effect.spec.ts @@ -0,0 +1,55 @@ +/// + +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); + }); +}; diff --git a/src/use-async-effect/use-async-effect.ts b/src/use-async-effect/use-async-effect.ts new file mode 100644 index 0000000..571d5c0 --- /dev/null +++ b/src/use-async-effect/use-async-effect.ts @@ -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, deps?: unknown[]) { + useEffect(() => { + const promise = effect(); + + return () => { + promise.cancel(); + }; + }, deps); +} diff --git a/src/use-async/README.md b/src/use-async/README.md new file mode 100644 index 0000000..611f1fd --- /dev/null +++ b/src/use-async/README.md @@ -0,0 +1,50 @@ +## 🪝 `useAsync` + +```ts +export function useAsync( + callback: () => Promise, + 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 {props[Roact.Children]}; +} +``` diff --git a/src/use-async/index.ts b/src/use-async/index.ts new file mode 100644 index 0000000..818b0a0 --- /dev/null +++ b/src/use-async/index.ts @@ -0,0 +1 @@ +export * from "./use-async"; diff --git a/src/use-async/use-async.spec.ts b/src/use-async/use-async.spec.ts new file mode 100644 index 0000000..2dbd50f --- /dev/null +++ b/src/use-async/use-async.spec.ts @@ -0,0 +1,88 @@ +/// + +import { renderHook } from "../utils/testez"; +import { useAsync } from "./use-async"; + +export = () => { + it("should run the promise on mount", () => { + const { result } = renderHook(() => { + const [value, status, message] = useAsync(() => Promise.resolve("foo")); + return { value, status, message }; + }); + expect(result.current.status).to.be.equal(Promise.Status.Resolved); + expect(result.current.value).to.be.equal("foo"); + expect(result.current.message).to.never.be.ok(); + }); + + it("should run the promise when the dependencies change", () => { + const { result, rerender } = renderHook( + (deps: unknown[]) => { + const [value, status, message] = useAsync(() => Promise.resolve(deps[0]), [deps]); + return { value, status, message }; + }, + { initialProps: [0] }, + ); + + expect(result.current.status).to.be.equal(Promise.Status.Resolved); + expect(result.current.value).to.be.equal(0); + expect(result.current.message).to.never.be.ok(); + + rerender([1]); + expect(result.current.status).to.be.equal(Promise.Status.Resolved); + expect(result.current.value).to.be.equal(1); + expect(result.current.message).to.never.be.ok(); + }); + + it("should cancel the previous promise", () => { + let completions = 0; + const { result, rerender } = renderHook( + (deps: unknown[]) => { + const [value, status, message] = useAsync(() => Promise.delay(0).then(() => ++completions), [deps]); + return { value, status, message }; + }, + { initialProps: [0] }, + ); + + rerender([1]); + rerender([2]); + rerender([3]); + + expect(completions).to.be.equal(0); + expect(result.current.status).to.be.equal(Promise.Status.Started); + expect(result.current.value).to.never.be.ok(); + expect(result.current.message).to.never.be.ok(); + + task.wait(0.03); + expect(completions).to.be.equal(1); + expect(result.current.status).to.be.equal(Promise.Status.Resolved); + expect(result.current.value).to.be.equal(1); + expect(result.current.message).to.never.be.ok(); + }); + + it("should update the state when the promise resolves", () => { + const { result } = renderHook(() => { + const [value, status, message] = useAsync(() => Promise.delay(0).then(() => "foo")); + return { value, status, message }; + }); + + expect(result.current.status).to.be.equal(Promise.Status.Started); + expect(result.current.value).to.never.be.ok(); + expect(result.current.message).to.never.be.ok(); + + task.wait(0.03); + expect(result.current.status).to.be.equal(Promise.Status.Resolved); + expect(result.current.value).to.be.equal("foo"); + expect(result.current.message).to.never.be.ok(); + }); + + it("should cancel the promise on unmount", () => { + let completions = 0; + const { unmount } = renderHook(() => { + useAsync(() => Promise.delay(0).then(() => ++completions)); + }); + + unmount(); + task.wait(0.03); + expect(completions).to.be.equal(0); + }); +}; diff --git a/src/use-async/use-async.ts b/src/use-async/use-async.ts new file mode 100644 index 0000000..019c504 --- /dev/null +++ b/src/use-async/use-async.ts @@ -0,0 +1,24 @@ +import { useEffect } from "@rbxts/roact-hooked"; +import { AsyncState, useAsyncCallback } from "../use-async-callback"; + +type AsyncStateTuple> = LuaTuple< + [result: T["value"], status: T["status"], message: T["message"]] +>; + +/** + * Returns a tuple containing the result and status of a promise. When the + * dependencies change, pending promises will be cancelled, and a new promise + * will be started. + * @param callback The async callback. + * @param deps The dependencies to watch. Defaults to an empty array. + * @returns The result and status of the promise. + */ +export function useAsync(callback: () => Promise, deps: unknown[] = []): AsyncStateTuple> { + const [state, asyncCallback] = useAsyncCallback(callback); + + useEffect(() => { + asyncCallback(); + }, deps); + + return $tuple(state.value, state.status, state.message); +}