-
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
6 changed files
with
247 additions
and
1 deletion.
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
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,42 @@ | ||
## 🪝 `useEventListener` | ||
|
||
```ts | ||
function useEventListener<T extends EventLike>( | ||
event: T, | ||
listener?: T extends EventLike<infer U> ? U : never, | ||
options: EventListenerOptions = {}, | ||
): void; | ||
``` | ||
|
||
Connects an event listener to the given event. The event can be any object with a `Connect` method that returns a Connection object or a cleanup function. | ||
|
||
If the listener is `undefined`, the previous listener will be disconnected. | ||
|
||
The `listener` parameter is memoized automatically, and will not cause a reconnect if it changes. | ||
|
||
### ⚙️ Options | ||
|
||
- `connected` - Whether the listener should be connected. Defaults to `true`. | ||
- `once` - Whether the listener should be disconnected after the first time it is called. Defaults to `false`. | ||
|
||
### 📕 Parameters | ||
|
||
- `event` - The event to listen to. | ||
- `listener` - The listener to call when the event fires. | ||
- `options` - Optional config for the listener. | ||
|
||
### 📗 Returns | ||
|
||
- `void` | ||
|
||
### 📘 Example | ||
|
||
```tsx | ||
export default function Component() { | ||
useEventListener(Players.PlayerAdded, (player) => { | ||
print(`${player.DisplayName} joined!`); | ||
}); | ||
|
||
return <frame />; | ||
} | ||
``` |
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-event-listener"; |
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,112 @@ | ||
/// <reference types="@rbxts/testez/globals" /> | ||
|
||
import { renderHook } from "../utils/testez"; | ||
import { useEventListener } from "./use-event-listener"; | ||
|
||
function createSignal<T extends unknown[] = []>() { | ||
const listeners = new Set<(...args: T) => void>(); | ||
|
||
return { | ||
listeners() { | ||
return listeners; | ||
}, | ||
|
||
connect(listener: (...args: T) => void) { | ||
listeners.add(listener); | ||
return () => listeners.delete(listener); | ||
}, | ||
|
||
fire(...args: T) { | ||
for (const listener of listeners) { | ||
listener(...args); | ||
} | ||
}, | ||
}; | ||
} | ||
|
||
export = () => { | ||
it("should connect on mount", () => { | ||
const signal = createSignal(); | ||
const { unmount } = renderHook(() => useEventListener(signal, () => {})); | ||
|
||
expect(signal.listeners().size()).to.equal(1); | ||
unmount(); | ||
}); | ||
|
||
it("should disconnect on unmount", () => { | ||
const signal = createSignal(); | ||
const { unmount } = renderHook(() => useEventListener(signal, () => {})); | ||
|
||
unmount(); | ||
expect(signal.listeners().size()).to.equal(0); | ||
}); | ||
|
||
it("should clean up old connections", () => { | ||
const signalA = createSignal(); | ||
const signalB = createSignal(); | ||
const { rerender, unmount } = renderHook( | ||
({ signal }: { signal: ReturnType<typeof createSignal> }) => useEventListener(signal, () => {}), | ||
{ initialProps: { signal: signalA } }, | ||
); | ||
|
||
rerender({ signal: signalB }); | ||
expect(signalA.listeners().size()).to.equal(0); | ||
expect(signalB.listeners().size()).to.equal(1); | ||
|
||
rerender({ signal: signalA }); | ||
expect(signalA.listeners().size()).to.equal(1); | ||
expect(signalB.listeners().size()).to.equal(0); | ||
|
||
unmount(); | ||
expect(signalA.listeners().size()).to.equal(0); | ||
expect(signalB.listeners().size()).to.equal(0); | ||
}); | ||
|
||
it("should call listener on event", () => { | ||
const signal = createSignal<[number]>(); | ||
let result: number | undefined; | ||
|
||
const { unmount } = renderHook(() => useEventListener(signal, (value) => (result = value))); | ||
|
||
signal.fire(0); | ||
expect(result).to.equal(0); | ||
|
||
signal.fire(1); | ||
expect(result).to.equal(1); | ||
|
||
unmount(); | ||
}); | ||
|
||
it("should receive a 'once' option", () => { | ||
const signal = createSignal(); | ||
let calls = 0; | ||
|
||
const { rerender, unmount } = renderHook(() => useEventListener(signal, () => calls++, { once: true })); | ||
|
||
signal.fire(); | ||
rerender(); | ||
signal.fire(); | ||
rerender(); | ||
signal.fire(); | ||
expect(calls).to.equal(1); | ||
unmount(); | ||
}); | ||
|
||
it("should receive a 'connected' option", () => { | ||
const signal = createSignal(); | ||
let calls = 0; | ||
|
||
const { rerender, unmount } = renderHook( | ||
({ connected }) => useEventListener(signal, () => calls++, { connected }), | ||
{ initialProps: { connected: true } }, | ||
); | ||
|
||
signal.fire(); | ||
rerender({ connected: false }); | ||
signal.fire(); | ||
rerender({ connected: true }); | ||
signal.fire(); | ||
expect(calls).to.equal(2); | ||
unmount(); | ||
}); | ||
}; |
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,89 @@ | ||
import { useEffect } from "@rbxts/roact-hooked"; | ||
import { useLatest } from "../use-latest"; | ||
|
||
interface EventListenerOptions { | ||
/** | ||
* Whether the event should be connected or not. Defaults to `true`. | ||
*/ | ||
connected?: boolean; | ||
/** | ||
* Whether the event should be disconnected after the first invocation. | ||
* Defaults to `false`. | ||
*/ | ||
once?: boolean; | ||
} | ||
|
||
type EventLike<T extends Callback = Callback> = | ||
| { Connect(callback: T): ConnectionLike } | ||
| { connect(callback: T): ConnectionLike } | ||
| { subscribe(callback: T): ConnectionLike }; | ||
|
||
type ConnectionLike = { Disconnect(): void } | { disconnect(): void } | (() => void); | ||
|
||
const connect = (event: EventLike, callback: Callback): ConnectionLike => { | ||
if (typeIs(event, "RBXScriptSignal") || "Connect" in event) { | ||
return event.Connect(callback); | ||
} else if ("connect" in event) { | ||
return event.connect(callback); | ||
} else if ("subscribe" in event) { | ||
return event.subscribe(callback); | ||
} else { | ||
throw "Event-like object does not have a supported connect method."; | ||
} | ||
}; | ||
|
||
const disconnect = (connection: ConnectionLike) => { | ||
if (typeIs(connection, "function")) { | ||
connection(); | ||
} else if (typeIs(connection, "RBXScriptConnection") || "Disconnect" in connection) { | ||
connection.Disconnect(); | ||
} else if ("disconnect" in connection) { | ||
connection.disconnect(); | ||
} else { | ||
throw "Connection-like object does not have a supported disconnect method."; | ||
} | ||
}; | ||
|
||
/** | ||
* Subscribes to an event-like object. The subscription is automatically | ||
* disconnected when the component unmounts. | ||
* | ||
* If the listener is `undefined`, the event will not be subscribed to, and the | ||
* subscription will be disconnected if it was previously connected. | ||
* | ||
* The listener is memoized, so it is safe to pass a callback that is recreated | ||
* on every render. | ||
* | ||
* @param event The event-like object to subscribe to. | ||
* @param listener The listener to subscribe with. | ||
* @param options Options for the subscription. | ||
*/ | ||
export function useEventListener<T extends EventLike>( | ||
event: T, | ||
listener?: T extends EventLike<infer U> ? U : never, | ||
options: EventListenerOptions = {}, | ||
) { | ||
const listenerRef = useLatest(listener); | ||
|
||
useEffect(() => { | ||
if (!listener || !options.connected) { | ||
return; | ||
} | ||
|
||
let connected = true; | ||
|
||
const connection = connect(event, (...args: unknown[]) => { | ||
if (options.once) { | ||
disconnect(connection); | ||
connected = false; | ||
} | ||
listenerRef.current?.(...args); | ||
}); | ||
|
||
return () => { | ||
if (connected) { | ||
disconnect(connection); | ||
} | ||
}; | ||
}, [event, options.connected ?? true, listener !== undefined]); | ||
} |