diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml new file mode 100644 index 0000000..a336723 --- /dev/null +++ b/.github/workflows/workflow.yml @@ -0,0 +1,31 @@ +name: "🚀 CI Workflow" + +on: + push: + branches: + - master + workflow_call: {} + pull_request: {} + +jobs: + test: + name: 🃏 Test + runs-on: ubuntu-latest + steps: + - name: 🛑 Cancel Previous Runs + uses: styfle/cancel-workflow-action@0.9.1 + + - name: ⬇️ Checkout repo + uses: actions/checkout@v3 + + - name: ⎔ Setup node + uses: actions/setup-node@v3 + with: + cache: "yarn" + node-version: 16 + + - name: 📥 Download deps + run: yarn install + + - name: 🃏 Test + run: yarn test diff --git a/package.json b/package.json index eafd0de..3e3317d 100644 --- a/package.json +++ b/package.json @@ -42,10 +42,10 @@ "dependencies": { "dequal": "^2.0.1", "invariant": "^2.2.4", - "pusher-js": "^7.0.0" + "pusher-js": "^7.3.0" }, "peerDependencies": { - "react": "^16.9.0" + "react": ">=16.9.0" }, "devDependencies": { "@babel/core": "^7.8.6", diff --git a/src/__tests__/useChannel.tsx b/src/__tests__/useChannel.tsx index 8efe361..4281804 100644 --- a/src/__tests__/useChannel.tsx +++ b/src/__tests__/useChannel.tsx @@ -1,24 +1,30 @@ -import { PusherChannelMock } from "pusher-js-mock"; +import { PusherChannelMock, PusherMock } from "pusher-js-mock"; import React from "react"; import { renderHook } from "@testing-library/react-hooks"; import { renderHookWithProvider } from "../testUtils"; -import { useChannel, NO_CHANNEL_NAME_WARNING } from "../core/useChannel"; +import { useChannel } from "../core/useChannel"; import { __PusherContext } from "../core/PusherProvider"; +import { ChannelsProvider } from "../web"; describe("useChannel()", () => { - test("should throw an error when no channelName present", () => { - const wrapper: React.FC = (props) => ( - <__PusherContext.Provider value={{ client: {} as any }} {...props} /> + test("should return undefined when channelName is falsy", () => { + const wrapper: React.FC = ({ children }) => ( + <__PusherContext.Provider value={{ client: {} as any }}> + {children} + ); + const { result } = renderHook(() => useChannel(""), { + wrapper, + }); - jest.spyOn(console, "warn"); - renderHook(() => useChannel(undefined), { wrapper }); - expect(console.warn).toHaveBeenCalledWith(NO_CHANNEL_NAME_WARNING); + expect(result.current).toBeUndefined(); }); test("should return undefined if no pusher client present", () => { - const wrapper: React.FC = (props) => ( - <__PusherContext.Provider value={{ client: undefined }} {...props} /> + const wrapper: React.FC = ({ children }) => ( + <__PusherContext.Provider value={{ client: undefined }}> + {children} + ); const { result } = renderHook(() => useChannel("public-channel"), { wrapper, @@ -35,19 +41,18 @@ describe("useChannel()", () => { }); test("should unsubscribe on unmount", async () => { - const mockUnsubscribe = jest.fn(); - const client = { - subscribe: jest.fn(), - unsubscribe: mockUnsubscribe, - }; - const wrapper: React.FC = (props) => ( - <__PusherContext.Provider value={{ client: client as any }} {...props} /> + const client = new PusherMock("key"); + client.unsubscribe = jest.fn(); + const wrapper: React.FC = ({ children, ...props }) => ( + <__PusherContext.Provider value={{ client: client as any }} {...props}> + {children} + ); - const { unmount } = await renderHook(() => useChannel("public-channel"), { + const { unmount } = renderHook(() => useChannel("public-channel"), { wrapper, }); unmount(); - expect(mockUnsubscribe).toHaveBeenCalled(); + expect(client.unsubscribe).toHaveBeenCalled(); }); }); diff --git a/src/core/ChannelsProvider.tsx b/src/core/ChannelsProvider.tsx new file mode 100644 index 0000000..ddf1656 --- /dev/null +++ b/src/core/ChannelsProvider.tsx @@ -0,0 +1,88 @@ +import { Channel, PresenceChannel } from "pusher-js"; +import React, { useCallback, useRef } from "react"; +import { ChannelsContextValues } from "./types"; + +import { usePusher } from "./usePusher"; + +// context setup +const ChannelsContext = React.createContext({}); +export const __ChannelsContext = ChannelsContext; + +type AcceptedChannels = Channel | PresenceChannel; +type ConnectedChannels = { + [channelName: string]: AcceptedChannels[]; +}; + +/** + * Provider that creates your channels instances and provides it to child hooks throughout your app. + */ + +export const ChannelsProvider: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + const { client } = usePusher(); + const connectedChannels = useRef({}); + + const subscribe = useCallback( + (channelName: string) => { + /** Return early if there's no client */ + if (!client || !channelName) return; + + /** Subscribe to channel and set it in state */ + const pusherChannel = client.subscribe(channelName); + connectedChannels.current[channelName] = [ + ...(connectedChannels.current[channelName] || []), + pusherChannel, + ]; + return pusherChannel as T; + }, + [client, connectedChannels] + ); + + const unsubscribe = useCallback( + (channelName: string) => { + /** Return early if there's no props */ + if ( + !client || + !channelName || + !(channelName in connectedChannels.current) + ) + return; + /** If just one connection, unsubscribe totally*/ + if (connectedChannels.current[channelName].length === 1) { + client.unsubscribe(channelName); + delete connectedChannels.current[channelName]; + } else { + connectedChannels.current[channelName].pop(); + } + }, + [connectedChannels, client] + ); + + const getChannel = useCallback( + (channelName: string) => { + /** Return early if there's no client */ + if ( + !client || + !channelName || + !(channelName in connectedChannels.current) + ) + return; + /** Return channel */ + return connectedChannels.current[channelName][0] as T; + }, + [connectedChannels, client] + ); + + return ( + + {children} + + ); +}; diff --git a/src/core/PusherProvider.tsx b/src/core/PusherProvider.tsx index f7acba2..ef9dbb0 100644 --- a/src/core/PusherProvider.tsx +++ b/src/core/PusherProvider.tsx @@ -1,8 +1,8 @@ import { Options } from "pusher-js"; -import { PusherContextValues, PusherProviderProps } from "./types"; -import React, { useEffect, useRef, useState } from "react"; - +import React, { useEffect, useMemo, useRef, useState } from "react"; import { dequal } from "dequal"; +import { PusherContextValues, PusherProviderProps } from "./types"; +import { ChannelsProvider } from "./ChannelsProvider"; // context setup const PusherContext = React.createContext({}); @@ -30,7 +30,10 @@ export const CorePusherProvider: React.FC = ({ if (!cluster) console.error("A cluster is required for pusher"); }, [clientKey, cluster]); - const config: Options = { cluster, ...props }; + const config: Options = useMemo( + () => ({ cluster, ...props }), + [cluster, props] + ); // track config for comparison const previousConfig = useRef(props); @@ -44,15 +47,15 @@ export const CorePusherProvider: React.FC = ({ if ( !_PusherRuntime || defer || + !clientKey || props.value || (dequal(previousConfig.current, props) && client !== undefined) ) { return; } - // @ts-ignore setClient(new _PusherRuntime(clientKey, config)); - }, [client, clientKey, props, defer]); + }, [client, clientKey, props, defer, _PusherRuntime, config]); return ( = ({ client, triggerEndpoint, }} - children={children} {...props} - /> + > + {children} + ); }; diff --git a/src/core/types.d.ts b/src/core/types.d.ts index d28a1d6..1df03bd 100644 --- a/src/core/types.d.ts +++ b/src/core/types.d.ts @@ -1,4 +1,9 @@ -import { default as Pusher, Options } from "pusher-js"; +import { + Channel, + default as Pusher, + Options, + PresenceChannel, +} from "pusher-js"; import * as React from "react"; import "jest-fetch-mock"; @@ -8,8 +13,21 @@ export interface PusherContextValues { triggerEndpoint?: string; } +export interface ChannelsContextValues { + subscribe?: ( + channelName: string + ) => T | undefined; + unsubscribe?: ( + channelName: string + ) => void; + getChannel?: ( + channelName: string + ) => T | undefined; +} + export interface PusherProviderProps extends Options { _PusherRuntime?: typeof Pusher; + children: React.ReactNode; clientKey: string | undefined; cluster: | "mt1" diff --git a/src/core/useChannel.ts b/src/core/useChannel.ts index 750cf6e..9624645 100644 --- a/src/core/useChannel.ts +++ b/src/core/useChannel.ts @@ -1,6 +1,6 @@ import { Channel, PresenceChannel } from "pusher-js"; import { useEffect, useState } from "react"; -import { usePusher } from "./usePusher"; +import { useChannels } from "./useChannels"; /** * Subscribe to a channel @@ -16,31 +16,19 @@ import { usePusher } from "./usePusher"; * ``` */ -export const NO_CHANNEL_NAME_WARNING = - "No channel name passed to useChannel. No channel has been subscribed to."; - export function useChannel( channelName: string | undefined ) { - const { client } = usePusher(); - const [channel, setChannel] = useState(); - useEffect(() => { - /** Return early if there's no client */ - if (!client) return; + const [channel, setChannel] = useState(); + const { subscribe, unsubscribe } = useChannels(); - /** Return early and warn if there's no channel */ - if (!channelName) { - console.warn(NO_CHANNEL_NAME_WARNING); - return; - } - - /** Subscribe to channel and set it in state */ - const pusherChannel = client.subscribe(channelName); - setChannel(pusherChannel as T); + useEffect(() => { + if (!channelName || !subscribe || !unsubscribe) return; - /** Cleanup on unmount/re-render */ - return () => client?.unsubscribe(channelName); - }, [channelName, client]); + const _channel = subscribe(channelName); + setChannel(_channel); + return () => unsubscribe(channelName); + }, [channelName, subscribe, unsubscribe]); /** Return the channel for use. */ return channel; diff --git a/src/core/useChannels.tsx b/src/core/useChannels.tsx new file mode 100644 index 0000000..0d7e551 --- /dev/null +++ b/src/core/useChannels.tsx @@ -0,0 +1,19 @@ +import { useContext, useEffect } from "react"; +import { __ChannelsContext } from "./ChannelsProvider"; +import { ChannelsContextValues } from "./types"; + +/** + * Provides access to the channels global provider. + */ + +export function useChannels() { + const context = useContext(__ChannelsContext); + useEffect(() => { + if (!context || !Object.keys(context).length) + console.warn(NOT_IN_CONTEXT_WARNING); + }, [context]); + return context; +} + +const NOT_IN_CONTEXT_WARNING = + "No Channels context. Did you forget to wrap your app in a ?"; diff --git a/src/core/useTrigger.ts b/src/core/useTrigger.ts index 07fbb0d..bf40fad 100644 --- a/src/core/useTrigger.ts +++ b/src/core/useTrigger.ts @@ -33,10 +33,13 @@ export function useTrigger(channelName: string) { (eventName: string, data?: TData) => { const fetchOptions: RequestInit = { method: "POST", - body: JSON.stringify({ channelName, eventName, data }) + body: JSON.stringify({ channelName, eventName, data }), }; + // @ts-expect-error deprecated since 7.1.0, but still supported for backwards compatibility + // now it should use channelAuthorization instead if (client && client.config?.auth) { + // @ts-expect-error deprecated fetchOptions.headers = client.config.auth.headers; } else { console.warn(NO_AUTH_HEADERS_WARNING); diff --git a/src/native/index.ts b/src/native/index.ts index 4ecf420..cb0cea1 100644 --- a/src/native/index.ts +++ b/src/native/index.ts @@ -5,5 +5,7 @@ export * from "../core/usePresenceChannel"; export * from "../core/useEvent"; export * from "../core/useClientTrigger"; export * from "../core/useTrigger"; +export * from "../core/useChannels"; +export * from "../core/ChannelsProvider"; export * from "./PusherProvider"; export { __PusherContext } from "../core/PusherProvider"; diff --git a/src/testUtils.tsx b/src/testUtils.tsx index cef4da3..1d3dc91 100644 --- a/src/testUtils.tsx +++ b/src/testUtils.tsx @@ -4,6 +4,7 @@ import React from "react"; import Pusher from "pusher-js"; import { PusherMock } from "pusher-js-mock"; import { __PusherContext } from "./core/PusherProvider"; +import { ChannelsProvider } from "./core/ChannelsProvider"; /** * Flushes async promises in mocks @@ -21,9 +22,9 @@ export async function renderHookWithProvider( clientConfig: Record = {} ) { const client = new PusherMock("key", clientConfig) as unknown; - const wrapper: React.FC = ({ children }) => ( - <__PusherContext.Provider value={{ client: client as Pusher }}> - {children} + const wrapper: React.FC = ({ children, ...props }) => ( + <__PusherContext.Provider value={{ client: client as Pusher }} {...props}> + {children} ); const result = renderHook(hook, { wrapper }); diff --git a/src/web/index.ts b/src/web/index.ts index 4ecf420..82151dc 100644 --- a/src/web/index.ts +++ b/src/web/index.ts @@ -4,6 +4,8 @@ export * from "../core/useChannel"; export * from "../core/usePresenceChannel"; export * from "../core/useEvent"; export * from "../core/useClientTrigger"; +export * from "../core/useChannels"; export * from "../core/useTrigger"; +export * from "../core/ChannelsProvider"; export * from "./PusherProvider"; export { __PusherContext } from "../core/PusherProvider"; diff --git a/yarn.lock b/yarn.lock index 6fcb36d..5ebcddd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6309,10 +6309,10 @@ pusher-js-mock@mayteio/pusher-js-mock#feature/presence-channels-release: version "0.2.1" resolved "https://codeload.github.com/mayteio/pusher-js-mock/tar.gz/1b426161901b736b729efeb256f573426eae56a2" -pusher-js@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/pusher-js/-/pusher-js-7.0.0.tgz#9716ae3f79ca7f87a269764f278a8e14494a4e20" - integrity sha512-2ZSw8msMe6EKNTebQSthRInrWUK9bo3zXPmQx0bfeDFJdSnTWUROhdAhmpRQREHzqrL+l4imv/3uwgIQHUO0oQ== +pusher-js@^7.3.0: + version "7.3.0" + resolved "https://registry.yarnpkg.com/pusher-js/-/pusher-js-7.3.0.tgz#0d4334bc67fb16b599cff7a1464016dfbd6d5658" + integrity sha512-N7uFRZGK6PKFfKd8e1aKsQ81OrilATGNhIbj42xJKmK+3zhstGBCQ110ZiF2nIngPTEKxQQqf15SJsVnQfNuqQ== dependencies: tweetnacl "^1.0.3"