Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Some improvements... #95

Merged
merged 7 commits into from
Aug 11, 2022
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
43 changes: 24 additions & 19 deletions src/__tests__/useChannel.tsx
Original file line number Diff line number Diff line change
@@ -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 }}>
<ChannelsProvider>{children}</ChannelsProvider>
</__PusherContext.Provider>
);
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 }}>
<ChannelsProvider>{children}</ChannelsProvider>
</__PusherContext.Provider>
);
const { result } = renderHook(() => useChannel("public-channel"), {
wrapper,
Expand All @@ -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}>
<ChannelsProvider>{children}</ChannelsProvider>
</__PusherContext.Provider>
);
const { unmount } = await renderHook(() => useChannel("public-channel"), {
const { unmount } = renderHook(() => useChannel("public-channel"), {
wrapper,
});
unmount();

expect(mockUnsubscribe).toHaveBeenCalled();
expect(client.unsubscribe).toHaveBeenCalled();
});
});
90 changes: 90 additions & 0 deletions src/core/ChannelsProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
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<ChannelsContextValues>({});
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<ConnectedChannels>({});

const subscribe = useCallback(
<T extends Channel & PresenceChannel>(channelName: string) => {
/** Return early if there's no client */
if (!client) return;

/** Return early if channel name is falsy */
if (!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 client */
if (!client) return;
/** Return early if channel name is falsy */
if (!channelName) return;
/** If no connection, just skip*/
if (!connectedChannels.current[channelName]?.length) 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(
<T extends Channel & PresenceChannel>(channelName: string) => {
/** Return early if there's no client */
semoal marked this conversation as resolved.
Show resolved Hide resolved
if (!client) return;
/** Return early if channel name is falsy */
if (!channelName) return;
/** Return early if channel is not in state */
if (!connectedChannels.current[channelName]) return;
/** Return channel */
return connectedChannels.current[channelName][0] as T;
},
[connectedChannels, client]
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

probably don't need connectedChannels in dependencies as it's a ref. Why did you opt for a ref rather than useState? Wouldn't useState refresh all the subscribers to get the updated channels?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've tried with useState but subscribe and unsubscribe reference changes every time connectedChannels changes, this makes useChannel useEffect to runs infinitely, tried with useCallbacks, useMemos, even using the callback of the setState instead of using the value of the state. Found the most stable option using the ref.

Probably I'm missing something, but couldn't get it work with useState :(

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, with useState if some parent context changes, will lose all the connections right?

);

return (
<ChannelsContext.Provider
value={{
unsubscribe,
subscribe,
getChannel,
}}
>
{children}
</ChannelsContext.Provider>
);
};
20 changes: 12 additions & 8 deletions src/core/PusherProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
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";

// context setup
const PusherContext = React.createContext<PusherContextValues>({});
Expand Down Expand Up @@ -30,7 +29,10 @@ export const CorePusherProvider: React.FC<PusherProviderProps> = ({
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<Options | undefined>(props);
Expand All @@ -44,24 +46,26 @@ export const CorePusherProvider: React.FC<PusherProviderProps> = ({
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]);

console.log(client);
semoal marked this conversation as resolved.
Show resolved Hide resolved
return (
<PusherContext.Provider
value={{
client,
triggerEndpoint,
}}
children={children}
{...props}
/>
>
{children}
</PusherContext.Provider>
);
};
20 changes: 19 additions & 1 deletion src/core/types.d.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -8,8 +13,21 @@ export interface PusherContextValues {
triggerEndpoint?: string;
}

export interface ChannelsContextValues {
subscribe?: <T extends Channel & PresenceChannel>(
channelName: string
) => T | undefined;
unsubscribe?: <T extends Channel & PresenceChannel>(
channelName: string
) => void;
getChannel?: <T extends Channel & PresenceChannel>(
channelName: string
) => T | undefined;
}

export interface PusherProviderProps extends Options {
_PusherRuntime?: typeof Pusher;
children: React.ReactNode;
clientKey: string | undefined;
cluster:
| "mt1"
Expand Down
30 changes: 9 additions & 21 deletions src/core/useChannel.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<T extends Channel & PresenceChannel>(
channelName: string | undefined
) {
const { client } = usePusher();
const [channel, setChannel] = useState<T | undefined>();
useEffect(() => {
/** Return early if there's no client */
if (!client) return;
const [channel, setChannel] = useState<Channel & PresenceChannel>();
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<T>(channelName);
setChannel(_channel);
return () => unsubscribe(channelName);
}, [channelName, subscribe, unsubscribe]);

/** Return the channel for use. */
return channel;
Expand Down
19 changes: 19 additions & 0 deletions src/core/useChannels.tsx
Original file line number Diff line number Diff line change
@@ -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<ChannelsContextValues>(__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 <ChannelsProvider />?";
5 changes: 4 additions & 1 deletion src/core/useTrigger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,13 @@ export function useTrigger<TData = {}>(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);
Expand Down
2 changes: 2 additions & 0 deletions src/native/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
7 changes: 4 additions & 3 deletions src/testUtils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -21,9 +22,9 @@ export async function renderHookWithProvider<T>(
clientConfig: Record<string, any> = {}
) {
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}>
<ChannelsProvider>{children}</ChannelsProvider>
</__PusherContext.Provider>
);
const result = renderHook(hook, { wrapper });
Expand Down
2 changes: 2 additions & 0 deletions src/web/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
8 changes: 4 additions & 4 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down