-
Notifications
You must be signed in to change notification settings - Fork 6
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
13 changed files
with
492 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(), | ||
}} | ||
/> | ||
); | ||
} | ||
``` |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export * from "./use-async-callback"; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
}); | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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}`} />; | ||
} | ||
``` |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export * from "./use-async-effect"; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
}); | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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>; | ||
} | ||
``` |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export * from "./use-async"; |
Oops, something went wrong.