From e33056460c49ee07086b450c0988eb3a2f580509 Mon Sep 17 00:00:00 2001 From: Jaden Date: Wed, 31 Jan 2024 15:56:58 -0800 Subject: [PATCH 1/9] add channel state caching and better reconnectivity --- .prettierrc.js => .prettierrc.cjs | 0 package.json | 4 +- src/PhoenixProvider.tsx | 13 ++- src/cache.ts | 32 +++++++ src/index.ts | 1 - src/useChannel/types.ts | 8 +- src/useChannel/useChannel.ts | 136 ++++++++++++++++++++---------- src/useEvent/index.ts | 4 +- src/useEvent/useEvent.ts | 72 +++------------- src/usePhoenix/usePhoenix.ts | 6 +- src/usePresence/types.ts | 10 --- src/usePresence/usePresence.tsx | 5 +- src/util.ts | 17 +++- 13 files changed, 177 insertions(+), 131 deletions(-) rename .prettierrc.js => .prettierrc.cjs (100%) create mode 100644 src/cache.ts diff --git a/.prettierrc.js b/.prettierrc.cjs similarity index 100% rename from .prettierrc.js rename to .prettierrc.cjs diff --git a/package.json b/package.json index ff23ce0..6c9afb5 100644 --- a/package.json +++ b/package.json @@ -2,9 +2,7 @@ "name": "use-phoenix", "version": "0.0.1-alpha.7", "description": "React hooks for the Phoenix Framework", - "main": "dist/index.js", - "module": "dist/index.esm.js", - "types": "dist/index.d.ts", + "main": "src/index.js", "files": [ "dist" ], diff --git a/src/PhoenixProvider.tsx b/src/PhoenixProvider.tsx index c87760a..2bdc773 100644 --- a/src/PhoenixProvider.tsx +++ b/src/PhoenixProvider.tsx @@ -17,6 +17,8 @@ export function PhoenixProvider({ url, options, ...props }: PhoenixProviderProps const { children, onOpen, onClose, onError } = props; const [socket, set] = useState(null); + const [isConnected, setConnected] = useState(false); + const socketRef = useLatest(socket); const defaultListeners = useCallback( @@ -24,6 +26,14 @@ export function PhoenixProvider({ url, options, ...props }: PhoenixProviderProps if (onOpen) socket.onOpen(onOpen); if (onClose) socket.onClose(onClose); if (onError) socket.onError(onError); + + socket.onOpen(() => { + setConnected(true); + }); + + socket.onClose(() => { + setConnected(false); + }); }, [onClose, onError, onOpen] ); @@ -33,7 +43,6 @@ export function PhoenixProvider({ url, options, ...props }: PhoenixProviderProps const socket = new Socket(url, options ?? {}) as PhoenixSocket; socket.connect(); set(socket); - defaultListeners(socket); return socket; @@ -52,7 +61,7 @@ export function PhoenixProvider({ url, options, ...props }: PhoenixProviderProps }, [url, options, connect]); return ( - + {children} ); diff --git a/src/cache.ts b/src/cache.ts new file mode 100644 index 0000000..e9dad99 --- /dev/null +++ b/src/cache.ts @@ -0,0 +1,32 @@ +import { ChannelMeta } from './useChannel'; + +export const cache = new Map>(); + +const defaultMeta: ChannelMeta = { + data: null, + status: 'joining', + isSuccess: false, + isLoading: true, + isError: false, + error: null +}; + +export default { + insert: (topic: string, channelMeta: ChannelMeta) => { + cache.set(topic, channelMeta); + }, + get: (topic: string | undefined | boolean | null): ChannelMeta => { + if (typeof topic !== 'string') return defaultMeta; + + const result = cache.get(topic); + + if (result) { + return result; + } else { + return defaultMeta; + } + }, + delete: (topic: string): boolean => { + return cache.delete(topic); + } +}; diff --git a/src/index.ts b/src/index.ts index ebb55ce..499f945 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,4 +3,3 @@ export { useChannel } from './useChannel'; export { useEvent } from './useEvent'; export { usePhoenix } from './usePhoenix'; export { usePresence } from './usePresence'; - diff --git a/src/useChannel/types.ts b/src/useChannel/types.ts index 253562c..8e80232 100644 --- a/src/useChannel/types.ts +++ b/src/useChannel/types.ts @@ -1,6 +1,10 @@ +import { Merge } from '../util'; + export type { Push } from 'phoenix'; export { Channel } from 'phoenix'; +export type ChannelState = Merge, { leave: () => void; push: PushFunction }>; + export type PushEvent = { event: string; data?: Record; @@ -14,8 +18,8 @@ export type ChannelStatus = | 'connection timeout' | 'closed'; -export type ChannelMeta = { - data: TJoinResponse | null; +export type ChannelMeta = { + data: JoinResposne | null; status: ChannelStatus; isSuccess: boolean; isLoading: boolean; diff --git a/src/useChannel/useChannel.ts b/src/useChannel/useChannel.ts index 5a4d587..4244784 100644 --- a/src/useChannel/useChannel.ts +++ b/src/useChannel/useChannel.ts @@ -1,59 +1,90 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import useLatest from '../useLatest'; import { usePhoenix } from '../usePhoenix'; -import type { Channel, ChannelMeta, ChannelOptions, ChannelParams, Push, PushFunction } from './types'; +import { + Channel, + ChannelMeta, + ChannelOptions, + ChannelParams, + ChannelState, + Push, + PushFunction +} from './types'; import { Channel as ChannelClass } from 'phoenix'; import { findChannel } from '../util'; -import { Merge } from '../usePresence'; - -export function useChannel( +import cache from '../cache'; + +/** + * A hook to open a new Phoenix channel, or attach to an existing one + * that has been opened by another component. + * + * Note If the channel is already open, the hook will return the existing + * channel and state. + * + * This behavior differs from Phoenix.js where any time you create + * a new channel, it will close the existing one. This hook will not close + * the existing channel and instead attaches to it. + * + * This is useful for when you have multiple components that need to interact + * with the same channel. + * + * @example + * ```ts + * const [channel, { push, leave, data }] = useChannel('room:1'); + * useEvent(channel, 'new_message', handleMessage); + * ``` + * + * @param topic - the topic to connect to. + * @param params - The params to send when joining the channel. + */ +export function useChannel( topic: string | boolean | null | undefined, - options?: ChannelOptions -): [Channel | null, Merge, { leave: () => void; push: PushFunction }>] { - const { socket } = usePhoenix(); - - const [channel, set] = useState(null); - const [meta, setMeta] = useState>({ - data: null, - status: 'joining', - isSuccess: false, - isLoading: true, - isError: false, - error: null - }); + params?: ChannelOptions +): [Channel | undefined, ChannelState] { + const { socket, isConnected } = usePhoenix(); - const channelRef = useLatest(channel); + const [channel, set] = useState(findChannel(socket, topic as string)); + const [meta, setMeta] = useState>( + cache.get(topic as string) + ); - const { params } = options || {}; + const paramsRef = useLatest(params); useEffect(() => { - if (socket === null) return; + if (!isConnected) return; if (typeof topic !== 'string') return; + if (!socket) return; + + const params = paramsRef.current?.params ?? {}; const existingChannel = findChannel(socket, topic); if (existingChannel) { /* If we find an existing channel with this topic, - we need to reconect our internal reference so we can - properly use our functions like `push` and `leave`. */ + we need to reconect our internal reference. */ set(existingChannel); + setMeta(cache.get(topic)); + return; } - const channel = socket.channel(topic, params); + const _channel = socket.channel(topic, params); - channel + _channel .join() - .receive('ok', (response: TJoinResponse) => { - setMeta({ + .receive('ok', (response: JoinResposne) => { + const meta: ChannelMeta = { isSuccess: true, isLoading: false, isError: false, error: null, data: response, status: 'success' - }); + }; + + setMeta(meta); + cache.insert(topic, meta); }) .receive('error', (error) => { setMeta({ @@ -76,7 +107,18 @@ export function useChannel( }); }); - channel.on('phx_error', () => { + _channel.onError((error) => { + setMeta({ + isSuccess: false, + isLoading: false, + isError: true, + error, + data: null, + status: 'error' + }); + }); + + _channel.on('phx_error', () => { setMeta({ isSuccess: false, isLoading: false, @@ -90,14 +132,19 @@ export function useChannel( * If the channel is in an error state, we want to leave the channel. * So we do not attempt to rejoin infinitely. */ - channel.leave(); + if (channel) channel.leave(); }); - set(channel); - }, [socket, topic, params, setMeta]); + set(_channel); + }, [isConnected, topic]); - const push: PushFunction = useCallback((event, payload) => - pushPromise(channelRef.current?.push(event, payload ?? {})), [channelRef]); + const push: PushFunction = useCallback( + (event, payload) => { + if (channel === undefined) return Promise.reject('Channel is not connected.'); + return pushPromise(channel.push(event, payload ?? {})); + }, + [channel] + ); /* * Allows you to leave the channel. @@ -105,20 +152,21 @@ export function useChannel( * */ const leave = useCallback(() => { - if (channelRef?.current instanceof ChannelClass) { - channelRef?.current.leave(); - set(null); + if (channel instanceof ChannelClass) { + channel.leave(); + set(undefined); } - }, [channelRef]); + }, [channel]); + + const channelState: ChannelState = useMemo( + () => ({ ...meta, push, leave }), + [meta, push, leave] + ); - return [channelRef.current, { leave, push, ...meta }]; + return [channel, channelState]; } -const pushPromise = (push: Push | undefined): Promise => +const pushPromise = (push: Push): Promise => new Promise((resolve, reject) => { - if (!push) { - return reject('Cannot use `push` while the reference to the channel is severed. Make sure the topic being supplied at the moment of this push is valid.'); - } - push.receive('ok', resolve).receive('error', reject); }); diff --git a/src/useEvent/index.ts b/src/useEvent/index.ts index edd8d7d..24e1f64 100644 --- a/src/useEvent/index.ts +++ b/src/useEvent/index.ts @@ -1,2 +1,2 @@ -export * from "./types"; -export * from "./useEvent"; +export * from './types'; +export * from './useEvent'; diff --git a/src/useEvent/useEvent.ts b/src/useEvent/useEvent.ts index d9295c6..422c9c6 100644 --- a/src/useEvent/useEvent.ts +++ b/src/useEvent/useEvent.ts @@ -1,90 +1,44 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useEffect, useState } from 'react'; import { Channel } from 'phoenix'; import useLatest from '../useLatest'; -import { usePhoenix } from '../usePhoenix'; -import { findChannel } from '../util'; import { EventAction } from './types'; /** - * Hook to subscribe to a Phoenix channel event. + * A hook to subscribe to a Phoenix channel event. * - * @example - Usage with a boolean identifier + * @example * ```ts - * useEvent(props.id && `room:${props.id}`, 'new_message', handleMessage); - * ``` - * @example - Usage with a channel topic. - * ```ts - * useEvent('room:lobby', 'new_message', handleMessage); - * ``` - * @example - Usage with an existing channel. - * ```ts - * const channel = useChannel('room:lobby'); + * const [channel, state] = useChannel('room:1'); * useEvent(channel, 'new_message', handleMessage); * ``` * - * @param identifier - The identifier can be a topic `string` or a `Channel`. - * In the case of a topic string, the hook will attempt to look for and connec to an existing instance - * of the channel on the socket. If one does not exist, it will create a new instance and join the channel. - * Additionally, if the identifier is a boolean expression that evaluates to `false`, the hook will not - * attempt to connect the identifier to the socket. + * @param channel - A `Channel` provided by `useChannel`. * @param event - The event name to listen for. * @param listener - The callback function to invoke when the event is received. */ export function useEvent( - identifier: Channel | string | undefined | null, + channel: Channel | undefined | null, event: Event['event'], listener?: (response: Event['data']) => void ): { data: Event['data'] | null } { - const { socket } = usePhoenix(); const handler = useLatest(listener); - const [channel, set] = useState(null); - const [data, setData] = useState(null); - - const upsert = useCallback( - (topic: string): Channel | null => { - if (socket) { - let channel = findChannel(socket, topic); - if (channel) return channel; - channel = socket.channel(topic, {}); - channel.join(); - return channel; - } - - return null; - }, - [socket] - ); - - useEffect(() => { - /* - * If the identifier is undefined, it indicates that a boolean expression was supplied - * and the condition was not met. This prevents the socket from being initialized - */ - if (typeof identifier == 'undefined' || identifier === null) { - return; - } else if (typeof identifier == 'string') { - set(upsert(identifier)); - return; - } else if (identifier instanceof Channel) { - set(identifier); - } else { - throw new Error('Invalid identifier. Must be a topic string or Channel.'); - } - }, [identifier, upsert]); + const [data, setData] = useState(null); useEffect(() => { - if (channel === null) return; + if (!channel) return; + if (typeof event !== 'string') return; const ref = channel.on(event, (message) => { + if (typeof handler.current === 'function') { + handler.current(message); + } + setData(message); - if (typeof handler.current !== 'function') return; - handler.current(message); }); return () => { channel.off(event, ref); - set(null); }; }, [channel, event, handler]); diff --git a/src/usePhoenix/usePhoenix.ts b/src/usePhoenix/usePhoenix.ts index 00f4637..31495ab 100644 --- a/src/usePhoenix/usePhoenix.ts +++ b/src/usePhoenix/usePhoenix.ts @@ -4,12 +4,10 @@ import { ConnectFunction, PhoenixSocket } from './types'; export const PhoenixContext = React.createContext<{ socket: PhoenixSocket | null; connect: ConnectFunction; + isConnected: boolean; } | null>(null); -export const usePhoenix = (): { - socket: PhoenixSocket | null; - connect: ConnectFunction; -} => { +export const usePhoenix = () => { const context = React.useContext(PhoenixContext); if (context === null) throw new Error('usePhoenix must be used within a PhoenixProvider'); return context; diff --git a/src/usePresence/types.ts b/src/usePresence/types.ts index 389b7c2..4a03a94 100644 --- a/src/usePresence/types.ts +++ b/src/usePresence/types.ts @@ -13,13 +13,3 @@ export type PresenceDiff = { joins: T & { metas: M }; leaves: T & { metas: M }; }; - -export type Merge = { - [K in keyof A | keyof B]: K extends keyof A & keyof B - ? A[K] | B[K] - : K extends keyof B - ? B[K] - : K extends keyof A - ? A[K] - : never; -}; diff --git a/src/usePresence/usePresence.tsx b/src/usePresence/usePresence.tsx index 68809eb..2bb4ccd 100644 --- a/src/usePresence/usePresence.tsx +++ b/src/usePresence/usePresence.tsx @@ -1,7 +1,8 @@ import { useEffect, useMemo, useState } from 'react'; import { Presence } from 'phoenix'; import { usePhoenix } from '../usePhoenix'; -import type { Merge, Metas, PresenceState } from './types'; +import type { Metas, PresenceState } from './types'; +import { Merge } from '../util'; export function usePresence( topic: string | undefined @@ -49,7 +50,7 @@ export function usePresence( } return { id: key, ..._presence[key], metas }; - }) + }) : [], [_presence] ) as Merge[]; diff --git a/src/util.ts b/src/util.ts index accb527..064eb99 100644 --- a/src/util.ts +++ b/src/util.ts @@ -1,4 +1,17 @@ +import { Channel } from 'phoenix'; import { PhoenixSocket } from './usePhoenix'; -export const findChannel = (socket: PhoenixSocket, topic: string) => - socket.channels.find((channel) => channel.topic === topic); +export const findChannel = (socket: PhoenixSocket | null, topic: string): Channel | undefined => { + if (typeof topic !== 'string') return undefined; + return socket?.channels.find((channel) => channel.topic === topic); +}; + +export type Merge = { + [K in keyof A | keyof B]: K extends keyof A & keyof B + ? A[K] | B[K] + : K extends keyof B + ? B[K] + : K extends keyof A + ? A[K] + : never; +}; From c6079a060c8aec0d9187a209c6ffed7f8a3f6900 Mon Sep 17 00:00:00 2001 From: Jaden Date: Thu, 8 Feb 2024 13:29:27 -0800 Subject: [PATCH 2/9] refresh channel state --- src/useChannel/useChannel.ts | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/src/useChannel/useChannel.ts b/src/useChannel/useChannel.ts index 4244784..dcf2d82 100644 --- a/src/useChannel/useChannel.ts +++ b/src/useChannel/useChannel.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import useLatest from '../useLatest'; import { usePhoenix } from '../usePhoenix'; import { @@ -136,7 +136,7 @@ export function useChannel( }); set(_channel); - }, [isConnected, topic]); + }, [isConnected, topic, setMeta, set]); const push: PushFunction = useCallback( (event, payload) => { @@ -158,12 +158,7 @@ export function useChannel( } }, [channel]); - const channelState: ChannelState = useMemo( - () => ({ ...meta, push, leave }), - [meta, push, leave] - ); - - return [channel, channelState]; + return [channel, {...meta, push, leave}]; } const pushPromise = (push: Push): Promise => From f52a3a4ceae6772e48464a6f4c0903a20a611228 Mon Sep 17 00:00:00 2001 From: Jaden Date: Fri, 9 Feb 2024 17:31:52 -0800 Subject: [PATCH 3/9] no longer leave channels on errors --- .prettierrc.cjs | 2 +- README.md | 59 ++++++++++++++++----------- src/cache.ts | 2 +- src/useChannel/useChannel.ts | 79 +++++++++--------------------------- src/util.ts | 24 ++++++++++- 5 files changed, 79 insertions(+), 87 deletions(-) diff --git a/.prettierrc.cjs b/.prettierrc.cjs index ad083de..2e0033a 100644 --- a/.prettierrc.cjs +++ b/.prettierrc.cjs @@ -2,7 +2,7 @@ * {@type require('prettier').Config} */ module.exports = { - useTabs: true, + useTabs: false, printWidth: 100, singleQuote: true, trailingComma: 'none', diff --git a/README.md b/README.md index 54dfb1d..573602b 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # use-phoenix + ## Connecting to your Phoenix Socket -Wrap the intended part of your application with `PhoenixProvider`. + +Wrap the intended part of your application with a `PhoenixProvider`. ```tsx // App.tsx @@ -12,21 +14,24 @@ const Application = () => { ``` Passing a `url` and params to your `PhoenixProvder` will connect to your socket instantly **on mount**: + ```tsx return ( - - ... - + + ... + ); ``` -Using the `usePhoenix` hook to connect lazily using `connect`: + +You could instead use the `usePhoenix` hook to connect lazily using `connect`: To use this option **do not pass a `url`** into `PhoenixProvider`: + ```tsx // App.tsx return ...; @@ -49,7 +54,10 @@ const Component = () => { }; ``` -## Listening for events - `useEvent` & `useChannel` +`usePhoenix` also provides `isConnected` which becomes `true` when the socket has successfully connected. + +## Quick Start - `useEvent` & `useChannel` + You can pass a short circuit expression to delay connection to an event or channel. If for example you are waiting to recieve an id to use from some network request, `useEvent` and `useChannel` will not connect until it is defined. Below is a contrived example: ```jsx @@ -67,16 +75,24 @@ interface PongEvent { }; } +interface JoinPayload { + secret: string; +} + interface PingResponse { ok: boolean; } // Channel will not connect until id is defined - const [channel, { push }] = useChannel(id && `chat:${id}`); + const [channel, { push, data }] = useChannel(id && `chat:${id}`); + // ^^^^ + // data is typed according to `JoinPayload` - const { data } = useEvent(channel, 'pong'); + // Events will not be listened to until data.secret is defined + const { data } = useEvent(channel, data?.secret && `pong:${data.secret}`); const handleClick = () => { + // ok is typed according to PingResponse const { ok } = await push('ping', { body: 'Hello World' }) } @@ -92,7 +108,7 @@ interface PingResponse { # useEvent -`useEvent` is a hook that allows you to succinctly listen in on a channel and receive responses. +`useEvent` is a hook that allows you to succinctly listen in on channel events. ### Example Usage @@ -111,17 +127,14 @@ const [channel, { isSuccess, isError, ...rest }] = useChannel('chat:lobby') // pass in a channel directly const { data } = useEvent(channel, 'join') -// OR pass in a channel topic and let the hook create the channel internally -const { data } = useEvent('chat:lobby', 'join'); - // typed console.log(data.members) ``` -Optionally, if you would rather capture the response in a callback you can: +Optionally, if you would rather capture the response in a callback you can (or both): -```tsx -useEvent('chat:lobby', 'join', (response) => { +```ts +const { data } = useEvent(channel, 'join', (data) => { console.log(response); }); ``` @@ -131,7 +144,7 @@ useEvent('chat:lobby', 'join', (response) => { `useChannel` gives you important functions and information about the state of the channel. The following properties are available for `useChannel` ```ts -data: TJoinResponse | null; // the join response from the server +data: JoinPayload | null; // the join response from the server status: ChannelStatus; isSuccess: boolean; isLoading: boolean; @@ -196,9 +209,7 @@ users[0].metas.lastSeen; ## Notes -- If a channel recieves a `phx_error` event, meaning there was some internal server error, `useChannel` will leave the associated channel to avoid infinite error looping. - -- Currently, `useChannel` does not automatically `leave` when the hook unmounts so the socket will continue to listen in on the channel. It is best to handle leaving the channel explicitly using `leave`: +- `useChannel` does not automatically `leave` the channel when the hook unmounts. That is, the socket will continue to listen in on the channel. It is best to handle leaving the channel explicitly using `leave` if you would like to leave the channel on component unmounts: ```ts const [channel, { leave }] = useChannel('chat:lobby'); diff --git a/src/cache.ts b/src/cache.ts index e9dad99..0c24dc2 100644 --- a/src/cache.ts +++ b/src/cache.ts @@ -15,7 +15,7 @@ export default { insert: (topic: string, channelMeta: ChannelMeta) => { cache.set(topic, channelMeta); }, - get: (topic: string | undefined | boolean | null): ChannelMeta => { + get: (topic: string | undefined | boolean | null): ChannelMeta => { if (typeof topic !== 'string') return defaultMeta; const result = cache.get(topic); diff --git a/src/useChannel/useChannel.ts b/src/useChannel/useChannel.ts index dcf2d82..3b868d1 100644 --- a/src/useChannel/useChannel.ts +++ b/src/useChannel/useChannel.ts @@ -1,18 +1,17 @@ import { useCallback, useEffect, useState } from 'react'; import useLatest from '../useLatest'; import { usePhoenix } from '../usePhoenix'; -import { +import type { Channel, ChannelMeta, ChannelOptions, ChannelParams, ChannelState, - Push, PushFunction } from './types'; import { Channel as ChannelClass } from 'phoenix'; -import { findChannel } from '../util'; +import { createMeta, findChannel, pushPromise } from '../util'; import cache from '../cache'; /** @@ -38,15 +37,15 @@ import cache from '../cache'; * @param topic - the topic to connect to. * @param params - The params to send when joining the channel. */ -export function useChannel( +export function useChannel( topic: string | boolean | null | undefined, - params?: ChannelOptions -): [Channel | undefined, ChannelState] { + params?: ChannelOptions +): [Channel | undefined, ChannelState] { const { socket, isConnected } = usePhoenix(); const [channel, set] = useState(findChannel(socket, topic as string)); - const [meta, setMeta] = useState>( - cache.get(topic as string) + const [meta, setMeta] = useState>( + cache.get(topic as string) ); const paramsRef = useLatest(params); @@ -64,7 +63,7 @@ export function useChannel( /* If we find an existing channel with this topic, we need to reconect our internal reference. */ set(existingChannel); - setMeta(cache.get(topic)); + setMeta(cache.get(topic)); return; } @@ -73,66 +72,31 @@ export function useChannel( _channel .join() - .receive('ok', (response: JoinResposne) => { - const meta: ChannelMeta = { - isSuccess: true, - isLoading: false, - isError: false, - error: null, - data: response, - status: 'success' - }; - - setMeta(meta); + .receive('ok', (response: JoinPayload) => { + const meta = createMeta(true, false, false, null, response, 'success'); cache.insert(topic, meta); + setMeta(meta); }) .receive('error', (error) => { - setMeta({ - isSuccess: false, - isLoading: false, - isError: true, - error, - data: null, - status: 'error' - }); + setMeta(createMeta(false, false, true, error, null, 'error')); }) .receive('timeout', () => { - setMeta({ - isSuccess: false, - isLoading: false, - isError: true, - error: null, - status: 'connection timeout', - data: null - }); + setMeta(createMeta(false, false, true, null, null, 'connection timeout')); }); _channel.onError((error) => { - setMeta({ - isSuccess: false, - isLoading: false, - isError: true, - error, - data: null, - status: 'error' - }); + setMeta(createMeta(false, false, true, error, null, 'error')); }); _channel.on('phx_error', () => { - setMeta({ - isSuccess: false, - isLoading: false, - isError: true, - error: null, - status: 'internal server error', - data: null - }); - + setMeta(createMeta(false, false, true, null, null, 'internal server error')); /** * If the channel is in an error state, we want to leave the channel. * So we do not attempt to rejoin infinitely. + * + * Disabling this for now, could make it opt-in. */ - if (channel) channel.leave(); + // if (channel) channel.leave(); }); set(_channel); @@ -158,10 +122,5 @@ export function useChannel( } }, [channel]); - return [channel, {...meta, push, leave}]; + return [channel, { ...meta, push, leave }]; } - -const pushPromise = (push: Push): Promise => - new Promise((resolve, reject) => { - push.receive('ok', resolve).receive('error', reject); - }); diff --git a/src/util.ts b/src/util.ts index 064eb99..05e8a8c 100644 --- a/src/util.ts +++ b/src/util.ts @@ -1,11 +1,33 @@ -import { Channel } from 'phoenix'; +import { Channel, Push } from 'phoenix'; import { PhoenixSocket } from './usePhoenix'; +import { ChannelMeta } from './useChannel'; export const findChannel = (socket: PhoenixSocket | null, topic: string): Channel | undefined => { if (typeof topic !== 'string') return undefined; return socket?.channels.find((channel) => channel.topic === topic); }; +export const createMeta = ( + isSuccess: ChannelMeta['isSuccess'], + isLoading: ChannelMeta['isLoading'], + isError: ChannelMeta['isError'], + error: ChannelMeta['error'], + data: ChannelMeta['data'], + status: ChannelMeta['status'] +): ChannelMeta => ({ + isSuccess, + isLoading, + isError, + error, + data, + status +}); + +export const pushPromise = (push: Push): Promise => + new Promise((resolve, reject) => { + push.receive('ok', resolve).receive('error', reject); + }); + export type Merge = { [K in keyof A | keyof B]: K extends keyof A & keyof B ? A[K] | B[K] From 813bac998f2a1784578c8d9c5873efbee3aade49 Mon Sep 17 00:00:00 2001 From: Jaden Date: Fri, 9 Feb 2024 23:30:59 -0800 Subject: [PATCH 4/9] fix a bug where the channel state does not update if another useChannel is joining --- src/useChannel/useChannel.ts | 177 +++++++++++++++++++---------------- 1 file changed, 94 insertions(+), 83 deletions(-) diff --git a/src/useChannel/useChannel.ts b/src/useChannel/useChannel.ts index 3b868d1..a0123ab 100644 --- a/src/useChannel/useChannel.ts +++ b/src/useChannel/useChannel.ts @@ -2,12 +2,12 @@ import { useCallback, useEffect, useState } from 'react'; import useLatest from '../useLatest'; import { usePhoenix } from '../usePhoenix'; import type { - Channel, - ChannelMeta, - ChannelOptions, - ChannelParams, - ChannelState, - PushFunction + Channel, + ChannelMeta, + ChannelOptions, + ChannelParams, + ChannelState, + PushFunction } from './types'; import { Channel as ChannelClass } from 'phoenix'; @@ -38,89 +38,100 @@ import cache from '../cache'; * @param params - The params to send when joining the channel. */ export function useChannel( - topic: string | boolean | null | undefined, - params?: ChannelOptions + topic: string | boolean | null | undefined, + params?: ChannelOptions ): [Channel | undefined, ChannelState] { - const { socket, isConnected } = usePhoenix(); + const { socket, isConnected } = usePhoenix(); - const [channel, set] = useState(findChannel(socket, topic as string)); - const [meta, setMeta] = useState>( - cache.get(topic as string) - ); + const [channel, set] = useState(findChannel(socket, topic as string)); + const [meta, setMeta] = useState>( + cache.get(topic as string) + ); - const paramsRef = useLatest(params); + const paramsRef = useLatest(params); - useEffect(() => { - if (!isConnected) return; - if (typeof topic !== 'string') return; - if (!socket) return; + useEffect(() => { + if (!isConnected) return; + if (typeof topic !== 'string') return; + if (!socket) return; - const params = paramsRef.current?.params ?? {}; + const params = paramsRef.current?.params ?? {}; - const existingChannel = findChannel(socket, topic); + const existingChannel = findChannel(socket, topic); - if (existingChannel) { - /* If we find an existing channel with this topic, + if (existingChannel) { + /* If we find an existing channel with this topic, we need to reconect our internal reference. */ - set(existingChannel); - setMeta(cache.get(topic)); - - return; - } - - const _channel = socket.channel(topic, params); - - _channel - .join() - .receive('ok', (response: JoinPayload) => { - const meta = createMeta(true, false, false, null, response, 'success'); - cache.insert(topic, meta); - setMeta(meta); - }) - .receive('error', (error) => { - setMeta(createMeta(false, false, true, error, null, 'error')); - }) - .receive('timeout', () => { - setMeta(createMeta(false, false, true, null, null, 'connection timeout')); - }); - - _channel.onError((error) => { - setMeta(createMeta(false, false, true, error, null, 'error')); - }); - - _channel.on('phx_error', () => { - setMeta(createMeta(false, false, true, null, null, 'internal server error')); - /** - * If the channel is in an error state, we want to leave the channel. - * So we do not attempt to rejoin infinitely. - * - * Disabling this for now, could make it opt-in. - */ - // if (channel) channel.leave(); - }); - - set(_channel); - }, [isConnected, topic, setMeta, set]); - - const push: PushFunction = useCallback( - (event, payload) => { - if (channel === undefined) return Promise.reject('Channel is not connected.'); - return pushPromise(channel.push(event, payload ?? {})); - }, - [channel] - ); - - /* - * Allows you to leave the channel. - * useChannel does not automatically leave the channel when the component unmounts by default. - * - */ - const leave = useCallback(() => { - if (channel instanceof ChannelClass) { - channel.leave(); - set(undefined); - } - }, [channel]); - - return [channel, { ...meta, push, leave }]; + set(existingChannel); + + if (existingChannel.state === 'joining') { + existingChannel.on('phx_reply', () => { + /* It is possible that we found an existing channel + but it has not yet fully joined. In this case, we want to + listen in on phx_reply, to update our meta from the + useChannel that is actually doing the join() */ + setMeta(cache.get(topic)); + }); + } else { + setMeta(cache.get(topic)); + } + + return; + } + + const _channel = socket.channel(topic, params); + + _channel + .join() + .receive('ok', (response: JoinPayload) => { + const meta = createMeta(true, false, false, null, response, 'success'); + cache.insert(topic, meta); + setMeta(meta); + }) + .receive('error', (error) => { + setMeta(createMeta(false, false, true, error, null, 'error')); + }) + .receive('timeout', () => { + setMeta(createMeta(false, false, true, null, null, 'connection timeout')); + }); + + _channel.onError((error) => { + setMeta(createMeta(false, false, true, error, null, 'error')); + }); + + _channel.on('phx_error', () => { + setMeta(createMeta(false, false, true, null, null, 'internal server error')); + /** + * If the channel is in an error state, we want to leave the channel. + * So we do not attempt to rejoin infinitely. + * + * Disabling this for now, could make it opt-in. + */ + // if (channel) channel.leave(); + }); + + set(_channel); + }, [isConnected, topic, setMeta, set]); + + const push: PushFunction = useCallback( + (event, payload) => { + if (channel === undefined) return Promise.reject('Channel is not connected.'); + return pushPromise(channel.push(event, payload ?? {})); + }, + [channel] + ); + + /* + * Allows you to leave the channel. + * useChannel does not automatically leave the channel when the component unmounts by default. + * + */ + const leave = useCallback(() => { + if (channel instanceof ChannelClass) { + channel.leave(); + set(undefined); + } + }, [channel]); + + return [channel, { ...meta, push, leave }]; } From b6c845f8855b6ad6657cd7380a3d76283f36c170 Mon Sep 17 00:00:00 2001 From: Jaden Date: Tue, 13 Feb 2024 06:12:52 -0800 Subject: [PATCH 5/9] use a ref to ensure constant access to current channel --- src/useChannel/useChannel.ts | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/useChannel/useChannel.ts b/src/useChannel/useChannel.ts index a0123ab..aa018bc 100644 --- a/src/useChannel/useChannel.ts +++ b/src/useChannel/useChannel.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import useLatest from '../useLatest'; import { usePhoenix } from '../usePhoenix'; import type { @@ -44,6 +44,7 @@ export function useChannel( const { socket, isConnected } = usePhoenix(); const [channel, set] = useState(findChannel(socket, topic as string)); + const channelRef = useRef(null); const [meta, setMeta] = useState>( cache.get(topic as string) ); @@ -63,6 +64,7 @@ export function useChannel( /* If we find an existing channel with this topic, we need to reconect our internal reference. */ set(existingChannel); + channelRef.current = existingChannel; if (existingChannel.state === 'joining') { existingChannel.on('phx_reply', () => { @@ -111,15 +113,13 @@ export function useChannel( }); set(_channel); + channelRef.current = _channel; }, [isConnected, topic, setMeta, set]); - const push: PushFunction = useCallback( - (event, payload) => { - if (channel === undefined) return Promise.reject('Channel is not connected.'); - return pushPromise(channel.push(event, payload ?? {})); - }, - [channel] - ); + const push: PushFunction = useCallback((event, payload) => { + if (channelRef.current === null) return Promise.reject('Channel is not connected.'); + return pushPromise(channelRef.current.push(event, payload ?? {})); + }, []); /* * Allows you to leave the channel. @@ -127,11 +127,11 @@ export function useChannel( * */ const leave = useCallback(() => { - if (channel instanceof ChannelClass) { - channel.leave(); + if (channelRef.current instanceof ChannelClass) { + channelRef.current.leave(); set(undefined); } - }, [channel]); + }, []); return [channel, { ...meta, push, leave }]; } From 6a2f48cc0dacf9d09b31d337befd1f5ca00753bb Mon Sep 17 00:00:00 2001 From: Jaden Date: Tue, 13 Feb 2024 06:36:50 -0800 Subject: [PATCH 6/9] export types and add docs --- src/index.ts | 8 ++++++ src/useChannel/useChannel.ts | 24 ++++++++++++++--- src/useEvent/useEvent.ts | 52 +++++++++++++++++++++--------------- 3 files changed, 60 insertions(+), 24 deletions(-) diff --git a/src/index.ts b/src/index.ts index 499f945..861accc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,3 +3,11 @@ export { useChannel } from './useChannel'; export { useEvent } from './useEvent'; export { usePhoenix } from './usePhoenix'; export { usePresence } from './usePresence'; + +export type { + Channel, + ChannelMeta, + ChannelState +} from './useChannel'; + +export type { PhoenixSocket, SocketConnectOption } from './usePhoenix'; diff --git a/src/useChannel/useChannel.ts b/src/useChannel/useChannel.ts index aa018bc..99ec5a7 100644 --- a/src/useChannel/useChannel.ts +++ b/src/useChannel/useChannel.ts @@ -62,7 +62,7 @@ export function useChannel( if (existingChannel) { /* If we find an existing channel with this topic, - we need to reconect our internal reference. */ + we reconect our internal reference. */ set(existingChannel); channelRef.current = existingChannel; @@ -116,15 +116,33 @@ export function useChannel( channelRef.current = _channel; }, [isConnected, topic, setMeta, set]); + /** + * Pushes an event to the channel. + * + * @param event - The event to push. + * @param payload - The payload to send with the event. + * @returns Promise + */ const push: PushFunction = useCallback((event, payload) => { if (channelRef.current === null) return Promise.reject('Channel is not connected.'); return pushPromise(channelRef.current.push(event, payload ?? {})); }, []); - /* + /** * Allows you to leave the channel. - * useChannel does not automatically leave the channel when the component unmounts by default. * + * useChannel does not automatically leave the channel when the component unmounts by default. If + * you want to leave the channel when the component unmounts, you can use a useEffect: + * + * @example + * ```ts + * useEffect(() => { + * return () => { + * leave(); + * }; + * }, []); + * ``` + * @returns void */ const leave = useCallback(() => { if (channelRef.current instanceof ChannelClass) { diff --git a/src/useEvent/useEvent.ts b/src/useEvent/useEvent.ts index 422c9c6..b4b8fad 100644 --- a/src/useEvent/useEvent.ts +++ b/src/useEvent/useEvent.ts @@ -4,43 +4,53 @@ import useLatest from '../useLatest'; import { EventAction } from './types'; /** - * A hook to subscribe to a Phoenix channel event. + * A hook to subscribe to a Phoenix Channel event. + * + * You may obtain the event data from the `data` property and/or the `listener` callback. * * @example * ```ts + * type NewMessageEvent = { + * event: 'new_message'; + * data: { message: string }; + * }; + * * const [channel, state] = useChannel('room:1'); - * useEvent(channel, 'new_message', handleMessage); + * const { data } = useEvent(channel, 'new_message', handleMessage); * ``` * + * * @param channel - A `Channel` provided by `useChannel`. * @param event - The event name to listen for. * @param listener - The callback function to invoke when the event is received. + * + * @returns The data from the event. */ export function useEvent( - channel: Channel | undefined | null, - event: Event['event'], - listener?: (response: Event['data']) => void + channel: Channel | undefined | null, + event: Event['event'], + listener?: (response: Event['data']) => void ): { data: Event['data'] | null } { - const handler = useLatest(listener); + const handler = useLatest(listener); - const [data, setData] = useState(null); + const [data, setData] = useState(null); - useEffect(() => { - if (!channel) return; - if (typeof event !== 'string') return; + useEffect(() => { + if (!channel) return; + if (typeof event !== 'string') return; - const ref = channel.on(event, (message) => { - if (typeof handler.current === 'function') { - handler.current(message); - } + const ref = channel.on(event, (message) => { + if (typeof handler.current === 'function') { + handler.current(message); + } - setData(message); - }); + setData(message); + }); - return () => { - channel.off(event, ref); - }; - }, [channel, event, handler]); + return () => { + channel.off(event, ref); + }; + }, [channel, event, handler]); - return { data }; + return { data }; } From e8b97d320b36b90ef51167ddffbd973e553ea661 Mon Sep 17 00:00:00 2001 From: Jaden Date: Tue, 13 Feb 2024 06:37:41 -0800 Subject: [PATCH 7/9] run linter --- .eslintrc | 71 ++++++++++------------ .prettierrc.cjs | 16 ++--- CHANGELOG.md | 35 +++++++---- README.md | 38 ++++++------ rollup.config.js | 40 ++++++------ src/PhoenixProvider.tsx | 104 ++++++++++++++++---------------- src/cache.ts | 42 ++++++------- src/index.ts | 6 +- src/useChannel/types.ts | 45 +++++++------- src/useEvent/types.ts | 4 +- src/useLatest.ts | 6 +- src/usePhoenix/types.ts | 26 ++++---- src/usePhoenix/usePhoenix.ts | 12 ++-- src/usePresence/types.ts | 10 +-- src/usePresence/usePresence.tsx | 100 +++++++++++++++--------------- src/util.ts | 48 +++++++-------- 16 files changed, 302 insertions(+), 301 deletions(-) diff --git a/.eslintrc b/.eslintrc index bf0e755..204574b 100644 --- a/.eslintrc +++ b/.eslintrc @@ -1,41 +1,34 @@ { - "parser": "@typescript-eslint/parser", - "plugins": [ - "@typescript-eslint", - "react-hooks", - "simple-import-sort" - ], - "extends": [ - "eslint:recommended", - "plugin:react/recommended", - "plugin:react-hooks/recommended", - "plugin:@typescript-eslint/recommended", - "prettier" - ], - "parserOptions": { - "ecmaVersion": 2020, - "sourceType": "module", - "ecmaFeatures": { - "jsx": true - } - }, - "rules": {}, - "overrides": [ - { - "files": [ - "*.test.ts", - "*.test.tsx" - ], - "rules": { - // Allow testing runtime errors to suppress TS errors - "@typescript-eslint/ban-ts-comment": "off" - } - } - ], - "settings": { - "react": { - "pragma": "React", - "version": "detect" - } - } + "parser": "@typescript-eslint/parser", + "plugins": ["@typescript-eslint", "react-hooks", "simple-import-sort"], + "extends": [ + "eslint:recommended", + "plugin:react/recommended", + "plugin:react-hooks/recommended", + "plugin:@typescript-eslint/recommended", + "prettier" + ], + "parserOptions": { + "ecmaVersion": 2020, + "sourceType": "module", + "ecmaFeatures": { + "jsx": true + } + }, + "rules": {}, + "overrides": [ + { + "files": ["*.test.ts", "*.test.tsx"], + "rules": { + // Allow testing runtime errors to suppress TS errors + "@typescript-eslint/ban-ts-comment": "off" + } + } + ], + "settings": { + "react": { + "pragma": "React", + "version": "detect" + } + } } diff --git a/.prettierrc.cjs b/.prettierrc.cjs index 2e0033a..ef8e9b5 100644 --- a/.prettierrc.cjs +++ b/.prettierrc.cjs @@ -2,12 +2,12 @@ * {@type require('prettier').Config} */ module.exports = { - useTabs: false, - printWidth: 100, - singleQuote: true, - trailingComma: 'none', - bracketSameLine: false, - semi: true, - tabWidth: 2, - quoteProps: 'consistent' + useTabs: false, + printWidth: 100, + singleQuote: true, + trailingComma: 'none', + bracketSameLine: false, + semi: true, + tabWidth: 2, + quoteProps: 'consistent' }; diff --git a/CHANGELOG.md b/CHANGELOG.md index 84b2ebf..6427ccb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,27 +3,34 @@ 2023-12-27 ### Breaking changes + None ### Additional changes -* Fix buggy behavior where the reference to `useChannel` functions would be changing on every render. This would cause your useEffects to run even if there should be no change. + +- Fix buggy behavior where the reference to `useChannel` functions would be changing on every render. This would cause your useEffects to run even if there should be no change. # 0.0.1-alpha.6 2023-12-23 ### Breaking changes + None ### Additional changes -* Fixed a bug where if you successfully connected to a channel, but then later on the topic supplied to `useChannel` had changed to `null` and then back to the valid topic, the `useChannel` hook functions like `push` would no longer be holding a valid reference to the channel. Now, the hook will successfully update the reference and the functions will work as if the channel topic never changed. -* Use the internel channel `ref` when using `useChannel`'s `leave` + +- Fixed a bug where if you successfully connected to a channel, but then later on the topic supplied to `useChannel` had changed to `null` and then back to the valid topic, the `useChannel` hook functions like `push` would no longer be holding a valid reference to the channel. Now, the hook will successfully update the reference and the functions will work as if the channel topic never changed. +- Use the internel channel `ref` when using `useChannel`'s `leave` + # 0.0.1-alpha.5 2023-12-17 ### Breaking changes -* The typescript type for `useChannel`'s `PushEvent` now aligns with the rest of the types + +- The typescript type for `useChannel`'s `PushEvent` now aligns with the rest of the types + ```jsx type PushEvent = { - type: string; @@ -35,12 +42,14 @@ type PushEvent = { ``` ### Additional changes -* Added rollup build tooling which should reduce bundle size slightly -* Phoenix.js is now marked as a peer dependency -* `useChannel` can now accept a short circuit operation to delay connecting to the channel until the condition is met. - - ```jsx - // Delay connecting until id is defined - const [channel] = useChannel(id && `room:${id}`) - ``` -* The `push` function type has been improved to catch more potential errors. + +- Added rollup build tooling which should reduce bundle size slightly +- Phoenix.js is now marked as a peer dependency +- `useChannel` can now accept a short circuit operation to delay connecting to the channel until the condition is met. + + ```jsx + // Delay connecting until id is defined + const [channel] = useChannel(id && `room:${id}`); + ``` + +- The `push` function type has been improved to catch more potential errors. diff --git a/README.md b/README.md index 573602b..671d1bb 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Wrap the intended part of your application with a `PhoenixProvider`. import { PhoenixProvider } from 'use-phoenix'; const Application = () => { - return ...; + return ...; }; ``` @@ -17,14 +17,14 @@ Passing a `url` and params to your `PhoenixProvder` will connect to your socket ```tsx return ( - - ... - + + ... + ); ``` @@ -44,13 +44,13 @@ Later on when you would like to connect the socket: import { usePhoenix } from 'use-phoenix'; const Component = () => { - const { socket, connect } = usePhoenix(); + const { socket, connect } = usePhoenix(); - useEffect(() => { - connect('ws://localhost:4000/socket', { - params: { token: 'xyz' } - }); - }, [connect]); + useEffect(() => { + connect('ws://localhost:4000/socket', { + params: { token: 'xyz' } + }); + }, [connect]); }; ``` @@ -135,7 +135,7 @@ Optionally, if you would rather capture the response in a callback you can (or b ```ts const { data } = useEvent(channel, 'join', (data) => { - console.log(response); + console.log(response); }); ``` @@ -215,8 +215,8 @@ users[0].metas.lastSeen; const [channel, { leave }] = useChannel('chat:lobby'); useEffect(() => { - return () => { - leave(); - }; + return () => { + leave(); + }; }, [leave]); ``` diff --git a/rollup.config.js b/rollup.config.js index f66dfcf..e130e45 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -10,36 +10,36 @@ import pkg from './package.json' assert { type: 'json' }; const isDev = process.env.BUILD !== 'production'; const cjs = { - file: pkg.main, - format: 'cjs', - exports: 'named', - sourcemap: true, - plugins: !isDev && [terser()] + file: pkg.main, + format: 'cjs', + exports: 'named', + sourcemap: true, + plugins: !isDev && [terser()] }; const esm = { - file: pkg.module, - format: 'esm', - exports: 'named', - sourcemap: true + file: pkg.module, + format: 'esm', + exports: 'named', + sourcemap: true }; const extensions = ['.js', '.ts', '.tsx', '.json']; const plugins = [ typescript(), - resolve({ extensions }), - commonjs(), - babel({ exclude: 'node_modules/**', extensions }), - replace({ - 'preventAssignment': true, - 'process.env.NODE_ENV': JSON.stringify(isDev ? 'development' : 'production') - }) + resolve({ extensions }), + commonjs(), + babel({ exclude: 'node_modules/**', extensions }), + replace({ + 'preventAssignment': true, + 'process.env.NODE_ENV': JSON.stringify(isDev ? 'development' : 'production') + }) ].filter(Boolean); export default { - input: 'src/index.ts', - output: isDev ? [esm] : [cjs, esm], - plugins, - external: Object.keys(pkg.peerDependencies) + input: 'src/index.ts', + output: isDev ? [esm] : [cjs, esm], + plugins, + external: Object.keys(pkg.peerDependencies) }; diff --git a/src/PhoenixProvider.tsx b/src/PhoenixProvider.tsx index 2bdc773..7f5d714 100644 --- a/src/PhoenixProvider.tsx +++ b/src/PhoenixProvider.tsx @@ -5,73 +5,73 @@ import { Socket } from 'phoenix'; import React, { useCallback, useEffect, useState } from 'react'; export type PhoenixProviderProps = { - url?: string; - options?: Partial; - children?: React.ReactNode; - onOpen?: () => void; - onClose?: () => void; - onError?: () => void; + url?: string; + options?: Partial; + children?: React.ReactNode; + onOpen?: () => void; + onClose?: () => void; + onError?: () => void; }; export function PhoenixProvider({ url, options, ...props }: PhoenixProviderProps) { - const { children, onOpen, onClose, onError } = props; + const { children, onOpen, onClose, onError } = props; - const [socket, set] = useState(null); - const [isConnected, setConnected] = useState(false); + const [socket, set] = useState(null); + const [isConnected, setConnected] = useState(false); - const socketRef = useLatest(socket); + const socketRef = useLatest(socket); - const defaultListeners = useCallback( - (socket: PhoenixSocket) => { - if (onOpen) socket.onOpen(onOpen); - if (onClose) socket.onClose(onClose); - if (onError) socket.onError(onError); + const defaultListeners = useCallback( + (socket: PhoenixSocket) => { + if (onOpen) socket.onOpen(onOpen); + if (onClose) socket.onClose(onClose); + if (onError) socket.onError(onError); - socket.onOpen(() => { - setConnected(true); - }); + socket.onOpen(() => { + setConnected(true); + }); - socket.onClose(() => { - setConnected(false); - }); - }, - [onClose, onError, onOpen] - ); + socket.onClose(() => { + setConnected(false); + }); + }, + [onClose, onError, onOpen] + ); - const connect = useCallback( - (url: string, options?: Partial): PhoenixSocket => { - const socket = new Socket(url, options ?? {}) as PhoenixSocket; - socket.connect(); - set(socket); - defaultListeners(socket); + const connect = useCallback( + (url: string, options?: Partial): PhoenixSocket => { + const socket = new Socket(url, options ?? {}) as PhoenixSocket; + socket.connect(); + set(socket); + defaultListeners(socket); - return socket; - }, - [defaultListeners] - ); + return socket; + }, + [defaultListeners] + ); - useEffect(() => { - if (!url) return; + useEffect(() => { + if (!url) return; - const socket = connect(url, options); + const socket = connect(url, options); - return () => { - socket.disconnect(); - }; - }, [url, options, connect]); + return () => { + socket.disconnect(); + }; + }, [url, options, connect]); - return ( - - {children} - - ); + return ( + + {children} + + ); } PhoenixProvider.defaultProps = { - options: {}, - onOpen: null, - onClose: null, - onError: null, - connect: true, - children: null + options: {}, + onOpen: null, + onClose: null, + onError: null, + connect: true, + children: null }; diff --git a/src/cache.ts b/src/cache.ts index 0c24dc2..550c56f 100644 --- a/src/cache.ts +++ b/src/cache.ts @@ -3,30 +3,30 @@ import { ChannelMeta } from './useChannel'; export const cache = new Map>(); const defaultMeta: ChannelMeta = { - data: null, - status: 'joining', - isSuccess: false, - isLoading: true, - isError: false, - error: null + data: null, + status: 'joining', + isSuccess: false, + isLoading: true, + isError: false, + error: null }; export default { - insert: (topic: string, channelMeta: ChannelMeta) => { - cache.set(topic, channelMeta); - }, - get: (topic: string | undefined | boolean | null): ChannelMeta => { - if (typeof topic !== 'string') return defaultMeta; + insert: (topic: string, channelMeta: ChannelMeta) => { + cache.set(topic, channelMeta); + }, + get: (topic: string | undefined | boolean | null): ChannelMeta => { + if (typeof topic !== 'string') return defaultMeta; - const result = cache.get(topic); + const result = cache.get(topic); - if (result) { - return result; - } else { - return defaultMeta; - } - }, - delete: (topic: string): boolean => { - return cache.delete(topic); - } + if (result) { + return result; + } else { + return defaultMeta; + } + }, + delete: (topic: string): boolean => { + return cache.delete(topic); + } }; diff --git a/src/index.ts b/src/index.ts index 861accc..6d045de 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,10 +4,6 @@ export { useEvent } from './useEvent'; export { usePhoenix } from './usePhoenix'; export { usePresence } from './usePresence'; -export type { - Channel, - ChannelMeta, - ChannelState -} from './useChannel'; +export type { Channel, ChannelMeta, ChannelState } from './useChannel'; export type { PhoenixSocket, SocketConnectOption } from './usePhoenix'; diff --git a/src/useChannel/types.ts b/src/useChannel/types.ts index 8e80232..3763ad2 100644 --- a/src/useChannel/types.ts +++ b/src/useChannel/types.ts @@ -3,37 +3,40 @@ import { Merge } from '../util'; export type { Push } from 'phoenix'; export { Channel } from 'phoenix'; -export type ChannelState = Merge, { leave: () => void; push: PushFunction }>; +export type ChannelState = Merge< + ChannelMeta, + { leave: () => void; push: PushFunction } +>; export type PushEvent = { - event: string; - data?: Record; -} + event: string; + data?: Record; +}; export type ChannelStatus = - | 'joining' - | 'success' - | 'error' - | 'internal server error' - | 'connection timeout' - | 'closed'; + | 'joining' + | 'success' + | 'error' + | 'internal server error' + | 'connection timeout' + | 'closed'; export type ChannelMeta = { - data: JoinResposne | null; - status: ChannelStatus; - isSuccess: boolean; - isLoading: boolean; - isError: boolean; - error: any; + data: JoinResposne | null; + status: ChannelStatus; + isSuccess: boolean; + isLoading: boolean; + isError: boolean; + error: any; }; -export type PushFunction = (( - event: Event['event'], - data?: Event['data'] -) => Promise); +export type PushFunction = ( + event: Event['event'], + data?: Event['data'] +) => Promise; export type ChannelOptions = { - params?: Params extends Record ? Params : undefined; + params?: Params extends Record ? Params : undefined; }; export type ChannelParams = Record; diff --git a/src/useEvent/types.ts b/src/useEvent/types.ts index 5e3a37f..6e662ef 100644 --- a/src/useEvent/types.ts +++ b/src/useEvent/types.ts @@ -1,6 +1,6 @@ export type EventAction = { - event: string; - data: any; + event: string; + data: any; }; export type UseEventListener = (response: EventResponse) => void; diff --git a/src/useLatest.ts b/src/useLatest.ts index 661bc9f..eef0b3c 100644 --- a/src/useLatest.ts +++ b/src/useLatest.ts @@ -2,7 +2,7 @@ import type { RefObject } from 'react'; import { useRef } from 'react'; export default (val: T): RefObject => { - const ref = useRef(val); - ref.current = val; - return ref; + const ref = useRef(val); + ref.current = val; + return ref; }; diff --git a/src/usePhoenix/types.ts b/src/usePhoenix/types.ts index 2003afe..71a8b17 100644 --- a/src/usePhoenix/types.ts +++ b/src/usePhoenix/types.ts @@ -3,19 +3,19 @@ import { Channel, Socket } from 'phoenix'; export type PhoenixSocket = Socket & { channels: Channel[] }; export interface SocketConnectOption { - binaryType: BinaryType; - params: Record | (() => object); - transport: new (endpoint: string) => object; - timeout: number; - heartbeatIntervalMs: number; - longpollerTimeout: number; - /** The function to encode outgoing messages, Defaults to JSON encoder */ - encode: (payload: object, callback: (encoded: any) => void | Promise) => void; - decode: (payload: string, callback: (decoded: any) => void | Promise) => void; - logger: (kind: string, message: string, data: any) => void; - reconnectAfterMs: (tries: number) => number; - rejoinAfterMs: (tries: number) => number; - vsn: string; + binaryType: BinaryType; + params: Record | (() => object); + transport: new (endpoint: string) => object; + timeout: number; + heartbeatIntervalMs: number; + longpollerTimeout: number; + /** The function to encode outgoing messages, Defaults to JSON encoder */ + encode: (payload: object, callback: (encoded: any) => void | Promise) => void; + decode: (payload: string, callback: (decoded: any) => void | Promise) => void; + logger: (kind: string, message: string, data: any) => void; + reconnectAfterMs: (tries: number) => number; + rejoinAfterMs: (tries: number) => number; + vsn: string; } /** diff --git a/src/usePhoenix/usePhoenix.ts b/src/usePhoenix/usePhoenix.ts index 31495ab..be2a462 100644 --- a/src/usePhoenix/usePhoenix.ts +++ b/src/usePhoenix/usePhoenix.ts @@ -2,13 +2,13 @@ import React from 'react'; import { ConnectFunction, PhoenixSocket } from './types'; export const PhoenixContext = React.createContext<{ - socket: PhoenixSocket | null; - connect: ConnectFunction; - isConnected: boolean; + socket: PhoenixSocket | null; + connect: ConnectFunction; + isConnected: boolean; } | null>(null); export const usePhoenix = () => { - const context = React.useContext(PhoenixContext); - if (context === null) throw new Error('usePhoenix must be used within a PhoenixProvider'); - return context; + const context = React.useContext(PhoenixContext); + if (context === null) throw new Error('usePhoenix must be used within a PhoenixProvider'); + return context; }; diff --git a/src/usePresence/types.ts b/src/usePresence/types.ts index 4a03a94..91607a5 100644 --- a/src/usePresence/types.ts +++ b/src/usePresence/types.ts @@ -1,15 +1,15 @@ export type Metas = { phx_ref?: string }[]; export type PresenceState = { - [id: string]: T & { metas: M }; + [id: string]: T & { metas: M }; }; export type PresenceData = T & { - id: string; - metas: M; + id: string; + metas: M; }; export type PresenceDiff = { - joins: T & { metas: M }; - leaves: T & { metas: M }; + joins: T & { metas: M }; + leaves: T & { metas: M }; }; diff --git a/src/usePresence/usePresence.tsx b/src/usePresence/usePresence.tsx index 2bb4ccd..d9135e0 100644 --- a/src/usePresence/usePresence.tsx +++ b/src/usePresence/usePresence.tsx @@ -5,55 +5,55 @@ import type { Metas, PresenceState } from './types'; import { Merge } from '../util'; export function usePresence( - topic: string | undefined + topic: string | undefined ): Merge[] { - const [_presence, _setPresence] = useState>({}); - const { socket } = usePhoenix(); - - useEffect(() => { - if (socket && topic) { - const channel = socket.channel(topic, {}); - - channel.on('presence_state', (newState) => { - _setPresence((prevState) => { - if (Object.keys(prevState).length === 0) return newState; - return Presence.syncState(prevState, newState); - }); - }); - - channel.on('presence_diff', (newDiff) => { - _setPresence((prevState) => { - if (Object.keys(prevState).length === 0) return prevState; - return Presence.syncDiff(prevState, newDiff); - }); - }); - - channel.join(); - - return () => { - channel.leave(); - _setPresence({}); - }; - } - - return () => {}; - }, [socket, _setPresence, topic]); - - const items = useMemo( - () => - _presence - ? Object.keys(_presence).map((key: string) => { - let metas = _presence[key].metas; - - if (Array.isArray(metas) && metas.length === 1) { - metas = metas[0]; - } - - return { id: key, ..._presence[key], metas }; - }) - : [], - [_presence] - ) as Merge[]; - - return items; + const [_presence, _setPresence] = useState>({}); + const { socket } = usePhoenix(); + + useEffect(() => { + if (socket && topic) { + const channel = socket.channel(topic, {}); + + channel.on('presence_state', (newState) => { + _setPresence((prevState) => { + if (Object.keys(prevState).length === 0) return newState; + return Presence.syncState(prevState, newState); + }); + }); + + channel.on('presence_diff', (newDiff) => { + _setPresence((prevState) => { + if (Object.keys(prevState).length === 0) return prevState; + return Presence.syncDiff(prevState, newDiff); + }); + }); + + channel.join(); + + return () => { + channel.leave(); + _setPresence({}); + }; + } + + return () => {}; + }, [socket, _setPresence, topic]); + + const items = useMemo( + () => + _presence + ? Object.keys(_presence).map((key: string) => { + let metas = _presence[key].metas; + + if (Array.isArray(metas) && metas.length === 1) { + metas = metas[0]; + } + + return { id: key, ..._presence[key], metas }; + }) + : [], + [_presence] + ) as Merge[]; + + return items; } diff --git a/src/util.ts b/src/util.ts index 05e8a8c..ac26b98 100644 --- a/src/util.ts +++ b/src/util.ts @@ -3,37 +3,37 @@ import { PhoenixSocket } from './usePhoenix'; import { ChannelMeta } from './useChannel'; export const findChannel = (socket: PhoenixSocket | null, topic: string): Channel | undefined => { - if (typeof topic !== 'string') return undefined; - return socket?.channels.find((channel) => channel.topic === topic); + if (typeof topic !== 'string') return undefined; + return socket?.channels.find((channel) => channel.topic === topic); }; export const createMeta = ( - isSuccess: ChannelMeta['isSuccess'], - isLoading: ChannelMeta['isLoading'], - isError: ChannelMeta['isError'], - error: ChannelMeta['error'], - data: ChannelMeta['data'], - status: ChannelMeta['status'] + isSuccess: ChannelMeta['isSuccess'], + isLoading: ChannelMeta['isLoading'], + isError: ChannelMeta['isError'], + error: ChannelMeta['error'], + data: ChannelMeta['data'], + status: ChannelMeta['status'] ): ChannelMeta => ({ - isSuccess, - isLoading, - isError, - error, - data, - status + isSuccess, + isLoading, + isError, + error, + data, + status }); export const pushPromise = (push: Push): Promise => - new Promise((resolve, reject) => { - push.receive('ok', resolve).receive('error', reject); - }); + new Promise((resolve, reject) => { + push.receive('ok', resolve).receive('error', reject); + }); export type Merge = { - [K in keyof A | keyof B]: K extends keyof A & keyof B - ? A[K] | B[K] - : K extends keyof B - ? B[K] - : K extends keyof A - ? A[K] - : never; + [K in keyof A | keyof B]: K extends keyof A & keyof B + ? A[K] | B[K] + : K extends keyof B + ? B[K] + : K extends keyof A + ? A[K] + : never; }; From b595960f7314cff6f099d1c9a630cbd034e17307 Mon Sep 17 00:00:00 2001 From: Jaden Date: Thu, 15 Feb 2024 14:18:11 -0800 Subject: [PATCH 8/9] update changelog --- CHANGELOG.md | 15 +++++++++++++++ package.json | 4 +++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6427ccb..08e9624 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,18 @@ +# 0.0.1-alpha.8 + +2024-2-13 + +### Breaking changes + +- Completely remove calling `useEvent` with a `string` channel topic. + - The benefit was reusing existing channels but we now do it by default and usually you want access to important channel functions like `push` and `leave` which you simply did not get if you used a channel `string`. + +### Additional changes + +- Calling `useChannel` on the same channel topic across any number of components should just work, and keep all components connected and listening. Additionally, the state object should be consistent across all `useChannel` topics across components. + +- expose an `isConnected` boolean inside `usePhoenix` to know when the socket has officially connected. This is useful for example, in cases when you want to request data with push right when the socket connects, and you dont want to specify the socket itself as a dependency to the useEffect since it would trigger the useEffect many times. + # 0.0.1-alpha.7 2023-12-27 diff --git a/package.json b/package.json index 6c9afb5..ff23ce0 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,9 @@ "name": "use-phoenix", "version": "0.0.1-alpha.7", "description": "React hooks for the Phoenix Framework", - "main": "src/index.js", + "main": "dist/index.js", + "module": "dist/index.esm.js", + "types": "dist/index.d.ts", "files": [ "dist" ], From 66bf20e4c7f06c9dd02438d1bf0123aa3af46378 Mon Sep 17 00:00:00 2001 From: Jaden Date: Thu, 15 Feb 2024 14:27:49 -0800 Subject: [PATCH 9/9] update version --- CHANGELOG.md | 8 +++++--- package.json | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 08e9624..ddc81bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,13 @@ -# 0.0.1-alpha.8 +# 0.0.1 -2024-2-13 +2024-2-15 + +I have been using this version enough in a few complex projects and has been performing very well. I'd like to move away from alpha version and just iterate versions normally. I feel the API is pretty stable at this point and I am not anticipating much more deviation except for `usePresence`. The `usePresence` hook will need to be looked at in more depth to understand the most common access patterns and provide an API that applies to the most people. Currently it deviates quite a bit from the vanilla SDK in terms of the outputted data. ### Breaking changes - Completely remove calling `useEvent` with a `string` channel topic. - - The benefit was reusing existing channels but we now do it by default and usually you want access to important channel functions like `push` and `leave` which you simply did not get if you used a channel `string`. + - The benefit was reusing existing channels but `useChannel` now does it inherently by default. Additionally you usually want access to important channel metadata and functions like `push` and `leave` which you simply did not get if you used a channel `string`. It is possible I reintroduce it in the future but it adds some more complexity and was not working consistently. ### Additional changes diff --git a/package.json b/package.json index ff23ce0..5457cee 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "use-phoenix", - "version": "0.0.1-alpha.7", + "version": "0.0.1", "description": "React hooks for the Phoenix Framework", "main": "dist/index.js", "module": "dist/index.esm.js",