diff --git a/docs/react.md b/docs/react.md index 2a10480638..0eb0223b32 100644 --- a/docs/react.md +++ b/docs/react.md @@ -139,6 +139,29 @@ const { channel } = useChannel({ channelName: "your-channel-name", options: { .. }); ``` +[Subscription filters](https://ably.com/docs/channels#filter-subscribe) are also supported: + +```javascript +const deriveOptions = { filter: 'headers.email == `"rob.pike@domain.com"` || headers.company == `"domain"`' } +const { channel } = useChannel({ channelName: "your-derived-channel-name", options: { ... }, deriveOptions }, (message) => { + ... +}); +``` + +Please note that attempts to publish to a derived channel (the one created or retrieved with a filter expression) will fail. In order to send messages to the channel called _"your-derived-channel-name"_ from the example above, you will need to create another channel instance without a filter expression. + +```javascript +const channelName = "your-derived-channel-name"; +const options = { ... }; +const deriveOptions = { filter: 'headers.email == `"rob.pike@domain.com"` || headers.company == `"domain"`' } +const callback = (message) => { ... }; + +const { channel: readOnlyChannelInstance } = useChannel({ channelName, options, deriveOptions }, callback); +const { channel: readWriteChannelInstance } = useChannel({ channelName, options }, callback); // NB! No 'deriveOptions' passed here + +readWriteChannelInstance.publish("test-message", { text: "message text" }); +``` + --- ### usePresence diff --git a/package-lock.json b/package-lock.json index d88155956b..4183a572d5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "@ably/vcdiff-decoder": "1.0.4", "@testing-library/react": "^13.3.0", "@types/crypto-js": "^4.0.1", + "@types/jmespath": "^0.15.2", "@types/node": "^15.0.0", "@types/request": "^2.48.7", "@types/ws": "^8.2.0", @@ -46,6 +47,7 @@ "grunt-shell": "~1.1", "grunt-webpack": "^4.0.2", "hexy": "~0.2", + "jmespath": "^0.16.0", "jsdom": "^20.0.0", "kexec": "ably-forks/node-kexec#update-for-node-12", "minimist": "^1.2.5", @@ -1250,6 +1252,12 @@ "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.1.tgz", "integrity": "sha512-SZs7ekbP8CN0txVG2xVRH6EgKmEm31BOxA07vkFaETzZz1xh+cbt8BcI0slpymvwhx5dlFnQG2rTlPVQn+iRPQ==" }, + "node_modules/@types/jmespath": { + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/@types/jmespath/-/jmespath-0.15.2.tgz", + "integrity": "sha512-pegh49FtNsC389Flyo9y8AfkVIZn9MMPE9yJrO9svhq6Fks2MwymULWjZqySuxmctd3ZH4/n7Mr98D+1Qo5vGA==", + "dev": true + }, "node_modules/@types/json-schema": { "version": "7.0.9", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.9.tgz", @@ -15146,6 +15154,12 @@ "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.1.tgz", "integrity": "sha512-SZs7ekbP8CN0txVG2xVRH6EgKmEm31BOxA07vkFaETzZz1xh+cbt8BcI0slpymvwhx5dlFnQG2rTlPVQn+iRPQ==" }, + "@types/jmespath": { + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/@types/jmespath/-/jmespath-0.15.2.tgz", + "integrity": "sha512-pegh49FtNsC389Flyo9y8AfkVIZn9MMPE9yJrO9svhq6Fks2MwymULWjZqySuxmctd3ZH4/n7Mr98D+1Qo5vGA==", + "dev": true + }, "@types/json-schema": { "version": "7.0.9", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.9.tgz", diff --git a/package.json b/package.json index accfca59d6..1381f78e41 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "@ably/vcdiff-decoder": "1.0.4", "@testing-library/react": "^13.3.0", "@types/crypto-js": "^4.0.1", + "@types/jmespath": "^0.15.2", "@types/node": "^15.0.0", "@types/request": "^2.48.7", "@types/ws": "^8.2.0", @@ -76,6 +77,7 @@ "grunt-shell": "~1.1", "grunt-webpack": "^4.0.2", "hexy": "~0.2", + "jmespath": "^0.16.0", "jsdom": "^20.0.0", "kexec": "ably-forks/node-kexec#update-for-node-12", "minimist": "^1.2.5", diff --git a/src/platform/react-hooks/sample-app/src/App.tsx b/src/platform/react-hooks/sample-app/src/App.tsx index fafebf8d3a..b60121fe5f 100644 --- a/src/platform/react-hooks/sample-app/src/App.tsx +++ b/src/platform/react-hooks/sample-app/src/App.tsx @@ -11,11 +11,36 @@ import './App.css'; function App() { const [messages, updateMessages] = useState([]); + const [derivedChannelMessages, updateDerivedChannelMessages] = useState([]); + const [frontOficeOnlyMessages, updateFrontOfficeOnlyMessages] = useState([]); + const [skip, setSkip] = useState(false); const { channel, ably } = useChannel({ channelName: 'your-channel-name', skip }, (message) => { updateMessages((prev) => [...prev, message]); }); + useChannel( + { + channelName: 'your-derived-channel-name', + deriveOptions: { filter: 'headers.email == `"rob.pike@domain.com"` || headers.company == `"domain"`' }, + }, + (message) => { + updateDerivedChannelMessages((prev) => [...prev, message]); + } + ); + + useChannel( + { + channelName: 'your-derived-channel-name', + deriveOptions: { filter: 'headers.role == `"front-office"` || headers.company == `"domain"`' }, + }, + (message) => { + updateFrontOfficeOnlyMessages((prev) => [...prev, message]); + } + ); + + const { channel: anotherChannelPublisher } = useChannel({ channelName: 'your-derived-channel-name' }); + const { presenceData, updateStatus } = usePresence( { channelName: 'your-channel-name', skip }, { foo: 'bar' }, @@ -42,7 +67,14 @@ function App() { setChannelStateReason(stateChange.reason ?? undefined); }); - const messagePreviews = messages.map((msg, index) =>
  • {msg.data.text}
  • ); + const messagePreviews = messages.map((message, idx) => ); + const derivedChannelMessagePreviews = derivedChannelMessages.map((message, idx) => ( + + )); + const frontOfficeMessagePreviews = frontOficeOnlyMessages.map((message, idx) => ( + + )); + const presentClients = presenceData.map((msg, index) => (
  • {msg.clientId}: {JSON.stringify(msg.data)} @@ -69,6 +101,57 @@ function App() { > Update status to hello + + +
    @@ -94,7 +177,13 @@ function App() {
    {ablyErr}

    Messages

    -
      {messagePreviews}
    + {
      {messagePreviews}
    } + +

    Derived Channel Messages

    +
      {derivedChannelMessagePreviews}
    + +

    Front Office Messages

    +
      {frontOfficeMessagePreviews}

    Present Clients

      {presentClients}
    @@ -103,6 +192,10 @@ function App() { ); } +function MessagePreview({ message }: { message: Types.Message }) { + return
  • {message.data.text}
  • ; +} + function ConnectionState() { const ably = useAbly(); const [connectionState, setConnectionState] = useState(ably.connection.state); diff --git a/src/platform/react-hooks/src/AblyReactHooks.ts b/src/platform/react-hooks/src/AblyReactHooks.ts index 2742f29a7e..0b31381c87 100644 --- a/src/platform/react-hooks/src/AblyReactHooks.ts +++ b/src/platform/react-hooks/src/AblyReactHooks.ts @@ -3,6 +3,7 @@ import { Types } from '../../../../ably.js'; export type ChannelNameAndOptions = { channelName: string; options?: Types.ChannelOptions; + deriveOptions?: Types.DeriveOptions; id?: string; subscribeOnly?: boolean; skip?: boolean; diff --git a/src/platform/react-hooks/src/fakes/ably.ts b/src/platform/react-hooks/src/fakes/ably.ts index a94cf735f1..6c298bfe9e 100644 --- a/src/platform/react-hooks/src/fakes/ably.ts +++ b/src/platform/react-hooks/src/fakes/ably.ts @@ -1,4 +1,5 @@ import { Types } from 'ably'; +import { search } from 'jmespath'; export class FakeAblySdk { public clientId: string; @@ -89,7 +90,7 @@ class Connection extends EventEmitter { export class ClientChannelsCollection { private client: FakeAblySdk; private channels: FakeAblyChannels; - private _channelConnections: Map; + private _channelConnections: Map; constructor(client: FakeAblySdk, channels: FakeAblyChannels) { this.client = client; @@ -107,6 +108,16 @@ export class ClientChannelsCollection { return channelConnection; } } + + public getDerived(name: string, options: Types.DeriveOptions): ClientSingleDerivedChannelConnection { + let channelConnection = this._channelConnections.get(name); + if (channelConnection) return channelConnection as ClientSingleDerivedChannelConnection; + + const channel = this.channels.get(name); + channelConnection = new ClientSingleDerivedChannelConnection(this.client, channel, options); + this._channelConnections.set(name, channelConnection); + return channelConnection; + } } export class ClientSingleChannelConnection extends EventEmitter { @@ -147,6 +158,32 @@ export class ClientSingleChannelConnection extends EventEmitter { } } +export class ClientSingleDerivedChannelConnection extends EventEmitter { + private client: FakeAblySdk; + private channel: Channel; + private deriveOpts: Types.DeriveOptions; + + constructor(client: FakeAblySdk, channel: Channel, deriveOptions?: Types.DeriveOptions) { + super(); + this.client = client; + this.channel = channel; + this.deriveOpts = deriveOptions; + } + + public async subscribe( + eventOrCallback: Types.messageCallback | string | Array, + listener?: Types.messageCallback + ) { + if (typeof eventOrCallback === 'function') eventOrCallback.deriveOptions = this.deriveOpts; + if (typeof listener === 'function') listener.deriveOpts = this.deriveOpts; + this.channel.subscribe(this.client.clientId, eventOrCallback, listener); + } + + public unsubscribe() { + this.channel.subscriptionsPerClient.delete(this.client.clientId); + } +} + export class ClientPresenceConnection { private client: FakeAblySdk; private presence: ChannelPresence; @@ -246,7 +283,11 @@ export class Channel { } for (const subscription of subs) { - subscription(messageEnvelope); + const filter = subscription.deriveOptions?.filter; + if (!filter) return subscription(messageEnvelope); + const headers = messageEnvelope.data?.extras?.headers; + const found = search({ headers }, filter); + if (found) subscription(messageEnvelope); } } diff --git a/src/platform/react-hooks/src/hooks/useChannel.test.tsx b/src/platform/react-hooks/src/hooks/useChannel.test.tsx index b499c05158..4cd2e7e3a1 100644 --- a/src/platform/react-hooks/src/hooks/useChannel.test.tsx +++ b/src/platform/react-hooks/src/hooks/useChannel.test.tsx @@ -134,18 +134,10 @@ describe('useChannel', () => { it('should use the latest version of the message callback', async () => { let callbackCount = 0; - const TestComponent = () => { - const [count, setCount] = React.useState(0); - - useChannel('blah', () => { - callbackCount++; - setCount(count + 1); - }); - - return
    {count}
    ; - }; - - renderInCtxProvider(ablyClient, ); + renderInCtxProvider( + ablyClient, + callbackCount++} /> + ); await act(async () => { ablyClient.channels.get('blah').publish({ text: 'test message 1' }); @@ -176,6 +168,299 @@ describe('useChannel', () => { }); }); +describe('useChannel with deriveOptions', () => { + const Channels = { + tasks: 'tasks', + alerts: 'alerts', + }; + + let channels: FakeAblyChannels; + let ablyClient: FakeAblySdk; + let anotherClient: FakeAblySdk; + let yetAnotherClient: FakeAblySdk; + + beforeEach(() => { + channels = new FakeAblyChannels([Channels.tasks, Channels.alerts]); + ablyClient = new FakeAblySdk().connectTo(channels); + anotherClient = new FakeAblySdk().connectTo(channels); + yetAnotherClient = new FakeAblySdk().connectTo(channels); + }); + + it('component can use "useChannel" with "deriveOptions" and renders nothing by default', async () => { + renderInCtxProvider( + ablyClient, + + ); + const messageUl = screen.getAllByRole('derived-channel-messages')[0]; + + expect(messageUl.childElementCount).toBe(0); + }); + + it('component updates when new message arrives', async () => { + renderInCtxProvider( + ablyClient, + + ); + + act(() => { + anotherClient.channels + .get(Channels.tasks) + .publish({ text: 'A new task for you', extras: { headers: { user: 'robert.pike@domain.io' } } }); + }); + + const messageUl = screen.getAllByRole('derived-channel-messages')[0]; + expect(messageUl.childElementCount).toBe(1); + expect(messageUl.children[0].innerHTML).toBe('A new task for you'); + }); + + it('component will not update if message filtered out', async () => { + renderInCtxProvider( + ablyClient, + + ); + + act(() => { + anotherClient.channels + .get(Channels.tasks) + .publish({ text: 'This one is for another Rob', extras: { headers: { user: 'robert.griesemer@domain.io' } } }); + }); + + const messageUl = screen.getAllByRole('derived-channel-messages')[0]; + expect(messageUl.childElementCount).toBe(0); + }); + + it('component will update with only those messages that qualify', async () => { + renderInCtxProvider( + ablyClient, + + ); + + act(() => { + const channel = anotherClient.channels.get(Channels.tasks); + channel.publish({ + text: 'This one is for another Rob', + extras: { headers: { user: 'robert.griesemer@domain.io' } }, + }); + channel.publish({ + text: 'This one is for the whole domain', + extras: { headers: { company: 'domain' } }, + }); + channel.publish({ + text: 'This one is for Ken', + extras: { headers: { user: 'ken.thompson@domain.io' } }, + }); + channel.publish({ + text: 'This one is also a domain-wide fan-out', + extras: { headers: { company: 'domain' } }, + }); + }); + + const messageUl = screen.getAllByRole('derived-channel-messages')[0]; + expect(messageUl.childElementCount).toBe(2); + expect(messageUl.children[0].innerHTML).toBe('This one is for the whole domain'); + expect(messageUl.children[1].innerHTML).toBe('This one is also a domain-wide fan-out'); + }); + + it('component can use "useChannel" with multiple clients', async () => { + const cliendId = 'client'; + const anotherClientId = 'anotherClient'; + + render( + + + + + + ); + + act(() => { + yetAnotherClient.channels.get(Channels.tasks).publish({ + text: 'A task for Griesemer', + extras: { headers: { user: 'robert.griesemer@domain.io' } }, + }); + yetAnotherClient.channels.get(Channels.alerts).publish({ + text: 'A company-wide alert', + extras: { headers: { company: 'domain' } }, + }); + }); + + const messageUl = screen.getAllByRole('derived-channel-messages')[0]; + + expect(messageUl.childElementCount).toBe(2); + expect(messageUl.children[0].innerHTML).toBe('A task for Griesemer'); + expect(messageUl.children[1].innerHTML).toBe('A company-wide alert'); + }); + + it('handles channel errors', async () => { + const onChannelError = vi.fn(); + const reason = { message: 'channel error occurred' }; + + renderInCtxProvider( + ablyClient, + + ); + + const channelErrorElem = screen.getByRole('channelError'); + expect(onChannelError).toHaveBeenCalledTimes(0); + expect(channelErrorElem.innerHTML).toEqual(''); + + act(() => ablyClient.channels.get(Channels.alerts).emit('failed', { reason })); + + expect(channelErrorElem.innerHTML).toEqual(reason.message); + expect(onChannelError).toHaveBeenCalledTimes(1); + expect(onChannelError).toHaveBeenCalledWith(reason); + }); + + it('handles connection errors', async () => { + const onConnectionError = vi.fn(); + const reason = { message: 'failed to establish connection' }; + + renderInCtxProvider( + ablyClient, + + ); + + const channelErrorElem = screen.getByRole('connectionError'); + expect(onConnectionError).toHaveBeenCalledTimes(0); + expect(channelErrorElem.innerHTML).toEqual(''); + + act(() => ablyClient.connection.emit('failed', { reason })); + + expect(channelErrorElem.innerHTML).toEqual(reason.message); + expect(onConnectionError).toHaveBeenCalledTimes(1); + expect(onConnectionError).toHaveBeenCalledWith(reason); + }); + + it('wildcard filter', async () => { + renderInCtxProvider( + ablyClient, + + ); + + act(() => { + const text = 'Will receive this text due to wildcard filter'; + anotherClient.channels.get(Channels.alerts).publish({ text }); + }); + + const messageUl = screen.getAllByRole('derived-channel-messages')[0]; + expect(messageUl.childElementCount).toBe(1); + }); + + it('skip param', async () => { + renderInCtxProvider( + ablyClient, + + ); + + act(() => { + const text = 'Will skip due to "skip=true"'; + anotherClient.channels.get(Channels.alerts).publish({ text }); + }); + + const messageUl = screen.getAllByRole('derived-channel-messages')[0]; + expect(messageUl.childElementCount).toBe(0); + }); + + it('should use the latest version of the message callback', async () => { + let callbackCount = 0; + + renderInCtxProvider( + ablyClient, + callbackCount++} + /> + ); + + act(() => { + const channel = anotherClient.channels.get(Channels.tasks); + channel.publish({ + text: 'This one is for another Rob', + extras: { headers: { user: 'robert.griesemer@domain.io' } }, + }); + channel.publish({ + text: 'This one is for the whole domain', + extras: { headers: { company: 'domain' } }, + }); + channel.publish({ + text: 'This one is for Ken', + extras: { headers: { user: 'ken.thompson@domain.io' } }, + }); + channel.publish({ + text: 'This one is also a domain-wide fan-out', + extras: { headers: { company: 'domain' } }, + }); + channel.publish({ + text: 'This one for Mr.Pike will also get through...', + extras: { headers: { user: 'robert.pike@domain.io' } }, + }); + channel.publish({ + text: '.... as well as this message', + extras: { headers: { user: 'robert.pike@domain.io' } }, + }); + }); + + expect(callbackCount).toBe(4); + expect(screen.getByRole('counter').innerHTML).toEqual(`${callbackCount}`); + }); + + it('should re-subscribe if event name has changed', async () => { + const channel = ablyClient.channels.get(Channels.alerts); + channel.subscribe = vi.fn(); + channel.unsubscribe = vi.fn(); + + const eventName = 'event1'; + const newEventName = 'event2'; + + renderInCtxProvider( + ablyClient, + + ); + + await waitFor(() => expect(channel.subscribe).toHaveBeenCalledWith(eventName, expect.any(Function))); + + await waitFor(() => expect(channel.unsubscribe).toHaveBeenCalledWith(eventName, expect.any(Function))); + + expect(channel.subscribe).toHaveBeenCalledWith(newEventName, expect.any(Function)); + }); +}); + const UseChannelComponentMultipleClients = () => { const [messages, updateMessages] = useState([]); useChannel({ channelName: 'blah' }, (message) => { @@ -201,13 +486,67 @@ const UseChannelComponent = ({ skip }: { skip?: boolean }) => { return
      {messagePreviews}
    ; }; +interface UseDerivedChannelComponentMultipleClientsProps { + clientId: string; + channelName: string; + anotherClientId: string; + anotherChannelName: string; + deriveOptions: Types.DeriveOptions; +} + +const UseDerivedChannelComponentMultipleClients = ({ + channelName, + clientId, + anotherClientId, + anotherChannelName, + deriveOptions, +}: UseDerivedChannelComponentMultipleClientsProps) => { + const [messages, setMessages] = useState([]); + useChannel({ id: clientId, channelName, deriveOptions }, (message) => { + setMessages((prev) => [...prev, message]); + }); + useChannel({ id: anotherClientId, channelName: anotherChannelName, deriveOptions }, (message) => { + setMessages((prev) => [...prev, message]); + }); + + const messagePreviews = messages.map((msg, index) =>
  • {msg.data.text}
  • ); + + return
      {messagePreviews}
    ; +}; + +interface UseDerivedChannelComponentProps { + channelName: string; + deriveOptions: Types.DeriveOptions; + skip?: boolean; +} + +const UseDerivedChannelComponent = ({ channelName, deriveOptions, skip = false }: UseDerivedChannelComponentProps) => { + const [messages, setMessages] = useState([]); + + useChannel({ channelName, deriveOptions, skip }, (message) => { + setMessages((prev) => [...prev, message]); + }); + + const messagePreviews = messages.map((msg, index) =>
  • {msg.data.text}
  • ); + + return
      {messagePreviews}
    ; +}; + interface UseChannelStateErrorsComponentProps { onConnectionError?: (err: Types.ErrorInfo) => unknown; onChannelError?: (err: Types.ErrorInfo) => unknown; + channelName?: string; + deriveOptions?: Types.DeriveOptions; } -const UseChannelStateErrorsComponent = ({ onConnectionError, onChannelError }: UseChannelStateErrorsComponentProps) => { - const { connectionError, channelError } = useChannel({ channelName: 'blah', onConnectionError, onChannelError }); +const UseChannelStateErrorsComponent = ({ + onConnectionError, + onChannelError, + channelName = 'blah', + deriveOptions, +}: UseChannelStateErrorsComponentProps) => { + const opts = { channelName, deriveOptions, onConnectionError, onChannelError }; + const { connectionError, channelError } = useChannel(opts); return ( <> @@ -217,14 +556,47 @@ const UseChannelStateErrorsComponent = ({ onConnectionError, onChannelError }: U ); }; -const ChangingEventComponent = ({ newEventName }: { newEventName: string }) => { - const [eventName, setEventName] = useState('event1'); +interface LatestMessageCallbackComponentProps { + channelName: string; + deriveOptions?: Types.DeriveOptions; + callback: () => any; +} + +const LatestMessageCallbackComponent = ({ + channelName, + deriveOptions, + callback, +}: LatestMessageCallbackComponentProps) => { + const [count, setCount] = React.useState(0); + + useChannel({ channelName, deriveOptions }, () => { + callback(); + setCount((count) => count + 1); + }); + + return
    {count}
    ; +}; + +interface ChangingEventComponentProps { + newEventName: string; + channelName?: string; + deriveOptions?: Types.DeriveOptions; + eventName?: string; +} + +const ChangingEventComponent = ({ + channelName = 'blah', + eventName = 'event1', + newEventName, + deriveOptions, +}: ChangingEventComponentProps) => { + const [currentEventName, setCurrentEventName] = useState(eventName); - useChannel('blah', eventName, vi.fn()); + useChannel({ channelName, deriveOptions }, currentEventName, vi.fn()); useEffect(() => { const timeoutId = setTimeout(() => { - setEventName(newEventName); + setCurrentEventName(newEventName); }, 50); return () => clearTimeout(timeoutId); diff --git a/src/platform/react-hooks/src/hooks/useChannel.ts b/src/platform/react-hooks/src/hooks/useChannel.ts index d747281a7d..2321f5e3f7 100644 --- a/src/platform/react-hooks/src/hooks/useChannel.ts +++ b/src/platform/react-hooks/src/hooks/useChannel.ts @@ -37,18 +37,23 @@ export function useChannel( const ably = useAbly(channelHookOptions.id); - const { channelName, options: channelOptions, skip } = channelHookOptions; + const { channelName, options: channelOptions, deriveOptions, skip } = channelHookOptions; const channelEvent = typeof eventOrCallback === 'string' ? eventOrCallback : null; const ablyMessageCallback = typeof eventOrCallback === 'string' ? callback : eventOrCallback; + const deriveOptionsRef = useRef(deriveOptions); const channelOptionsRef = useRef(channelOptions); const ablyMessageCallbackRef = useRef(ablyMessageCallback); - const channel = useMemo( - () => ably.channels.get(channelName, channelOptionsWithAgent(channelOptionsRef.current)), - [ably, channelName] - ); + const channel = useMemo(() => { + const derived = deriveOptionsRef.current; + const withAgent = channelOptionsWithAgent(channelOptionsRef.current); + const channel = derived + ? ably.channels.getDerived(channelName, derived, withAgent) + : ably.channels.get(channelName, withAgent); + return channel; + }, [ably, channelName]); const { connectionError, channelError } = useStateErrors(channelHookOptions); @@ -59,6 +64,10 @@ export function useChannel( channelOptionsRef.current = channelOptions; }, [channel, channelOptions]); + useEffect(() => { + deriveOptionsRef.current = deriveOptions; + }, [deriveOptions]); + useEffect(() => { ablyMessageCallbackRef.current = ablyMessageCallback; }, [ablyMessageCallback]);