From 0d7703584d6cd6a498c135fe00acb5ea43928654 Mon Sep 17 00:00:00 2001 From: Harley Alexander Date: Tue, 18 Feb 2020 11:44:02 +1100 Subject: [PATCH] =?UTF-8?q?=F0=9F=8F=B7=E2=99=BB=EF=B8=8F=20improved=20typ?= =?UTF-8?q?es,=20refactored=20hooks=20for=20simplicity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .netlify/state.json | 3 ++ src/PusherProvider.tsx | 49 ++++++++++---------- src/helpers.ts | 17 ------- src/types.ts | 15 ++---- src/useChannel.ts | 20 +++++--- src/useClientTrigger.ts | 17 ++++++- src/useEvent.ts | 4 +- src/usePresenceChannel.ts | 97 ++++++++++++++++----------------------- src/usePusher.ts | 7 ++- src/useTrigger.ts | 50 ++++++++++---------- 10 files changed, 133 insertions(+), 146 deletions(-) create mode 100644 .netlify/state.json delete mode 100644 src/helpers.ts diff --git a/.netlify/state.json b/.netlify/state.json new file mode 100644 index 0000000..6fc2d21 --- /dev/null +++ b/.netlify/state.json @@ -0,0 +1,3 @@ +{ + "siteId": "aa919ed6-b843-411b-b6f7-828ea279b040" +} \ No newline at end of file diff --git a/src/PusherProvider.tsx b/src/PusherProvider.tsx index b3d8cc9..8cb0c65 100644 --- a/src/PusherProvider.tsx +++ b/src/PusherProvider.tsx @@ -10,49 +10,52 @@ const PusherContext = React.createContext({}); export const __PusherContext = PusherContext; /** - * Provider for the pusher service in an app + * Provider that creates your pusher instance and provides it to child hooks throughout your app. + * Note, you can pass in value={{}} as a prop if you'd like to override the pusher client passed. + * This is handy when simulating pusher locally, or for testing. * @param props Config for Pusher client */ -export function PusherProvider({ + +export const PusherProvider: React.FC = ({ clientKey, cluster, triggerEndpoint, - authEndpoint, - auth, defer = false, + children, ...props -}: PusherProviderProps) { +}) => { // errors when required props are not passed. invariant(clientKey, 'A client key is required for pusher'); invariant(cluster, 'A cluster is required for pusher'); - const { children, ...additionalConfig } = props; - const config: Options = { cluster, ...additionalConfig }; - if (authEndpoint) config.authEndpoint = authEndpoint; - if (auth) config.auth = auth; - const pusherClientRef = useRef(); + const config: Options = { cluster, ...props }; + + const pusherClientRef = useRef(); // track config for comparison - const previousConfig = useRef(); + const previousConfig = useRef(props); useEffect(() => { - previousConfig.current = config; + previousConfig.current = props; }); useEffect(() => { - // if client exists and options are the same, skip. - if (dequal(previousConfig.current, config) && pusherClientRef.current !== undefined) { + // Skip creation of client if deferring, a value prop is passed, or config props are the same. + if ( + defer || + props.value || + (dequal(previousConfig.current, props) && pusherClientRef.current !== undefined) + ) { return; } - // optional defer parameter skips creating the class. - // handy if you want to wait for something async like - // a JWT before creating it. - if (!defer) { - pusherClientRef.current = new Pusher(clientKey, config); - } + // create the client and assign it to the ref + pusherClientRef.current = new Pusher(clientKey, config); - return () => pusherClientRef.current && pusherClientRef.current.disconnect(); - }, [clientKey, config, defer, pusherClientRef]); + // cleanup + return () => { + pusherClientRef.current && pusherClientRef.current.disconnect(); + }; + }, [clientKey, props, defer, pusherClientRef]); return ( ); -} +}; diff --git a/src/helpers.ts b/src/helpers.ts deleted file mode 100644 index 479a61c..0000000 --- a/src/helpers.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { useRef } from "react"; -import deepEqual from "dequal"; - -/** - * Nice helper to deep compare memoize - * @copyright Kent C. Dodds - * @param value value to memoize - */ -export function useDeepCompareMemoize(value: any[]) { - const ref = useRef(); - - if (!deepEqual(value, ref.current)) { - ref.current = value; - } - - return ref.current; -} diff --git a/src/types.ts b/src/types.ts index 640209a..c9fe7f6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,23 +1,16 @@ -import { Options, AuthOptions } from 'pusher-js'; +import Pusher, { Options } from 'pusher-js'; import * as React from 'react'; export interface PusherContextValues { - client?: any | undefined; + client?: React.MutableRefObject; triggerEndpoint?: string; } export interface PusherProviderProps extends Options { clientKey: string; - cluster: string; - authEndpoint?: string; - auth?: AuthOptions; + cluster: 'mt1' | 'us2' | 'us3' | 'eu' | 'ap1' | 'ap2' | 'ap3' | 'ap4'; triggerEndpoint?: string; defer?: boolean; - children: React.ReactNode; // for testing purposes - value?: any; -} - -export interface useChannelOptions { - skip?: boolean; + value?: PusherContextValues; } diff --git a/src/useChannel.ts b/src/useChannel.ts index d2d09ab..6b092f5 100644 --- a/src/useChannel.ts +++ b/src/useChannel.ts @@ -1,28 +1,36 @@ import { useEffect, useState } from 'react'; import invariant from 'invariant'; +import { Channel, PresenceChannel } from 'pusher-js'; import { usePusher } from './usePusher'; /** - * Subscribe to channel + * Subscribe to a channel + * + * @param channelName The name of the channel you want to subscribe to. + * @typeparam Type of channel you're subscribing to. Can be one of Channel or PresenceChannel. + * @returns Instance of the channel you just subscribed to. * * @example - * useChannel("my-channel") + * ```javascript + * const channel = useChannel("my-channel") + * channel.bind('some-event', () => {}) + * ``` */ -export function useChannel(channelName: string) { +export function useChannel(channelName: string) { // errors for missing arguments invariant(channelName, 'channelName required to subscribe to a channel'); const { client } = usePusher(); const pusherClient = client && client.current; - const [channel, setChannel] = useState(); + const [channel, setChannel] = useState(); useEffect(() => { if (!pusherClient) return; - const channel = pusherClient.subscribe(channelName); - setChannel(channel); + const pusherChannel = pusherClient.subscribe(channelName); + setChannel(pusherChannel as T); }, [channelName, pusherClient]); return channel; } diff --git a/src/useClientTrigger.ts b/src/useClientTrigger.ts index 0bc02ea..e4a0ed2 100644 --- a/src/useClientTrigger.ts +++ b/src/useClientTrigger.ts @@ -2,7 +2,20 @@ import { useCallback } from 'react'; import invariant from 'invariant'; import { Channel, PresenceChannel } from 'pusher-js'; -export function useClientTrigger(channel: Channel | PresenceChannel) { +/** + * + * @param channel the channel you'd like to trigger clientEvents on. Get this from [[useChannel]] or [[usePresenceChannel]]. + * @typeparam TData shape of the data you're sending with the event. + * @returns A memoized trigger function that will perform client events on the channel. + * @example + * ```javascript + * const channel = useChannel('my-channel'); + * const trigger = useClientTrigger(channel) + * + * const handleClick = () => trigger('some-client-event', {}); + * ``` + */ +export function useClientTrigger(channel: Channel | PresenceChannel) { channel && invariant( channel.name.match(/(private-|presence-)/gi), @@ -11,7 +24,7 @@ export function useClientTrigger(channel: Channel | PresenceChannel) { // memoize trigger so it's not being created every render const trigger = useCallback( - (eventName: string, data: any = {}) => { + (eventName: string, data: TData) => { invariant(eventName, 'Must pass event name to trigger a client event.'); channel && channel.trigger(eventName, data); }, diff --git a/src/useEvent.ts b/src/useEvent.ts index 58e559e..5e1f78e 100644 --- a/src/useEvent.ts +++ b/src/useEvent.ts @@ -7,7 +7,6 @@ import { Channel, PresenceChannel } from 'pusher-js'; * @param channel Pusher channel to bind to * @param eventName Name of event to bind to * @param callback Callback to call on a new event - * @param dependencies Dependencies the callback uses. */ export function useEvent( channel: Channel | PresenceChannel | undefined, @@ -32,8 +31,7 @@ export function useEvent( useEffect(() => { if (channel === undefined) { return; - } - channel.bind(eventName, callback); + } else channel.bind(eventName, callback); return () => { channel.unbind(eventName, callback); }; diff --git a/src/usePresenceChannel.ts b/src/usePresenceChannel.ts index 660ec06..adf2bba 100644 --- a/src/usePresenceChannel.ts +++ b/src/usePresenceChannel.ts @@ -1,6 +1,6 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useEffect, useState } from 'react'; -import { PresenceChannel } from 'pusher-js'; +import { PresenceChannel, Members } from 'pusher-js'; import invariant from 'invariant'; import { useChannel } from './useChannel'; @@ -8,22 +8,16 @@ import { useChannel } from './useChannel'; /** * Subscribe to presence channel events and get members back * - * @param channelName name of presence channel. Should have presence- suffix. - * @param eventName name of event to bind to - * @param onEvent callback to fire when event is called - * @param dependencies dependencies array that onEvent uses - * @param options optional argument to skip events + * @param channelName name of presence the channel. Should have `presence-` suffix. + * @returns Object with `channel`, `members` and `myID` properties. * * @example - * const {members} = usePresenceChannel( - * "my-channel", - * "my-event", - * (message) => console.log(message), - * ) + * ```javascript + * const { channel, members, myID } = usePresenceChannel("presence-my-channel"); + * ``` */ export function usePresenceChannel(channelName: string) { // errors for missing arguments - invariant(channelName, 'channelName required to subscribe to a channel'); invariant( channelName.includes('presence-'), "Presence channels should use prefix 'presence-' in their name. Use the useChannel hook instead." @@ -32,44 +26,34 @@ export function usePresenceChannel(channelName: string) { // Get regular channel functionality const [members, setMembers] = useState({}); const [myID, setMyID] = useState(); - /** - * Get members info on subscription success - */ - const handleSubscriptionSuccess = useCallback((members: any) => { - setMembers(members.members); - setMyID(members.myID); - }, []); - /** - * Add or update member in object. - * @note not using a new Map() here to match pusher-js library. - * @param member member being added - */ - const handleAdd = useCallback((member: any) => { - setMembers(previousMembers => ({ - ...previousMembers, - [member.id]: member.info, - })); - }, []); - - /** - * Remove member from the state object. - * @param member Member being removed - */ - const handleRemove = useCallback((member: any) => { - setMembers(previousMembers => { - const nextMembers: any = { ...previousMembers }; - delete nextMembers[member.id]; - return nextMembers; - }); - }, []); - - /** - * Bind and unbind to membership events - */ - const channel = useChannel(channelName); + // bind and unbind member events events on our channel + const channel = useChannel(channelName); useEffect(() => { if (channel) { + // Get membership info on successful subscription + const handleSubscriptionSuccess = (members: Members) => { + setMembers(members.members); + setMyID(members.myID); + }; + + // add a member to the members object + const handleAdd = (member: any) => { + setMembers(previousMembers => ({ + ...previousMembers, + [member.id]: member.info, + })); + }; + + // remove a member from the members object + const handleRemove = (member: any) => { + setMembers(previousMembers => { + const nextMembers: any = { ...previousMembers }; + delete nextMembers[member.id]; + return nextMembers; + }); + }; + // bind to all member addition/removal events channel.bind('pusher:subscription_succeeded', handleSubscriptionSuccess); channel.bind('pusher:member_added', handleAdd); @@ -80,22 +64,21 @@ export function usePresenceChannel(channelName: string) { setMembers(channel.members.members); setMyID(channel.members.myID); } - } - // cleanup - return () => { - if (channel) { + // cleanup + return () => { channel.unbind('pusher:subscription_succeeded', handleSubscriptionSuccess); channel.unbind('pusher:member_added', handleAdd); channel.unbind('pusher:member_removed', handleRemove); - } - }; - }, [channel, handleSubscriptionSuccess, handleAdd, handleRemove]); + }; + } - const presenceChannel = channel as PresenceChannel; + // to make typescript happy. + return () => {}; + }, [channel]); return { - channel: presenceChannel, + channel, members, myID, }; diff --git a/src/usePusher.ts b/src/usePusher.ts index 43175a5..6d211d4 100644 --- a/src/usePusher.ts +++ b/src/usePusher.ts @@ -3,11 +3,14 @@ import { __PusherContext } from './PusherProvider'; import { PusherContextValues } from './types'; /** - * Provides access to the pusher client + * Provides access to the pusher client instance. * + * @returns a `MutableRefObject`. The instance is held by a `useRef()` hook. * @example - * const {client} = usePusher(); + * ```javscript + * const { client } = usePusher(); * client.current.subscribe('my-channel'); + * ``` */ export function usePusher() { const context = useContext(__PusherContext); diff --git a/src/useTrigger.ts b/src/useTrigger.ts index 8d2d65c..515ce05 100644 --- a/src/useTrigger.ts +++ b/src/useTrigger.ts @@ -1,50 +1,50 @@ -import { useCallback } from "react"; -import { usePusher } from "./usePusher"; -import invariant from "invariant"; -import { useChannel } from "./useChannel"; +import { useCallback } from 'react'; +import { usePusher } from './usePusher'; +import invariant from 'invariant'; +import { useChannel } from './useChannel'; /** - * Trigger events hook + * Hook to provide a trigger function that calls the server defined in `PusherProviderProps.triggerEndpoint` using `fetch`. + * Any `auth?.headers` in the config object will be passed with the `fetch` call. * - * @example + * @param channelName name of channel to call trigger on + * @typeparam TData shape of the data you're sending with the event * - * const trigger = useTrigger('my-channel'); + * @example + * ```typescript + * const trigger = useTrigger<{message: string}>('my-channel'); * trigger('my-event', {message: 'hello'}); + * ``` */ -export function useTrigger(channelName: string) { +export function useTrigger(channelName: string) { const { client, triggerEndpoint } = usePusher(); - // subscribe to the channel we'll be triggering to. - useChannel(channelName); - - invariant(channelName, "No channel specified to trigger to."); - + // you can't use this if you haven't supplied a triggerEndpoint. invariant( triggerEndpoint, - "No trigger endpoint specified to . Cannot trigger an event." + 'No trigger endpoint specified to . Cannot trigger an event.' ); - /** - * Memoized trigger function - */ + // subscribe to the channel we'll be triggering to. + useChannel(channelName); + + // memoized trigger function to return const trigger = useCallback( - (eventName: string, data?: any) => { + (eventName: string, data?: TData) => { const fetchOptions: RequestInit = { - method: "POST", - body: JSON.stringify({ channelName, eventName, data }) + method: 'POST', + body: JSON.stringify({ channelName, eventName, data }), }; - if (client.current && client.current.config.auth) { + if (client && client.current && client.current.config.auth) { fetchOptions.headers = client.current.config.auth.headers; } else { console.warn( - "No auth parameters supplied to . Your events will be unauthenticated." + 'No auth parameters supplied to . Your events will be unauthenticated.' ); } - // forcing triggerEndpoint to exist for TS here - // because invariant will throw during dev - return fetch(triggerEndpoint!, fetchOptions); + return fetch(triggerEndpoint, fetchOptions); }, [client, triggerEndpoint, channelName] );