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 new file mode 100644 index 0000000..ef8e9b5 --- /dev/null +++ b/.prettierrc.cjs @@ -0,0 +1,13 @@ +/** + * {@type require('prettier').Config} + */ +module.exports = { + useTabs: false, + printWidth: 100, + singleQuote: true, + trailingComma: 'none', + bracketSameLine: false, + semi: true, + tabWidth: 2, + quoteProps: 'consistent' +}; diff --git a/.prettierrc.js b/.prettierrc.js deleted file mode 100644 index ad083de..0000000 --- a/.prettierrc.js +++ /dev/null @@ -1,13 +0,0 @@ -/** - * {@type require('prettier').Config} - */ -module.exports = { - useTabs: true, - printWidth: 100, - singleQuote: true, - trailingComma: 'none', - bracketSameLine: false, - semi: true, - tabWidth: 2, - quoteProps: 'consistent' -}; diff --git a/CHANGELOG.md b/CHANGELOG.md index b542177..16e0528 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,19 @@ +# 0.0.1 + +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 `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 + +- 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.8 2023-1-05 @@ -13,27 +29,34 @@ None 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; @@ -45,12 +68,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 54dfb1d..671d1bb 100644 --- a/README.md +++ b/README.md @@ -1,32 +1,37 @@ # 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 import { PhoenixProvider } from 'use-phoenix'; const Application = () => { - return ...; + return ...; }; ``` 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 ...; @@ -39,17 +44,20 @@ 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]); }; ``` -## 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,18 +127,15 @@ 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) => { - console.log(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,16 +209,14 @@ 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'); useEffect(() => { - return () => { - leave(); - }; + return () => { + leave(); + }; }, [leave]); ``` diff --git a/package.json b/package.json index 359bc16..5457cee 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "use-phoenix", - "version": "0.0.1-alpha.8", + "version": "0.0.1", "description": "React hooks for the Phoenix Framework", "main": "dist/index.js", "module": "dist/index.esm.js", 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 c87760a..7f5d714 100644 --- a/src/PhoenixProvider.tsx +++ b/src/PhoenixProvider.tsx @@ -5,64 +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 socketRef = useLatest(socket); + const [socket, set] = useState(null); + const [isConnected, setConnected] = useState(false); - const defaultListeners = useCallback( - (socket: PhoenixSocket) => { - if (onOpen) socket.onOpen(onOpen); - if (onClose) socket.onClose(onClose); - if (onError) socket.onError(onError); - }, - [onClose, onError, onOpen] - ); + const socketRef = useLatest(socket); - const connect = useCallback( - (url: string, options?: Partial): PhoenixSocket => { - const socket = new Socket(url, options ?? {}) as PhoenixSocket; - socket.connect(); - set(socket); + const defaultListeners = useCallback( + (socket: PhoenixSocket) => { + if (onOpen) socket.onOpen(onOpen); + if (onClose) socket.onClose(onClose); + if (onError) socket.onError(onError); - defaultListeners(socket); + socket.onOpen(() => { + setConnected(true); + }); - return socket; - }, - [defaultListeners] - ); + socket.onClose(() => { + setConnected(false); + }); + }, + [onClose, onError, onOpen] + ); - useEffect(() => { - if (!url) return; + const connect = useCallback( + (url: string, options?: Partial): PhoenixSocket => { + const socket = new Socket(url, options ?? {}) as PhoenixSocket; + socket.connect(); + set(socket); + defaultListeners(socket); - const socket = connect(url, options); + return socket; + }, + [defaultListeners] + ); - return () => { - socket.disconnect(); - }; - }, [url, options, connect]); + useEffect(() => { + if (!url) return; - return ( - - {children} - - ); + const socket = connect(url, options); + + return () => { + socket.disconnect(); + }; + }, [url, options, connect]); + + 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 new file mode 100644 index 0000000..550c56f --- /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..6d045de 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,3 +4,6 @@ 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/types.ts b/src/useChannel/types.ts index 253562c..3763ad2 100644 --- a/src/useChannel/types.ts +++ b/src/useChannel/types.ts @@ -1,35 +1,42 @@ +import { Merge } from '../util'; + export type { Push } from 'phoenix'; export { Channel } from 'phoenix'; +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'; - -export type ChannelMeta = { - data: TJoinResponse | null; - status: ChannelStatus; - isSuccess: boolean; - isLoading: boolean; - isError: boolean; - error: any; + | '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; }; -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/useChannel/useChannel.ts b/src/useChannel/useChannel.ts index 5a4d587..99ec5a7 100644 --- a/src/useChannel/useChannel.ts +++ b/src/useChannel/useChannel.ts @@ -1,124 +1,155 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import useLatest from '../useLatest'; import { usePhoenix } from '../usePhoenix'; -import type { Channel, ChannelMeta, ChannelOptions, ChannelParams, Push, PushFunction } from './types'; +import type { + Channel, + ChannelMeta, + ChannelOptions, + ChannelParams, + ChannelState, + PushFunction +} from './types'; import { Channel as ChannelClass } from 'phoenix'; -import { findChannel } from '../util'; -import { Merge } from '../usePresence'; - -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 - }); - - const channelRef = useLatest(channel); - - const { params } = options || {}; - - useEffect(() => { - if (socket === null) return; - if (typeof topic !== 'string') return; - - 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`. */ - set(existingChannel); - return; - } - - const channel = socket.channel(topic, params); - - channel - .join() - .receive('ok', (response: TJoinResponse) => { - setMeta({ - isSuccess: true, - isLoading: false, - isError: false, - error: null, - data: response, - status: 'success' - }); - }) - .receive('error', (error) => { - setMeta({ - isSuccess: false, - isLoading: false, - isError: true, - error, - data: null, - status: 'error' - }); - }) - .receive('timeout', () => { - setMeta({ - isSuccess: false, - isLoading: false, - isError: true, - error: null, - status: 'connection timeout', - data: null - }); - }); - - channel.on('phx_error', () => { - setMeta({ - isSuccess: false, - isLoading: false, - isError: true, - error: null, - status: 'internal server error', - data: null - }); - - /** - * If the channel is in an error state, we want to leave the channel. - * So we do not attempt to rejoin infinitely. - */ - channel.leave(); - }); - - set(channel); - }, [socket, topic, params, setMeta]); - - const push: PushFunction = useCallback((event, payload) => - pushPromise(channelRef.current?.push(event, payload ?? {})), [channelRef]); - - /* - * Allows you to leave the channel. - * useChannel does not automatically leave the channel when the component unmounts by default. - * - */ - const leave = useCallback(() => { - if (channelRef?.current instanceof ChannelClass) { - channelRef?.current.leave(); - set(null); - } - }, [channelRef]); - - return [channelRef.current, { leave, push, ...meta }]; +import { createMeta, findChannel, pushPromise } from '../util'; +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, + params?: ChannelOptions +): [Channel | undefined, ChannelState] { + 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) + ); + + const paramsRef = useLatest(params); + + useEffect(() => { + 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 reconect our internal reference. */ + set(existingChannel); + channelRef.current = 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); + 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. 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) { + channelRef.current.leave(); + set(undefined); + } + }, []); + + return [channel, { ...meta, push, leave }]; } - -const pushPromise = (push: Push | undefined): 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/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/useEvent/useEvent.ts b/src/useEvent/useEvent.ts index da8876f..b4b8fad 100644 --- a/src/useEvent/useEvent.ts +++ b/src/useEvent/useEvent.ts @@ -1,97 +1,56 @@ -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 - * ```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. + * You may obtain the event data from the `data` property and/or the `listener` callback. + * + * @example * ```ts - * const channel = useChannel('room:lobby'); - * useEvent(channel, 'new_message', handleMessage); + * type NewMessageEvent = { + * event: 'new_message'; + * data: { message: string }; + * }; + * + * const [channel, state] = useChannel('room:1'); + * const { data } = 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. + * + * @returns The data from the event. */ export function useEvent( - identifier: Channel | string | 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 { socket } = usePhoenix(); - const handler = useLatest(listener); - const [channel, set] = useState(findChannel(socket, event)); - - const channelRef = useLatest(channel); - - const [data, setData] = useState(null); - - const upsert = useCallback( - (topic: string): Channel | undefined => { - if (socket) { - let channel = findChannel(socket, topic); - if (channel) return channel; - - channel = socket.channel(topic, {}); - channel.join(); - return channel; - } - - return undefined; - }, - [socket] - ); + const handler = useLatest(listener); - 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 (!channelRef.current) return; + useEffect(() => { + if (!channel) return; + if (typeof event !== 'string') return; - const ref = channelRef.current.on(event, (message) => { - setData(message); + const ref = channel.on(event, (message) => { + if (typeof handler.current === 'function') { + handler.current(message); + } - if (typeof handler.current === 'function') { - handler.current(message); - } - }); + setData(message); + }); - return () => { - channelRef.current?.off(event, ref); - set(undefined); - }; - }, [channel, event, handler]); + return () => { + channel.off(event, ref); + }; + }, [channel, event, handler]); - return { data }; + return { data }; } 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 00f4637..be2a462 100644 --- a/src/usePhoenix/usePhoenix.ts +++ b/src/usePhoenix/usePhoenix.ts @@ -2,15 +2,13 @@ import React from 'react'; import { ConnectFunction, PhoenixSocket } from './types'; export const PhoenixContext = React.createContext<{ - socket: PhoenixSocket | null; - connect: ConnectFunction; + socket: PhoenixSocket | null; + connect: ConnectFunction; + isConnected: boolean; } | null>(null); -export const usePhoenix = (): { - socket: PhoenixSocket | null; - connect: ConnectFunction; -} => { - const context = React.useContext(PhoenixContext); - if (context === null) throw new Error('usePhoenix must be used within a PhoenixProvider'); - return context; +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..91607a5 100644 --- a/src/usePresence/types.ts +++ b/src/usePresence/types.ts @@ -1,25 +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 }; -}; - -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; + joins: T & { metas: M }; + leaves: T & { metas: M }; }; diff --git a/src/usePresence/usePresence.tsx b/src/usePresence/usePresence.tsx index 68809eb..d9135e0 100644 --- a/src/usePresence/usePresence.tsx +++ b/src/usePresence/usePresence.tsx @@ -1,58 +1,59 @@ 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 + 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 bfd3879..ac26b98 100644 --- a/src/util.ts +++ b/src/util.ts @@ -1,5 +1,39 @@ +import { Channel, Push } from 'phoenix'; import { PhoenixSocket } from './usePhoenix'; -import type { Channel } from 'phoenix'; +import { ChannelMeta } from './useChannel'; -export const findChannel = (socket: PhoenixSocket | null, topic: string): Channel | undefined => - socket ? socket.channels.find((channel) => channel.topic === topic) : undefined; +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] + : K extends keyof B + ? B[K] + : K extends keyof A + ? A[K] + : never; +};