Skip to content

Commit

Permalink
Add useEventListener
Browse files Browse the repository at this point in the history
  • Loading branch information
littensy committed Apr 22, 2023
1 parent 82f61ea commit 26518b8
Show file tree
Hide file tree
Showing 6 changed files with 247 additions and 1 deletion.
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,7 @@ export * from "./utils/binding";
export * from "./use-binding-listener";
export * from "./use-binding-state";
export * from "./use-camera";
export * from "./use-event-listener";
export * from "./use-latest";
export * from "./use-memoized-callback";
export * from "./use-previous";
2 changes: 1 addition & 1 deletion src/use-binding-listener/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ Subscribes the given listener to binding updates. The listener will be called wi

If not passed a valid binding, the listener will be called with the value passed to the hook.

The `listener` parameter is safe to not memoize, and will only be called when the binding updates.
The `listener` parameter is memoized automatically, and will only be called when the binding updates.

### 📕 Parameters

Expand Down
42 changes: 42 additions & 0 deletions src/use-event-listener/README.md
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 />;
}
```
1 change: 1 addition & 0 deletions src/use-event-listener/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./use-event-listener";
112 changes: 112 additions & 0 deletions src/use-event-listener/use-event-listener.spec.ts
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();
});
};
89 changes: 89 additions & 0 deletions src/use-event-listener/use-event-listener.ts
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]);
}

0 comments on commit 26518b8

Please sign in to comment.