diff --git a/README.md b/README.md index 24123841..9a2c32fc 100644 --- a/README.md +++ b/README.md @@ -143,19 +143,25 @@ chat.connection.offAllStatusChange(); You can create or retrieve a chat room with name `"basketball-stream"` this way: ```ts -const room = chat.rooms.get('basketball-stream', { reactions: RoomOptionsDefaults.reactions }); +const room = await chat.rooms.get('basketball-stream', { reactions: RoomOptionsDefaults.reactions }); ``` -The second argument to `rooms.get` is a `RoomOptions` argument, which tells the Chat SDK what features you would like your room to use and how they should be configured. For example, you can set the timeout between keystrokes for typing events as part of the room options. Sensible defaults for each -of the features are provided for your convenience: +The second argument to `rooms.get` is a `RoomOptions` argument, which tells the Chat SDK what features you would like your room to use and how they should be configured. -- A typing timeout (time of inactivity before typing stops) of 10 seconds. +For example, you can set the timeout between keystrokes for typing events as part of the room options. Sensible defaults for each of the features are provided for your convenience: + +- A typing timeout (time of inactivity before typing stops) of 5 seconds. - Entry into, and subscription to, presence. The defaults options for each feature may be viewed [here](https://github.com/ably/ably-chat-js/blob/main/src/RoomOptions.ts). In order to use the same room but with different options, you must first `release` the room before requesting an instance with the changed options (see below for more information on releasing rooms). +Note that: + +- If a `release` call is currently in progress for the room (see below), then a call to `get` will wait for that to resolve before resolving itself. +- If a `get` call is currently in progress for the room and `release` is called, the `get` call will reject. + ### Attaching to a room To start receiving events on a room, it must first be attached. This can be done using the `attach` method. diff --git a/demo/src/App.tsx b/demo/src/App.tsx index cb367f44..76aea3f4 100644 --- a/demo/src/App.tsx +++ b/demo/src/App.tsx @@ -1,4 +1,4 @@ -import { FC } from 'react'; +import { FC, useEffect, useState } from 'react'; import { Chat } from './containers/Chat'; import { OccupancyComponent } from './components/OccupancyComponent'; import { UserPresenceComponent } from './components/UserPresenceComponent'; @@ -23,20 +23,49 @@ let roomId: string; interface AppProps {} -const App: FC = () => ( - -
- -
- - +const App: FC = () => { + const [roomIdState, setRoomId] = useState(roomId); + const updateRoomId = (newRoomId: string) => { + const params = new URLSearchParams(window.location.search); + params.set('room', newRoomId); + history.pushState(null, '', '?' + params.toString()); + setRoomId(newRoomId); + }; + + // Add a useEffect that handles the popstate event to update the roomId when + // the user navigates back and forth in the browser history. + useEffect(() => { + const handlePopState = () => { + const params = new URLSearchParams(window.location.search); + const newRoomId = params.get('room') || 'abcd'; + setRoomId(newRoomId); + }; + + window.addEventListener('popstate', handlePopState); + + return () => { + window.removeEventListener('popstate', handlePopState); + }; + }, []); + + return ( + +
+ +
+ + +
-
- -); + + ); +}; export default App; diff --git a/demo/src/containers/Chat/Chat.tsx b/demo/src/containers/Chat/Chat.tsx index 0aecac87..cd31652f 100644 --- a/demo/src/containers/Chat/Chat.tsx +++ b/demo/src/containers/Chat/Chat.tsx @@ -6,7 +6,7 @@ import { ReactionInput } from '../../components/ReactionInput'; import { ConnectionStatusComponent } from '../../components/ConnectionStatusComponent/ConnectionStatusComponent.tsx'; import { ConnectionStatus, Message, MessageEventPayload, MessageEvents, PaginatedResult, Reaction } from '@ably/chat'; -export const Chat = () => { +export const Chat = (props: { roomId: string; setRoomId: (roomId: string) => void }) => { const chatClient = useChatClient(); const clientId = chatClient.clientId; const [messages, setMessages] = useState([]); @@ -15,6 +15,21 @@ export const Chat = () => { const isConnected: boolean = currentStatus === ConnectionStatus.Connected; + const backfillPreviousMessages = (getPreviousMessages: ReturnType['getPreviousMessages']) => { + chatClient.logger.debug('backfilling previous messages'); + if (getPreviousMessages) { + getPreviousMessages({ limit: 50 }) + .then((result: PaginatedResult) => { + chatClient.logger.debug('backfilled messages', result); + setMessages(result.items.filter((m) => !m.isDeleted).reverse()); + setLoading(false); + }) + .catch((error: unknown) => { + chatClient.logger.error('Error fetching initial messages', error); + }); + } + }; + const { send: sendMessage, getPreviousMessages, @@ -42,8 +57,11 @@ export const Chat = () => { // this will trigger a re-fetch of the messages setMessages([]); - // triggers the useEffect to fetch the initial messages again. + // set our state to loading, because we'll need to fetch previous messages again setLoading(true); + + // Do a message backfill + backfillPreviousMessages(getPreviousMessages); }, }); @@ -60,21 +78,9 @@ export const Chat = () => { const messagesEndRef = useRef(null); useEffect(() => { - // try and fetch the messages up to attachment of the messages listener - if (getPreviousMessages && loading) { - getPreviousMessages({ limit: 50 }) - .then((result: PaginatedResult) => { - // reverse the messages so they are displayed in the correct order - // and don't include deleted messages - setMessages(result.items.filter((m) => !m.isDeleted).reverse()); - setLoading(false); - }) - .catch((error: unknown) => { - console.error('Error fetching initial messages', error); - setLoading(false); - }); - } - }, [getPreviousMessages, loading]); + chatClient.logger.debug('updating getPreviousMessages useEffect', { getPreviousMessages }); + backfillPreviousMessages(getPreviousMessages); + }, [getPreviousMessages]); const handleStartTyping = () => { start().catch((error: unknown) => { @@ -124,6 +130,19 @@ export const Chat = () => { window.location.reload(); } + function changeRoomId() { + const newRoomId = prompt('Enter your new roomId'); + if (!newRoomId) { + return; + } + + // Clear the room messages + setMessages([]); + setLoading(true); + setRoomReactions([]); + props.setRoomId(newRoomId); + } + const scrollToBottom = () => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }; @@ -151,6 +170,20 @@ export const Chat = () => { .
+
+ You are in room {props.roomId}.{' '} + + Change roomId + + . +
{loading &&
loading...
} {!loading && (
diff --git a/package.json b/package.json index 7777e5f1..b431bfbe 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "build": "npm run build:chat && npm run build:react", "build:chat": "vite build --config ./src/core/vite.config.ts --emptyOutDir", "build:react": "vite build --config ./src/react/vite.config.ts --emptyOutDir", + "build:start-demo": "npm run build && (cd demo && npm start)", "prepare": "npm run build", "test:typescript": "tsc", "demo:reload": "npm run build && cd demo && npm i file:../", diff --git a/src/core/discontinuity.ts b/src/core/discontinuity.ts index 9fe977aa..ff0a2d6a 100644 --- a/src/core/discontinuity.ts +++ b/src/core/discontinuity.ts @@ -7,10 +7,9 @@ import EventEmitter from './utils/event-emitter.js'; */ export interface HandlesDiscontinuity { /** - * A promise of the channel that this object is associated with. The promise - * is resolved when the feature has finished initializing. + * A channel that this object is associated with. */ - get channel(): Promise; + get channel(): Ably.RealtimeChannel; /** * Called when a discontinuity is detected on the channel. diff --git a/src/core/errors.ts b/src/core/errors.ts index e8e45f26..241e441c 100644 --- a/src/core/errors.ts +++ b/src/core/errors.ts @@ -87,6 +87,11 @@ export enum ErrorCodes { * An unknown error has happened in the room lifecycle. */ RoomLifecycleError = 102105, + + /** + * Room was released before the operation could complete. + */ + RoomReleasedBeforeOperationCompleted = 102106, } /** diff --git a/src/core/id.ts b/src/core/id.ts new file mode 100644 index 00000000..f4394f37 --- /dev/null +++ b/src/core/id.ts @@ -0,0 +1,7 @@ +/** + * Generates a random string that can be used as an identifier, for instance in identifying specific room + * objects. + * + * @returns A random string that can be used as an identifier. + */ +export const randomId = (): string => Math.random().toString(36).slice(2); diff --git a/src/core/messages.ts b/src/core/messages.ts index 05bb55ab..a2ebcb23 100644 --- a/src/core/messages.ts +++ b/src/core/messages.ts @@ -240,9 +240,9 @@ export interface Messages extends EmitsDiscontinuities { /** * Get the underlying Ably realtime channel used for the messages in this chat room. * - * @returns A promise of the realtime channel. + * @returns The realtime channel. */ - get channel(): Promise; + get channel(): Ably.RealtimeChannel; } /** @@ -253,7 +253,7 @@ export class DefaultMessages implements Messages, HandlesDiscontinuity, ContributesToRoomLifecycle { private readonly _roomId: string; - private readonly _channel: Promise; + private readonly _channel: Ably.RealtimeChannel; private readonly _chatApi: ChatApi; private readonly _clientId: string; private readonly _listenerSubscriptionPoints: Map< @@ -272,25 +272,12 @@ export class DefaultMessages * @param chatApi An instance of the ChatApi. * @param clientId The client ID of the user. * @param logger An instance of the Logger. - * @param initAfter A promise that is awaited before creating any channels. - */ - constructor( - roomId: string, - realtime: Ably.Realtime, - chatApi: ChatApi, - clientId: string, - logger: Logger, - initAfter: Promise, - ) { + */ + constructor(roomId: string, realtime: Ably.Realtime, chatApi: ChatApi, clientId: string, logger: Logger) { super(); this._roomId = roomId; - this._channel = initAfter.then(() => this._makeChannel(roomId, realtime)); - - // Catch this so it won't send unhandledrejection global event - this._channel.catch((error: unknown) => { - logger.debug('Messages: channel initialization canceled', { roomId, error }); - }); + this._channel = this._makeChannel(roomId, realtime); this._chatApi = chatApi; this._clientId = clientId; @@ -299,7 +286,7 @@ export class DefaultMessages } /** - * Creates the realtime channel for messages. Called after initAfter is resolved. + * Creates the realtime channel for messages. */ private _makeChannel(roomId: string, realtime: Ably.Realtime): Ably.RealtimeChannel { const channel = getChannel(messagesChannelName(roomId), realtime); @@ -398,7 +385,7 @@ export class DefaultMessages private async _resolveSubscriptionStart(): Promise<{ fromSerial: string; }> { - const channelWithProperties = await this._getChannelProperties(); + const channelWithProperties = this._getChannelProperties(); // If we are attached, we can resolve with the channelSerial if (channelWithProperties.state === 'attached') { @@ -412,14 +399,11 @@ export class DefaultMessages return this._subscribeAtChannelAttach(); } - private async _getChannelProperties(): Promise< - Ably.RealtimeChannel & { - properties: { attachSerial: string | undefined; channelSerial: string | undefined }; - } - > { + private _getChannelProperties(): Ably.RealtimeChannel & { + properties: { attachSerial: string | undefined; channelSerial: string | undefined }; + } { // Get the attachSerial from the channel properties - const channel = await this._channel; - return channel as Ably.RealtimeChannel & { + return this._channel as Ably.RealtimeChannel & { properties: { attachSerial: string | undefined; channelSerial: string | undefined; @@ -428,7 +412,7 @@ export class DefaultMessages } private async _subscribeAtChannelAttach(): Promise<{ fromSerial: string }> { - const channelWithProperties = await this._getChannelProperties(); + const channelWithProperties = this._getChannelProperties(); return new Promise((resolve, reject) => { // Check if the state is now attached if (channelWithProperties.state === 'attached') { @@ -468,7 +452,7 @@ export class DefaultMessages /** * @inheritdoc Messages */ - get channel(): Promise { + get channel(): Ably.RealtimeChannel { return this._channel; } diff --git a/src/core/occupancy.ts b/src/core/occupancy.ts index 7b56c210..d533678c 100644 --- a/src/core/occupancy.ts +++ b/src/core/occupancy.ts @@ -46,9 +46,9 @@ export interface Occupancy extends EmitsDiscontinuities { /** * Get underlying Ably channel for occupancy events. * - * @returns A promise of the underlying Ably channel for occupancy events. + * @returns The underlying Ably channel for occupancy events. */ - get channel(): Promise; + get channel(): Ably.RealtimeChannel; } /** @@ -98,7 +98,7 @@ export class DefaultOccupancy implements Occupancy, HandlesDiscontinuity, ContributesToRoomLifecycle { private readonly _roomId: string; - private readonly _channel: Promise; + private readonly _channel: Ably.RealtimeChannel; private readonly _chatApi: ChatApi; private _logger: Logger; private _discontinuityEmitter: DiscontinuityEmitter = newDiscontinuityEmitter(); @@ -109,25 +109,18 @@ export class DefaultOccupancy * @param realtime An instance of the Ably Realtime client. * @param chatApi An instance of the ChatApi. * @param logger An instance of the Logger. - * @param initAfter A promise that is awaited before creating any channels. */ - constructor(roomId: string, realtime: Ably.Realtime, chatApi: ChatApi, logger: Logger, initAfter: Promise) { + constructor(roomId: string, realtime: Ably.Realtime, chatApi: ChatApi, logger: Logger) { super(); - this._roomId = roomId; - - this._channel = initAfter.then(() => this._makeChannel(roomId, realtime)); - - // Catch this so it won't send unhandledrejection global event - this._channel.catch((error: unknown) => { - logger.debug('Occupancy: channel initialization canceled', { roomId, error }); - }); + this._roomId = roomId; + this._channel = this._makeChannel(roomId, realtime); this._chatApi = chatApi; this._logger = logger; } /** - * Creates the realtime channel for occupancy. Called after initAfter is resolved. + * Creates the realtime channel for occupancy. */ private _makeChannel(roomId: string, realtime: Ably.Realtime): Ably.RealtimeChannel { const channel = getChannel(messagesChannelName(roomId), realtime, { params: { occupancy: 'metrics' } }); @@ -173,7 +166,7 @@ export class DefaultOccupancy /** * @inheritdoc Occupancy */ - get channel(): Promise { + get channel(): Ably.RealtimeChannel { return this._channel; } diff --git a/src/core/presence.ts b/src/core/presence.ts index 1d471dca..512554b3 100644 --- a/src/core/presence.ts +++ b/src/core/presence.ts @@ -178,9 +178,9 @@ export interface Presence extends EmitsDiscontinuities { /** * Get the underlying Ably realtime channel used for presence in this chat room. - * @returns A promise of the realtime channel. + * @returns The realtime channel. */ - get channel(): Promise; + get channel(): Ably.RealtimeChannel; } /** @@ -190,7 +190,7 @@ export class DefaultPresence extends EventEmitter implements Presence, HandlesDiscontinuity, ContributesToRoomLifecycle { - private readonly _channel: Promise; + private readonly _channel: Ably.RealtimeChannel; private readonly _clientId: string; private readonly _logger: Logger; private readonly _discontinuityEmitter: DiscontinuityEmitter = newDiscontinuityEmitter(); @@ -203,31 +203,17 @@ export class DefaultPresence * @param clientId The client ID, attached to presences messages as an identifier of the sender. * A channel can have multiple connections using the same clientId. * @param logger An instance of the Logger. - * @param initAfter A promise that is awaited before creating any channels. - */ - constructor( - roomId: string, - roomOptions: RoomOptions, - realtime: Ably.Realtime, - clientId: string, - logger: Logger, - initAfter: Promise, - ) { + */ + constructor(roomId: string, roomOptions: RoomOptions, realtime: Ably.Realtime, clientId: string, logger: Logger) { super(); - this._channel = initAfter.then(() => this._makeChannel(roomId, roomOptions, realtime)); - - // Catch this so it won't send unhandledrejection global event - this._channel.catch((error: unknown) => { - logger.debug('Presence: channel initialization canceled', { roomId, error }); - }); - + this._channel = this._makeChannel(roomId, roomOptions, realtime); this._clientId = clientId; this._logger = logger; } /** - * Creates the realtime channel for presence. Called after initAfter is resolved. + * Creates the realtime channel for presence. */ private _makeChannel(roomId: string, roomOptions: RoomOptions, realtime: Ably.Realtime): Ably.RealtimeChannel { // Set our channel modes based on the room options @@ -254,7 +240,7 @@ export class DefaultPresence * Get the underlying Ably realtime channel used for presence in this chat room. * @returns The realtime channel. */ - get channel(): Promise { + get channel(): Ably.RealtimeChannel { return this._channel; } @@ -263,8 +249,7 @@ export class DefaultPresence */ async get(params?: Ably.RealtimePresenceParams): Promise { this._logger.trace('Presence.get()', { params }); - const channel = await this._channel; - const userOnPresence = await channel.presence.get(params); + const userOnPresence = await this._channel.presence.get(params); // ably-js never emits the 'absent' event, so we can safely ignore it here. return userOnPresence.map((user) => ({ @@ -282,8 +267,7 @@ export class DefaultPresence * @inheritDoc */ async isUserPresent(clientId: string): Promise { - const channel = await this._channel; - const presenceSet = await channel.presence.get({ clientId: clientId }); + const presenceSet = await this._channel.presence.get({ clientId: clientId }); return presenceSet.length > 0; } @@ -297,8 +281,7 @@ export class DefaultPresence const presenceEventToSend: AblyPresenceData = { userCustomData: data, }; - const channel = await this._channel; - return channel.presence.enterClient(this._clientId, presenceEventToSend); + return this._channel.presence.enterClient(this._clientId, presenceEventToSend); } /** @@ -311,8 +294,7 @@ export class DefaultPresence const presenceEventToSend: AblyPresenceData = { userCustomData: data, }; - const channel = await this._channel; - return channel.presence.updateClient(this._clientId, presenceEventToSend); + return this._channel.presence.updateClient(this._clientId, presenceEventToSend); } /** @@ -325,8 +307,7 @@ export class DefaultPresence const presenceEventToSend: AblyPresenceData = { userCustomData: data, }; - const channel = await this._channel; - return channel.presence.leaveClient(this._clientId, presenceEventToSend); + return this._channel.presence.leaveClient(this._clientId, presenceEventToSend); } /** diff --git a/src/core/room-lifecycle-manager.ts b/src/core/room-lifecycle-manager.ts index 15280068..29c04753 100644 --- a/src/core/room-lifecycle-manager.ts +++ b/src/core/room-lifecycle-manager.ts @@ -11,10 +11,9 @@ import { DefaultRoomLifecycle, NewRoomStatus, RoomStatus, RoomStatusChange } fro */ export interface ContributesToRoomLifecycle extends HandlesDiscontinuity { /** - * Gets the channel on which the feature operates. This promise is never - * rejected except in the case where room initialization is canceled. + * Gets the channel on which the feature operates. */ - get channel(): Promise; + get channel(): Ably.RealtimeChannel; /** * Gets the ErrorInfo code that should be used when the feature fails to attach. @@ -29,18 +28,6 @@ export interface ContributesToRoomLifecycle extends HandlesDiscontinuity { get detachmentErrorCode(): ErrorCodes; } -/** - * This interface represents a feature that contributes to the room lifecycle and - * exposes its channel directly. Objects of this type are created by awaiting the - * channel promises of all the {@link ContributesToRoomLifecycle} objects. - * - * @internal - */ -interface ResolvedContributor { - channel: Ably.RealtimeChannel; - contributor: ContributesToRoomLifecycle; -} - /** * The order of precedence for lifecycle operations, passed to the mutex which allows * us to ensure that internal operations take precedence over user-driven operations. @@ -54,13 +41,13 @@ enum LifecycleOperationPrecedence { /** * A map of contributors to pending discontinuity events. */ -type DiscontinuityEventMap = Map; +type DiscontinuityEventMap = Map; /** * An internal interface that represents the result of a room attachment operation. */ type RoomAttachmentResult = NewRoomStatus & { - failedFeature?: ResolvedContributor; + failedFeature?: ContributesToRoomLifecycle; }; /** @@ -76,7 +63,7 @@ export class RoomLifecycleManager { /** * The features that contribute to the room status. */ - private readonly _contributors: ResolvedContributor[]; + private readonly _contributors: ContributesToRoomLifecycle[]; private readonly _logger: Logger; /** @@ -92,7 +79,7 @@ export class RoomLifecycleManager { * If a channel enters the attaching state (as a result of a server initiated detach), we should initially * consider it to be transient and not bother changing the room status. */ - private readonly _transientDetachTimeouts: Map>; + private readonly _transientDetachTimeouts: Map>; /** * This flag indicates whether some sort of controlled operation is in progress (e.g. attaching, detaching, releasing). @@ -115,7 +102,7 @@ export class RoomLifecycleManager { * * Used to control whether we should trigger discontinuity events. */ - private _firstAttachesCompleted = new Map(); + private _firstAttachesCompleted = new Map(); /** * Are we in the process of releasing the room? @@ -124,14 +111,14 @@ export class RoomLifecycleManager { /** * Constructs a new `RoomLifecycleManager` instance. - * @param status The status to update. + * @param lifecycle The room lifecycle that manages status. * @param contributors The features that contribute to the room status. * @param logger An instance of the Logger. * @param transientDetachTimeout The number of milliseconds to consider a detach to be "transient" */ constructor( lifecycle: DefaultRoomLifecycle, - contributors: ResolvedContributor[], + contributors: ContributesToRoomLifecycle[], logger: Logger, transientDetachTimeout: number, ) { @@ -200,7 +187,7 @@ export class RoomLifecycleManager { channel: contributor.channel.name, change, }); - contributor.contributor.discontinuityDetected(change.reason); + contributor.discontinuityDetected(change.reason); }); // We handle all events except update events here @@ -325,7 +312,7 @@ export class RoomLifecycleManager { * @param contributor The contributor that has detached. * @param detachError The error that caused the detachment. */ - private _onChannelSuspension(contributor: ResolvedContributor, detachError?: Ably.ErrorInfo): void { + private _onChannelSuspension(contributor: ContributesToRoomLifecycle, detachError?: Ably.ErrorInfo): void { this._logger.debug('RoomLifecycleManager._onChannelSuspension();', { channel: contributor.channel.name, error: detachError, @@ -372,7 +359,7 @@ export class RoomLifecycleManager { * @param contributor The contributor that has entered a suspended state. * @returns A promise that resolves when the room is attached, or the room enters a failed state. */ - private async _doRetry(contributor: ResolvedContributor): Promise { + private async _doRetry(contributor: ContributesToRoomLifecycle): Promise { // A helper that allows us to retry the attach operation const doAttachWithRetry = () => { this._logger.debug('RoomLifecycleManager.doAttachWithRetry();'); @@ -562,7 +549,7 @@ export class RoomLifecycleManager { // We take the status to be whatever caused the error attachResult.error = new Ably.ErrorInfo( 'failed to attach feature', - feature.contributor.attachmentErrorCode, + feature.attachmentErrorCode, 500, error as Ably.ErrorInfo, ); @@ -606,7 +593,7 @@ export class RoomLifecycleManager { // Iterate the pending discontinuity events and trigger them for (const [contributor, error] of this._pendingDiscontinuityEvents) { - contributor.contributor.discontinuityDetected(error); + contributor.discontinuityDetected(error); } this._pendingDiscontinuityEvents.clear(); @@ -640,7 +627,7 @@ export class RoomLifecycleManager { * @param except The contributor to exclude from the detachment. * @returns A promise that resolves when all channels are detached. */ - private _doChannelWindDown(except?: ResolvedContributor): Promise { + private _doChannelWindDown(except?: ContributesToRoomLifecycle): Promise { return Promise.all( this._contributors.map(async (contributor) => { // If its the contributor we want to wait for a conclusion on, then we should not detach it @@ -680,7 +667,7 @@ export class RoomLifecycleManager { ) { const contributorError = new Ably.ErrorInfo( 'failed to detach feature', - contributor.contributor.detachmentErrorCode, + contributor.detachmentErrorCode, 500, error as Ably.ErrorInfo, ); diff --git a/src/core/room-reactions.ts b/src/core/room-reactions.ts index 10e132ae..f8c4932a 100644 --- a/src/core/room-reactions.ts +++ b/src/core/room-reactions.ts @@ -104,9 +104,9 @@ export interface RoomReactions extends EmitsDiscontinuities { * Returns an instance of the Ably realtime channel used for room-level reactions. * Avoid using this directly unless special features that cannot otherwise be implemented are needed. * - * @returns A promise of the Ably realtime channel. + * @returns The Ably realtime channel. */ - get channel(): Promise; + get channel(): Ably.RealtimeChannel; } interface RoomReactionEventsMap { @@ -135,7 +135,7 @@ export class DefaultRoomReactions extends EventEmitter implements RoomReactions, HandlesDiscontinuity, ContributesToRoomLifecycle { - private readonly _channel: Promise; + private readonly _channel: Ably.RealtimeChannel; private readonly _clientId: string; private readonly _logger: Logger; private readonly _discontinuityEmitter: DiscontinuityEmitter = newDiscontinuityEmitter(); @@ -146,23 +146,17 @@ export class DefaultRoomReactions * @param realtime An instance of the Ably Realtime client. * @param clientId The client ID of the user. * @param logger An instance of the Logger. - * @param initAfter A promise that is awaited before creating any channels. */ - constructor(roomId: string, realtime: Ably.Realtime, clientId: string, logger: Logger, initAfter: Promise) { + constructor(roomId: string, realtime: Ably.Realtime, clientId: string, logger: Logger) { super(); - this._channel = initAfter.then(() => this._makeChannel(roomId, realtime)); - - // Catch this so it won't send unhandledrejection global event - this._channel.catch((error: unknown) => { - logger.debug('RoomReactions: channel initialization canceled', { roomId, error }); - }); + this._channel = this._makeChannel(roomId, realtime); this._clientId = clientId; this._logger = logger; } /** - * Creates the realtime channel for room reactions. Called after initAfter is resolved. + * Creates the realtime channel for room reactions. */ private _makeChannel(roomId: string, realtime: Ably.Realtime): Ably.RealtimeChannel { const channel = getChannel(`${roomId}::$chat::$reactions`, realtime); @@ -199,9 +193,7 @@ export class DefaultRoomReactions }, }; - return this._channel.then((channel) => { - return channel.publish(realtimeMessage); - }); + return this._channel.publish(realtimeMessage); } /** @@ -237,7 +229,7 @@ export class DefaultRoomReactions this.emit(RoomReactionEvents.Reaction, reaction); }; - get channel(): Promise { + get channel(): Ably.RealtimeChannel { return this._channel; } diff --git a/src/core/room-status.ts b/src/core/room-status.ts index a9957f5b..2a59ba8b 100644 --- a/src/core/room-status.ts +++ b/src/core/room-status.ts @@ -8,12 +8,13 @@ import EventEmitter from './utils/event-emitter.js'; */ export enum RoomStatus { /** - * The library is currently initializing the room. + * The library is currently initializing the room. This state is a temporary state used in React prior + * to the room being resolved. */ Initializing = 'initializing', /** - * A temporary state for when the library is first initialized. + * A temporary state for when the room object is first initialized. */ Initialized = 'initialized', @@ -167,19 +168,21 @@ type RoomStatusEventsMap = { * @internal */ export class DefaultRoomLifecycle extends EventEmitter implements InternalRoomLifecycle { - private _status: RoomStatus = RoomStatus.Initializing; + private _status: RoomStatus = RoomStatus.Initialized; private _error?: Ably.ErrorInfo; private readonly _logger: Logger; private readonly _internalEmitter = new EventEmitter(); + private readonly _roomId: string; /** * Constructs a new `DefaultStatus` instance. * @param logger The logger to use. */ - constructor(logger: Logger) { + constructor(roomId: string, logger: Logger) { super(); + this._roomId = roomId; this._logger = logger; - this._status = RoomStatus.Initializing; + this._status = RoomStatus.Initialized; this._error = undefined; } @@ -230,7 +233,7 @@ export class DefaultRoomLifecycle extends EventEmitter impl this._status = change.current; this._error = change.error; - this._logger.info(`Room status changed`, change); + this._logger.info(`room status changed`, { ...change, roomId: this._roomId }); this._internalEmitter.emit(change.current, change); this.emit(change.current, change); } diff --git a/src/core/room.ts b/src/core/room.ts index 4351d86c..a7c2f536 100644 --- a/src/core/room.ts +++ b/src/core/room.ts @@ -2,7 +2,6 @@ import * as Ably from 'ably'; import cloneDeep from 'lodash.clonedeep'; import { ChatApi } from './chat-api.js'; -import { ErrorCodes } from './errors.js'; import { Logger } from './logger.js'; import { DefaultMessages, Messages } from './messages.js'; import { DefaultOccupancy, Occupancy } from './occupancy.js'; @@ -121,52 +120,6 @@ export interface Room { options(): RoomOptions; } -// Returns a promise that always resolves when p is settled, regardless of whether p is resolved or rejected. -function makeAlwaysResolves(p: Promise): Promise { - return new Promise((res) => { - p.then(() => { - res(); - }).catch(() => { - res(); - }); - }); -} - -// Returns a promise that gets resolved when p gets resolved, and rejected when either p is rejected or the returned reject function is called. -function makeRejectablePromise(p: Promise): { promise: Promise; reject: (reason: unknown) => boolean } { - let insideReject: ((reason: unknown) => void) | undefined; - - const reject = (reason: unknown) => { - if (insideReject) { - insideReject(reason); - insideReject = undefined; - return true; - } - return false; - }; - - const promise = new Promise((res, rej) => { - insideReject = rej; - p.then((data) => { - if (!insideReject) { - // Do nothing if already settled - return; - } - insideReject = undefined; - res(data); - }).catch((error: unknown) => { - if (!insideReject) { - // Do nothing if already settled - return; - } - insideReject = undefined; - rej(error as Error); - }); - }); - - return { promise, reject }; -} - export class DefaultRoom implements Room { private readonly _roomId: string; private readonly _options: RoomOptions; @@ -178,206 +131,90 @@ export class DefaultRoom implements Room { private readonly _occupancy?: DefaultOccupancy; private readonly _logger: Logger; private readonly _lifecycle: DefaultRoomLifecycle; - private _lifecycleManager?: RoomLifecycleManager; - private _finalizer: () => Promise; - private readonly _asyncOpsAfter: Promise; + private readonly _lifecycleManager: RoomLifecycleManager; + private readonly _finalizer: () => Promise; + + /** + * A random identifier for the room instance, useful in debugging and logging. + */ + private readonly _nonce: string; /** * Constructs a new Room instance. * * @param roomId The unique identifier of the room. + * @param nonce A random identifier for the room instance, useful in debugging and logging. * @param options The options for the room. * @param realtime An instance of the Ably Realtime client. * @param chatApi An instance of the ChatApi. * @param logger An instance of the Logger. - * @param initAfter The room will wait for this promise to finish before initializing */ constructor( roomId: string, + nonce: string, options: RoomOptions, realtime: Ably.Realtime, chatApi: ChatApi, logger: Logger, - initAfter: Promise, ) { validateRoomOptions(options); - logger.debug('Room();', { roomId, options }); + this._nonce = nonce; + logger.debug('Room();', { roomId, options, nonce: this._nonce }); this._roomId = roomId; this._options = options; this._chatApi = chatApi; this._logger = logger; - this._lifecycle = new DefaultRoomLifecycle(logger); - - // Room initialization: handle the state before features start to be initialized - // We don't need to catch it here as we immediately go into _asyncOpsAfter where we catch it - const rejectablePromise = makeRejectablePromise(makeAlwaysResolves(initAfter)); - const initFeaturesAfter = rejectablePromise.promise; - - this._finalizer = () => { - const rejectedNow = rejectablePromise.reject( - new Ably.ErrorInfo('Room released before initialization started', ErrorCodes.RoomIsReleased, 400), - ); - if (rejectedNow) { - // when this promise is rejected by calling reject(), the room is released - this._lifecycle.setStatus({ status: RoomStatus.Released }); - } - return initAfter; - }; + this._lifecycle = new DefaultRoomLifecycle(roomId, logger); // Setup features - this._messages = new DefaultMessages( - roomId, - realtime, - this._chatApi, - realtime.auth.clientId, - logger, - initFeaturesAfter, - ); + this._messages = new DefaultMessages(roomId, realtime, this._chatApi, realtime.auth.clientId, logger); const features: ContributesToRoomLifecycle[] = [this._messages]; if (options.presence) { this._logger.debug('enabling presence on room', { roomId }); - this._presence = new DefaultPresence( - roomId, - options, - realtime, - realtime.auth.clientId, - logger, - initFeaturesAfter, - ); + this._presence = new DefaultPresence(roomId, options, realtime, realtime.auth.clientId, logger); features.push(this._presence); } if (options.typing) { this._logger.debug('enabling typing on room', { roomId }); - this._typing = new DefaultTyping( - roomId, - options.typing, - realtime, - realtime.auth.clientId, - logger, - initFeaturesAfter, - ); + this._typing = new DefaultTyping(roomId, options.typing, realtime, realtime.auth.clientId, logger); features.push(this._typing); } if (options.reactions) { this._logger.debug('enabling reactions on room', { roomId }); - this._reactions = new DefaultRoomReactions(roomId, realtime, realtime.auth.clientId, logger, initFeaturesAfter); + this._reactions = new DefaultRoomReactions(roomId, realtime, realtime.auth.clientId, logger); features.push(this._reactions); } if (options.occupancy) { this._logger.debug('enabling occupancy on room', { roomId }); - this._occupancy = new DefaultOccupancy(roomId, realtime, this._chatApi, logger, initFeaturesAfter); + this._occupancy = new DefaultOccupancy(roomId, realtime, this._chatApi, logger); features.push(this._occupancy); } - this._asyncOpsAfter = this._setupAsyncRoomInit(features, initFeaturesAfter, realtime); + this._lifecycleManager = new RoomLifecycleManager(this._lifecycle, features.toReversed(), this._logger, 5000); - // Catch errors from asyncOpsAfter to prevent unhandled promise rejection error and print a debug log - // This only prevents the base promise from erroring - calls to attach, detach etc that depend on _asyncOpsAfter - // will need to specify their own error handling. - this._asyncOpsAfter.catch((error: unknown) => { - this._logger.debug('Room initialization was prevented before finishing', { error: error, roomId: roomId }); - }); - } + // Setup a finalization function to clean up resources + let finalized = false; + this._finalizer = async () => { + // Cycle the channels in the feature and release them from the realtime client + if (finalized) { + this._logger.debug('Room.finalizer(); already finalized'); + return; + } - /** - * Runs async room initialization and waits for all features to finish initializing. Handles calls to release() - * at different points in the initialization process. - * - * @param features Array of all enabled room features that are to be initialized with channels. - * @param initFeaturesAfter Initialization of features starts after this promise resolves - * @param realtime The Ably.Realtime instance used by this room - * @returns A promise that is resolved when the room is initialized or rejected if the room is released before initialization finishes. - */ - private _setupAsyncRoomInit( - features: ContributesToRoomLifecycle[], - initFeaturesAfter: Promise, - realtime: Ably.Realtime, - ): Promise { - // Wait for features to finish initializing and then finish initializing - // the room. Set _asyncOpsAfter to the promise that waits for the room to - // finish initializing. This promise is awaited before performing any async - // operations at room level (attach and detach). - const rejectableAsyncOpsAfter = makeRejectablePromise( - initFeaturesAfter.then(() => { - // Features have now started initializing so we can no longer stop the - // initialization process. Features haven't yet finished initializing, - // so if release() is called we first need to wait for initialization to - // finish before releasing. We use a promise to wait for the correct - // release function. - - let setFinalizerFunc: (f: () => Promise) => void; - const finalizerFuncPromise = new Promise<() => Promise>((resolve) => { - setFinalizerFunc = resolve; - }); - this._finalizer = () => { - // Make sure async ops (attach or detach) don't run after calling release() - rejectableAsyncOpsAfter.reject( - new Ably.ErrorInfo('Room released before initialization finished', ErrorCodes.RoomIsReleased, 400), - ); - return finalizerFuncPromise.then((f) => { - return f(); - }); - }; - - // Setup all contributors with resolved channels - interface ContributorWithChannel { - channel: Ably.RealtimeChannel; - contributor: ContributesToRoomLifecycle; - } - const promises = features.map((feature) => { - return feature.channel.then((channel): ContributorWithChannel => { - return { - channel: channel, - contributor: feature, - }; - }); - }); - - // With all features with resolved channels: - // - setup room lifecycle manager - // - mark the room as initialized - // - setup finalizer function - return Promise.all(promises) - .then((contributors) => { - const manager = new RoomLifecycleManager(this._lifecycle, contributors.toReversed(), this._logger, 5000); - this._lifecycleManager = manager; - - let finalized = false; - setFinalizerFunc((): Promise => { - if (finalized) { - return Promise.resolve(); - } - finalized = true; - return manager.release().then(() => { - for (const contributor of contributors) { - realtime.channels.release(contributor.channel.name); - } - }); - }); - this._lifecycle.setStatus({ status: RoomStatus.Initialized }); - }) - .catch((error: unknown) => { - // this should never happen because contributor channel promises - // should only reject when initFeaturesAfter is rejected. We log - // here just in case. - setFinalizerFunc(() => Promise.resolve()); - this._logger.error('Room features initialization failed', { error: error, roomId: this.roomId }); - this._lifecycle.setStatus({ - status: RoomStatus.Failed, - error: new Ably.ErrorInfo('Room features initialization failed.', 40000, 400, error as Error), - }); - throw error; - }); - }), - ); - - return rejectableAsyncOpsAfter.promise; + await this._lifecycleManager.release(); + + for (const feature of features) { + realtime.channels.release(feature.channel.name); + } + + finalized = true; + }; } /** @@ -481,16 +318,16 @@ export class DefaultRoom implements Room { * @inheritdoc Room */ async attach() { - this._logger.trace('Room.attach();'); - return this._asyncOpsAfter.then(() => this._lifecycleManager?.attach()); + this._logger.trace('Room.attach();', { nonce: this._nonce, roomId: this._roomId }); + return this._lifecycleManager.attach(); } /** * @inheritdoc Room */ async detach(): Promise { - this._logger.trace('Room.detach();'); - return this._asyncOpsAfter.then(() => this._lifecycleManager?.detach()); + this._logger.trace('Room.detach();', { nonce: this._nonce, roomId: this._roomId }); + return this._lifecycleManager.detach(); } /** @@ -498,18 +335,17 @@ export class DefaultRoom implements Room { * We guarantee that this does not throw an error. */ release(): Promise { - this._logger.trace('Room.release();'); + this._logger.trace('Room.release();', { nonce: this._nonce, roomId: this._roomId }); return this._finalizer(); } /** - * @internal + * A random identifier for the room instance, useful in debugging and logging. * - * Returns a promise that is resolved when the room is initialized or - * rejected if the room gets released before initialization. + * @returns The nonce. */ - initializationStatus(): Promise { - return this._asyncOpsAfter; + get nonce(): string { + return this._nonce; } /** diff --git a/src/core/rooms.ts b/src/core/rooms.ts index ee75a78f..0695f308 100644 --- a/src/core/rooms.ts +++ b/src/core/rooms.ts @@ -3,6 +3,8 @@ import { dequal } from 'dequal'; import { ChatApi } from './chat-api.js'; import { ClientOptions, NormalizedClientOptions } from './config.js'; +import { ErrorCodes } from './errors.js'; +import { randomId } from './id.js'; import { Logger } from './logger.js'; import { DefaultRoom, Room } from './room.js'; import { RoomOptions } from './room-options.js'; @@ -18,12 +20,18 @@ export interface Rooms { * * Always call `release(roomId)` after the Room object is no longer needed. * + * If a call to `get` is made for a room that is currently being released, then the promise will resolve only when + * the release operation is complete. + * + * If a call to `get` is made, followed by a subsequent call to `release` before the promise resolves, then the + * promise will reject with an error. + * * @param roomId The ID of the room. * @param options The options for the room. * @throws {@link ErrorInfo} if a room with the same ID but different options already exists. - * @returns Room A new or existing Room object. + * @returns Room A promise to a new or existing Room object. */ - get(roomId: string, options: RoomOptions): Room; + get(roomId: string, options: RoomOptions): Promise; /** * Release the Room object if it exists. This method only releases the reference @@ -33,6 +41,8 @@ export interface Rooms { * After calling this function, the room object is no-longer usable. If you wish to get the room object again, * you must call {@link Rooms.get}. * + * Calling this function will abort any in-progress `get` calls for the same room. + * * @param roomId The ID of the room. */ release(roomId: string): Promise; @@ -44,6 +54,31 @@ export interface Rooms { get clientOptions(): ClientOptions; } +/** + * Represents an entry in the chat room map. + */ +interface RoomMapEntry { + /** + * The promise that will eventually resolve to the room. + */ + promise: Promise; + + /** + * A random, internal identifier useful for debugging and logging. + */ + nonce: string; + + /** + * The options for the room. + */ + options: RoomOptions; + + /** + * An abort controller to abort the get operation if the room is released before the get operation completes. + */ + abort?: AbortController; +} + /** * Manages the chat rooms. */ @@ -51,8 +86,8 @@ export class DefaultRooms implements Rooms { private readonly _realtime: Ably.Realtime; private readonly _chatApi: ChatApi; private readonly _clientOptions: NormalizedClientOptions; - private readonly _rooms: Map = new Map(); - private readonly _releasing = new Map }>(); + private readonly _rooms: Map = new Map(); + private readonly _releasing = new Map>(); private readonly _logger: Logger; /** @@ -72,25 +107,80 @@ export class DefaultRooms implements Rooms { /** * @inheritDoc */ - get(roomId: string, options: RoomOptions): Room { + get(roomId: string, options: RoomOptions): Promise { this._logger.trace('Rooms.get();', { roomId }); const existing = this._rooms.get(roomId); if (existing) { - if (!dequal(existing.options(), options)) { - throw new Ably.ErrorInfo('Room already exists with different options', 40000, 400); + if (!dequal(existing.options, options)) { + return Promise.reject(new Ably.ErrorInfo('room already exists with different options', 40000, 400)); } - return existing; + this._logger.debug('Rooms.get(); returning existing room', { roomId, nonce: existing.nonce }); + return existing.promise; } const releasing = this._releasing.get(roomId); - const initializeAfter: Promise = releasing ? releasing.promise : Promise.resolve(); + const nonce = randomId(); + + // We're not currently releasing the room, so we just make a new one + if (!releasing) { + const room = this._makeRoom(roomId, nonce, options); + const entry = { + promise: Promise.resolve(room), + nonce: nonce, + options: options, + }; + + this._rooms.set(roomId, entry); + this._logger.debug('Rooms.get(); returning new room', { roomId, nonce: room.nonce }); + return entry.promise; + } - const room = new DefaultRoom(roomId, options, this._realtime, this._chatApi, this._logger, initializeAfter); - this._rooms.set(roomId, room); + // The room is currently in the process of being released so, we wait for it to finish + // we add an abort controller so that if the room is released again whilst we're waiting, we abort the process + const abortController = new AbortController(); + const roomPromise = new Promise((resolve, reject) => { + const abortListener = () => { + this._logger.debug('Rooms.get(); aborted before init', { roomId }); + reject( + new Ably.ErrorInfo( + 'room released before get operation could complete', + ErrorCodes.RoomReleasedBeforeOperationCompleted, + 400, + ), + ); + }; - return room; + abortController.signal.addEventListener('abort', abortListener); + + releasing + .then(() => { + // We aborted before resolution + if (abortController.signal.aborted) { + this._logger.debug('Rooms.get(); aborted before releasing promise resolved', { roomId }); + return; + } + + this._logger.debug('Rooms.get(); releasing finished', { roomId }); + const room = this._makeRoom(roomId, nonce, options); + abortController.signal.removeEventListener('abort', abortListener); + resolve(room); + }) + .catch((error: unknown) => { + reject(error as Error); + }); + }); + + this._rooms.set(roomId, { + promise: roomPromise, + options: options, + nonce: nonce, + abort: abortController, + }); + + this._logger.debug('Rooms.get(); creating new promise dependent on previous release', { roomId }); + return roomPromise; } /** @@ -106,47 +196,64 @@ export class DefaultRooms implements Rooms { release(roomId: string): Promise { this._logger.trace('Rooms.release();', { roomId }); - const room = this._rooms.get(roomId); + const existing = this._rooms.get(roomId); const releasing = this._releasing.get(roomId); // If the room doesn't currently exist - if (!room) { - // If the room is being released, forward the releasing promise + if (!existing) { + // There's no existing room, but there is a release in progress, so forward that releasing promise + // to the caller so they can watch that. if (releasing) { - return releasing.promise; + this._logger.debug('Rooms.release(); waiting for previous release call', { + roomId, + }); + return releasing; } // If the room is not releasing, there is nothing else to do + this._logger.debug('Rooms.release(); room does not exist', { roomId }); return Promise.resolve(); } - // Make sure we no longer keep this room in the map - this._rooms.delete(roomId); - - // We have a room and an ongoing release, we keep the count of the latest - // release call. - let count = 0; + // A release is in progress, but its not for the currently requested room instance + // ie we called release, then get, then release again + // so instead of doing another release process, we just abort the current get if (releasing) { - count = releasing.count + 1; + if (existing.abort) { + this._logger.debug('Rooms.release(); aborting get call', { roomId, existingNonce: existing.nonce }); + existing.abort.abort(); + this._rooms.delete(roomId); + } + + return releasing; } - const releasedPromise = room.release().then(() => { - this._logger.debug('Rooms.release(); room released', { roomId }); - // Remove the room from currently releasing if the count of - // this callback is at least as high as the current count. - // - // This is to handle the case where multiple release calls - // are made in quick succession. We only want to remove the - // room from the releasing map if the last ongoing release - // finished. - const releasing = this._releasing.get(roomId); - if (releasing && releasing.count < count) { + // Room doesn't exist and we're not releasing, so its just a regular release operation + this._rooms.delete(roomId); + const releasePromise = existing.promise.then((room) => { + this._logger.debug('Rooms.release(); releasing room', { roomId, nonce: existing.nonce }); + return room.release().then(() => { + this._logger.debug('Rooms.release(); room released', { roomId, nonce: existing.nonce }); this._releasing.delete(roomId); - } + }); }); - this._releasing.set(roomId, { count: count, promise: releasedPromise }); + this._logger.debug('Rooms.release(); creating new release promise', { roomId, nonce: existing.nonce }); + this._releasing.set(roomId, releasePromise); - return releasedPromise; + return releasePromise; + } + + /** + * makes a new room object + * + * @param roomId The ID of the room. + * @param nonce A random, internal identifier useful for debugging and logging. + * @param options The options for the room. + * + * @returns DefaultRoom A new room object. + */ + private _makeRoom(roomId: string, nonce: string, options: RoomOptions): DefaultRoom { + return new DefaultRoom(roomId, nonce, options, this._realtime, this._chatApi, this._logger); } } diff --git a/src/core/tsconfig.json b/src/core/tsconfig.json index dbb55e02..c63bff35 100644 --- a/src/core/tsconfig.json +++ b/src/core/tsconfig.json @@ -10,6 +10,7 @@ "noImplicitThis": true, "esModuleInterop": true, "declaration": true, + "stripInternal": true, "moduleResolution": "Node", "skipLibCheck": true, "allowJs": true, diff --git a/src/core/typing.ts b/src/core/typing.ts index 80a7609e..b6707cdb 100644 --- a/src/core/typing.ts +++ b/src/core/typing.ts @@ -69,9 +69,9 @@ export interface Typing extends EmitsDiscontinuities { /** * Get the Ably realtime channel underpinning typing events. - * @returns A promise of the Ably realtime channel. + * @returns The Ably realtime channel. */ - channel: Promise; + channel: Ably.RealtimeChannel; } /** @@ -115,7 +115,7 @@ export class DefaultTyping implements Typing, HandlesDiscontinuity, ContributesToRoomLifecycle { private readonly _clientId: string; - private readonly _channel: Promise; + private readonly _channel: Ably.RealtimeChannel; private readonly _logger: Logger; private readonly _discontinuityEmitter: DiscontinuityEmitter = newDiscontinuityEmitter(); @@ -136,24 +136,11 @@ export class DefaultTyping * @param realtime An instance of the Ably Realtime client. * @param clientId The client ID of the user. * @param logger An instance of the Logger. - * @param initAfter A promise that is awaited before creating any channels. */ - constructor( - roomId: string, - options: TypingOptions, - realtime: Ably.Realtime, - clientId: string, - logger: Logger, - initAfter: Promise, - ) { + constructor(roomId: string, options: TypingOptions, realtime: Ably.Realtime, clientId: string, logger: Logger) { super(); this._clientId = clientId; - this._channel = initAfter.then(() => this._makeChannel(roomId, realtime)); - - // Catch this so it won't send unhandledrejection global event - this._channel.catch((error: unknown) => { - logger.debug('Typing: channel initialization canceled', { roomId, error }); - }); + this._channel = this._makeChannel(roomId, realtime); // Timeout for typing this._typingTimeoutMs = options.timeoutMs; @@ -161,7 +148,7 @@ export class DefaultTyping } /** - * Creates the realtime channel for typing indicators. Called after initAfter is resolved. + * Creates the realtime channel for typing indicators. */ private _makeChannel(roomId: string, realtime: Ably.Realtime): Ably.RealtimeChannel { const channel = getChannel(`${roomId}::$chat::$typingIndicators`, realtime); @@ -176,15 +163,14 @@ export class DefaultTyping * @inheritDoc */ get(): Promise> { - return this._channel.then((channel) => - channel.presence.get().then((members) => new Set(members.map((m) => m.clientId))), - ); + this._logger.trace(`DefaultTyping.get();`); + return this._channel.presence.get().then((members) => new Set(members.map((m) => m.clientId))); } /** * @inheritDoc */ - get channel(): Promise { + get channel(): Ably.RealtimeChannel { return this._channel; } @@ -214,8 +200,7 @@ export class DefaultTyping // Start typing and emit typingStarted event this._startTypingTimer(); - const channel = await this.channel; - return channel.presence.enterClient(this._clientId).then(); + return this._channel.presence.enterClient(this._clientId); } /** @@ -230,8 +215,7 @@ export class DefaultTyping } // Will throw an error if the user is not typing - const channel = await this.channel; - return channel.presence.leaveClient(this._clientId); + return this._channel.presence.leaveClient(this._clientId); } /** diff --git a/src/react/contexts/chat-room-context.tsx b/src/react/contexts/chat-room-context.tsx index a583fbd4..1205b1db 100644 --- a/src/react/contexts/chat-room-context.tsx +++ b/src/react/contexts/chat-room-context.tsx @@ -1,12 +1,29 @@ -import { Room } from '@ably/chat'; +import { ChatClient, Room, RoomOptions } from '@ably/chat'; import { createContext } from 'react'; /** * Data type for {@link ChatRoomContext}. */ export interface ChatRoomContextType { - /** The room in this context. */ - room: Room; + /** + * Promise that resolves to the chat room. + */ + room: Promise; + + /** + * The ID of the room that promise will resolve to. + */ + roomId: string; + + /** + * Options used to create the room. + */ + options: RoomOptions; + + /** + * The chat client used to create the room. + */ + client: ChatClient; } /** diff --git a/src/react/helper/room-promise.ts b/src/react/helper/room-promise.ts new file mode 100644 index 00000000..eed262ae --- /dev/null +++ b/src/react/helper/room-promise.ts @@ -0,0 +1,151 @@ +import { Logger, Room } from '@ably/chat'; + +/** + * RoomPromise is a wrapper around a promise that resolves to a Room instance. + * + * It is designed to better integrate into the React lifecycle, and control whether an unmount + * function needs to be called depending on where the promise resolution occurs relative to the + * component lifecycle. + */ +export interface RoomPromise { + /** + * Returns a function to be called when the component is unmounted. If the room promise has resolved at the time, + * of calling, then the unmount function returned by the onResolve callback will be called. + * + * Multiple calls are no-op. + * + * This should be used in conjunction with React's useEffect hook to ensure that resources are cleaned up. + * + * Example usage: + * + * ```ts + * useEffect(() => { + * const roomPromise: RoomPromise; + * return roomPromise.unmount(); + * }, []); + * + * @returns A function that should be called when the component is unmounted. + */ + unmount: () => () => void; +} + +/** + * A callback that can be returned by the onResolve callback to clean up any resources. + */ +type UnmountCallback = () => void; + +/** + * A callback that is called when the promise resolves to a Room instance. + */ +export type RoomResolutionCallback = (room: Room) => UnmountCallback; + +/** + * Default implementation of RoomPromise. + */ +class DefaultRoomPromise implements RoomPromise { + private readonly _roomId?: string; + private readonly _logger: Logger; + private readonly _onResolve: RoomResolutionCallback; + private _onUnmount?: UnmountCallback; + private _unmounted = false; + + /** + * Creates a new DefaultRoomPromise and starts the resolution of the promise. + * + * @param room The promise that resolves to a Room instance. + * @param onResolve The callback that is called when the promise resolves to a Room instance. + * @param logger The logger to use for logging. + * @param roomId The ID of the room, used for logging. + */ + constructor(room: Promise, onResolve: RoomResolutionCallback, logger: Logger, roomId?: string) { + this._roomId = roomId; + this._onResolve = onResolve; + this._logger = logger; + + this.mount(room).catch(() => { + this._logger.trace('DefaultRoomPromise(); mount error', { roomId: this._roomId }); + }); + } + + /** + * Wait for the room promise to resolve, then execute the onResolve callback, storing its response as an unmount function. + * If the component is unmounted before the promise resolves,then this will do nothing. + * + * @param promise The promise that resolves to a Room instance. + * @returns A promise that we simply resolve when its done. + */ + async mount(promise: Promise): Promise { + this._logger.debug('DefaultRoomPromise(); mount', { roomId: this._roomId }); + try { + const room = await promise; + if (this._unmounted) { + return; + } + + this._logger.debug('DefaultRoomPromise(); mount resolved', { roomId: this._roomId }); + this._onUnmount = this._onResolve(room); + } catch (error) { + this._logger.error('DefaultRoomPromise(); mount error', { roomId: this._roomId, error }); + } + } + + /** + * Returns a function to be called when the component is unmounted. If the room promise has resolved at the time + * of calling, then the unmount function returned by the onResolve callback will be called. + * + * Multiple calls are no-op. + * + * Example usage: + * + * ```ts + * useEffect(() => { + * const roomPromise = wrapRoomPromise(...); + * return roomPromise.unmount(); + * }, []); + * ``` + * + * @returns A function that should be called when the component is unmounted. + */ + unmount() { + if (this._unmounted) { + return () => { + // noop + }; + } + + return () => { + this._logger.debug('DefaultRoomPromise(); unmount', { roomId: this._roomId }); + this._unmounted = true; + this._onUnmount?.(); + }; + } +} + +/** + * Provides a convenient way to wrap a promise that resolves to a Room instance, and execute a callback. + * This should be used in conjunction with React's useEffect hook to ensure that resources are cleaned up. + * + * Example usage: + * + * ```ts + * useEffect(() => { + * const roomPromise = wrapRoomPromise(...); + * return roomPromise.unmount(); + * }, []); + * ``` + * + * @internal + * @param room The promise that resolves to a Room instance. + * @param onResolve The callback that is called when the promise resolves to a Room instance. + * @param logger The logger to use for logging. + * @param id The ID of the room, used for logging. + * @returns A RoomPromise instance that can be used to clean up resources. + */ +export function wrapRoomPromise( + room: Promise, + onResolve: RoomResolutionCallback, + logger: Logger, + id?: string, +): RoomPromise { + return new DefaultRoomPromise(room, onResolve, logger, id); +} diff --git a/src/react/helper/use-eventual-room.ts b/src/react/helper/use-eventual-room.ts new file mode 100644 index 00000000..48f21be4 --- /dev/null +++ b/src/react/helper/use-eventual-room.ts @@ -0,0 +1,88 @@ +import { Room } from '@ably/chat'; +import { useEffect, useState } from 'react'; + +import { useLogger } from '../hooks/use-logger.js'; +import { useRoomContext } from './use-room-context.js'; +import { useStableReference } from './use-stable-reference.js'; + +/** + * This hook will take the room promise from the current context and return the room object once it has been resolved. + * This is useful in hooks like useRoom to provide a direct reference to the room object, as Promises aren't usually + * the best thing to be passing around React components. + * + * @internal + * @returns The room object if it has resolved, otherwise undefined + */ +export const useEventualRoom = (): Room | undefined => { + const [roomState, setRoomState] = useState(); + const context = useRoomContext('useEventualRoom'); + const logger = useLogger(); + logger.trace('useEventualRoom();', { roomId: context.roomId }); + + useEffect(() => { + logger.debug('useEventualRoom(); running useEffect', { roomId: context.roomId }); + let unmounted = false; + void context.room + .then((room: Room) => { + if (unmounted) { + logger.debug('useEventualRoom(); already unmounted', { roomId: context.roomId }); + return; + } + + logger.debug('useEventualRoom(); resolved', { roomId: context.roomId }); + setRoomState(room); + }) + .catch((error: unknown) => { + logger.error('Failed to get room', { roomId: context.roomId, error }); + }); + + return () => { + logger.debug('useEventualRoom(); cleanup', { roomId: context.roomId }); + unmounted = true; + }; + }, [context, logger]); + + return roomState; +}; + +/** + * Similar to useEventualRoom, but instead of providing the room itself, it provides a property of the room - e.g. + * Messages. We use this to eventually provide access to underlying room interfaces as non-promise values + * in hooks like useMessages. + * + * @internal + * @returns The property of the room object that's been resolved, as returned by the onResolve callback, + * or undefined if the room hasn't resolved yet. + */ +export const useEventualRoomProperty = (onResolve: (room: Room) => T) => { + const [roomState, setRoomState] = useState(); + const context = useRoomContext('useEventualRoomProperty'); + const logger = useLogger(); + logger.trace('useEventualRoomProperty();', { roomId: context.roomId }); + const onResolveRef = useStableReference(onResolve); + + useEffect(() => { + let unmounted = false; + logger.debug('useEventualRoomProperty(); running useEffect', { roomId: context.roomId }); + void context.room + .then((room: Room) => { + if (unmounted) { + logger.debug('useEventualRoomProperty(); already unmounted', { roomId: context.roomId }); + return; + } + + logger.debug('useEventualRoomProperty(); resolved', { roomId: context.roomId }); + setRoomState(onResolveRef(room)); + }) + .catch((error: unknown) => { + logger.error('Failed to get room', { roomId: context.roomId, error }); + }); + + return () => { + logger.debug('useEventualRoomProperty(); cleanup', { roomId: context.roomId }); + unmounted = true; + }; + }, [context, logger, onResolveRef]); + + return roomState; +}; diff --git a/src/react/helper/use-room-context.ts b/src/react/helper/use-room-context.ts new file mode 100644 index 00000000..b69180f0 --- /dev/null +++ b/src/react/helper/use-room-context.ts @@ -0,0 +1,21 @@ +import * as Ably from 'ably'; +import { useContext } from 'react'; + +import { ChatRoomContext, ChatRoomContextType } from '../contexts/chat-room-context.js'; + +/** + * A hook that returns the current ChatRoomContext. This should be used within a ChatRoomProvider. + * + * @internal + * @param callingHook The name of the hook that is calling this function, for logging purposes. + * @throws {@link Ably.ErrorInfo} if the hook is not used within a ChatRoomProvider. + * @returns The ChatRoomContext. + */ +export const useRoomContext = (callingHook: string): ChatRoomContextType => { + const context = useContext(ChatRoomContext); + if (!context) { + throw new Ably.ErrorInfo(`${callingHook} hook must be used within a `, 40000, 400); + } + + return context; +}; diff --git a/src/react/helper/use-room-status.ts b/src/react/helper/use-room-status.ts new file mode 100644 index 00000000..1581eded --- /dev/null +++ b/src/react/helper/use-room-status.ts @@ -0,0 +1,120 @@ +import { Room, RoomStatus, RoomStatusChange } from '@ably/chat'; +import * as Ably from 'ably'; +import { useEffect, useState } from 'react'; + +import { useLogger } from '../hooks/use-logger.js'; +import { wrapRoomPromise } from './room-promise.js'; +import { useEventListenerRef } from './use-event-listener-ref.js'; +import { useRoomContext } from './use-room-context.js'; + +/** + * The response object for the useRoomStatus hook. + */ +export interface UseRoomStatusResponse { + /** + * The current status of the room. + */ + readonly status: RoomStatus; + + /** + * The error that caused the room to transition to an errored state. + */ + readonly error?: Ably.ErrorInfo; +} + +/** + * The parameters for the useRoomStatus hook. + */ +export interface UseRoomStatusParams { + /** + * A listener for room status changes. + */ + onRoomStatusChange?: (change: RoomStatusChange) => void; +} + +/** + * A hook that returns the current status of the room, and listens for changes to the room status. + * + * @internal + * @param params An optional user-provided listener for room status changes. + * @returns The current status of the room, and an error if the room is in an errored state. + */ +export const useRoomStatus = (params?: UseRoomStatusParams): UseRoomStatusResponse => { + const context = useRoomContext('useRoomStatus'); + + const [status, setStatus] = useState(RoomStatus.Initializing); + const [error, setError] = useState(); + const logger = useLogger(); + + // create stable references for the listeners and register the user-provided callbacks + const onRoomStatusChangeRef = useEventListenerRef(params?.onRoomStatusChange); + + // create an internal listener to update the status + useEffect(() => { + const roomPromise = wrapRoomPromise( + context.room, + (room: Room) => { + logger.debug('useRoomStatus(); subscribing internal listener'); + // Set instantaneous values + setStatus(room.status); + setError(room.error); + + // Add the subscription + const { off } = room.onStatusChange((change) => { + logger.debug('useRoomStatus(); status change', change); + setStatus(change.current); + setError(change.error); + }); + + return () => { + logger.debug('useRoomStatus(); unsubscribing internal listener'); + off(); + }; + }, + logger, + context.roomId, + ); + + return roomPromise.unmount(); + }, [context, logger]); + + useEffect(() => { + const roomPromise = wrapRoomPromise( + context.room, + (room: Room) => { + let off: (() => void) | undefined; + if (onRoomStatusChangeRef) { + logger.debug('useRoomStatus(); subscribing to status changes'); + off = room.onStatusChange(onRoomStatusChangeRef).off; + } + + logger.debug('useRoomStatus(); setting initial status', { status: room.status }); + if (onRoomStatusChangeRef) { + logger.debug('useRoomStatus(); sending initial status event'); + onRoomStatusChangeRef({ + current: room.status, + previous: RoomStatus.Initializing, + error: room.error, + }); + } + + return () => { + logger.debug('useRoomStatus(); unmounting'); + if (off) { + logger.debug('useRoomStatus(); unsubscribing from status changes'); + off(); + } + }; + }, + logger, + context.roomId, + ); + + return roomPromise.unmount(); + }, [context, logger, onRoomStatusChangeRef]); + + return { + status, + error, + }; +}; diff --git a/src/react/helper/use-stable-reference.ts b/src/react/helper/use-stable-reference.ts new file mode 100644 index 00000000..21a78106 --- /dev/null +++ b/src/react/helper/use-stable-reference.ts @@ -0,0 +1,26 @@ +import { useCallback, useEffect, useRef } from 'react'; + +/** + * The type of a callback function that can be stored in the reference. + */ +type Callback = (...args: CallbackArguments) => ReturnType; + +/** + * In some cases, we want to use a callback that is always the same, and always persists across renders. + * This function creates a stable reference to a callback, so that it can be used in a `useEffect` or `useCallback` + * without causing unnecessary re-renders. + * + * @internal + * @param callback The callback to turn into a stable reference + * @returns A stable reference to the callback + */ +export const useStableReference = ( + callback: Callback, +): Callback => { + const ref = useRef>(callback); + useEffect(() => { + ref.current = callback; + }); + + return useCallback((...args: Arguments) => ref.current(...args), []); +}; diff --git a/src/react/hooks/use-chat-connection.ts b/src/react/hooks/use-chat-connection.ts index 8426c9b6..13ee1f48 100644 --- a/src/react/hooks/use-chat-connection.ts +++ b/src/react/hooks/use-chat-connection.ts @@ -4,6 +4,7 @@ import { useEffect, useState } from 'react'; import { useEventListenerRef } from '../helper/use-event-listener-ref.js'; import { useChatClient } from './use-chat-client.js'; +import { useLogger } from './use-logger.js'; /** * The options for the {@link useChatConnection} hook. @@ -44,7 +45,8 @@ export interface UseChatConnectionResponse { */ export const useChatConnection = (options?: UseChatConnectionOptions): UseChatConnectionResponse => { const chatClient = useChatClient(); - chatClient.logger.trace('useChatConnection();', options); + const logger = useLogger(); + logger.trace('useChatConnection();', options); // Initialize states with the current values from chatClient const [currentStatus, setCurrentStatus] = useState(chatClient.connection.status); @@ -63,7 +65,7 @@ export const useChatConnection = (options?: UseChatConnectionOptions): UseChatCo // Apply the listener to the chatClient's connection status changes to keep the state update across re-renders useEffect(() => { - chatClient.logger.debug('useChatConnection(); applying internal listener'); + logger.debug('useChatConnection(); applying internal listener'); const { off } = chatClient.connection.onStatusChange((change: ConnectionStatusChange) => { // Update states with new values setCurrentStatus(change.current); @@ -71,22 +73,22 @@ export const useChatConnection = (options?: UseChatConnectionOptions): UseChatCo }); // Cleanup listener on un-mount return () => { - chatClient.logger.debug('useChatConnection(); cleaning up listener'); + logger.debug('useChatConnection(); cleaning up listener'); off(); }; - }, [chatClient.connection, chatClient.logger]); + }, [chatClient.connection, logger]); // Register the listener for the user-provided onStatusChange callback useEffect(() => { if (!onStatusChangeRef) return; - chatClient.logger.debug('useChatConnection(); applying client listener'); + logger.debug('useChatConnection(); applying client listener'); const { off } = chatClient.connection.onStatusChange(onStatusChangeRef); return () => { - chatClient.logger.debug('useChatConnection(); cleaning up client listener'); + logger.debug('useChatConnection(); cleaning up client listener'); off(); }; - }, [chatClient.connection, chatClient.logger, onStatusChangeRef]); + }, [chatClient.connection, logger, onStatusChangeRef]); return { currentStatus, diff --git a/src/react/hooks/use-logger.ts b/src/react/hooks/use-logger.ts index 7b8e46ae..8b2e2d4c 100644 --- a/src/react/hooks/use-logger.ts +++ b/src/react/hooks/use-logger.ts @@ -12,5 +12,5 @@ import { useChatClient } from './use-chat-client.js'; */ export const useLogger = (): Logger => { const chatClient = useChatClient(); - return useMemo(() => chatClient.logger, [chatClient]); + return useMemo(() => (chatClient as unknown as { logger: Logger }).logger, [chatClient]); }; diff --git a/src/react/hooks/use-messages.ts b/src/react/hooks/use-messages.ts index 14ae6458..e7adb99f 100644 --- a/src/react/hooks/use-messages.ts +++ b/src/react/hooks/use-messages.ts @@ -4,19 +4,22 @@ import { MessageListener, Messages, MessageSubscriptionResponse, - PaginatedResult, QueryOptions, SendMessageParams, } from '@ably/chat'; +import * as Ably from 'ably'; import { useCallback, useEffect, useState } from 'react'; +import { wrapRoomPromise } from '../helper/room-promise.js'; import { useEventListenerRef } from '../helper/use-event-listener-ref.js'; +import { useEventualRoomProperty } from '../helper/use-eventual-room.js'; +import { useRoomContext } from '../helper/use-room-context.js'; +import { useRoomStatus } from '../helper/use-room-status.js'; import { ChatStatusResponse } from '../types/chat-status-response.js'; import { Listenable } from '../types/listenable.js'; import { StatusParams } from '../types/status-params.js'; import { useChatConnection } from './use-chat-connection.js'; import { useLogger } from './use-logger.js'; -import { useRoom } from './use-room.js'; /** * The response from the {@link useMessages} hook. @@ -40,7 +43,7 @@ export interface UseMessagesResponse extends ChatStatusResponse { /** * Provides access to the underlying {@link Messages} instance of the room. */ - readonly messages: Messages; + readonly messages?: Messages; /** * Retrieves the previous messages in the room. @@ -82,79 +85,99 @@ export const useMessages = (params?: UseMessagesParams): UseMessagesResponse => const { currentStatus: connectionStatus, error: connectionError } = useChatConnection({ onStatusChange: params?.onConnectionStatusChange, }); - const { room, roomError, roomStatus } = useRoom({ - onStatusChange: params?.onRoomStatusChange, - }); + const context = useRoomContext('useMessages'); + const { status: roomStatus, error: roomError } = useRoomStatus(params); const logger = useLogger(); - logger.trace('useMessages();', { params, roomId: room.roomId }); + logger.trace('useMessages();', { params, roomId: context.roomId }); // we are storing the params in a ref so that we don't end up with an infinite loop should the user pass // in an unstable reference const listenerRef = useEventListenerRef(params?.listener); const onDiscontinuityRef = useEventListenerRef(params?.onDiscontinuity); - const send = useCallback((params: SendMessageParams) => room.messages.send(params), [room]); - + const send = useCallback( + (params: SendMessageParams) => context.room.then((room) => room.messages.send(params)), + [context], + ); const deleteMessage = useCallback( - (message: Message, deleteMessageParams?: DeleteMessageParams) => room.messages.delete(message, deleteMessageParams), - [room], + (message: Message, deleteMessageParams?: DeleteMessageParams) => + context.room.then((room) => room.messages.delete(message, deleteMessageParams)), + [context], + ); + const get = useCallback( + (options: QueryOptions) => context.room.then((room) => room.messages.get(options)), + [context], ); - const get = useCallback((options: QueryOptions) => room.messages.get(options), [room]); const [getPreviousMessages, setGetPreviousMessages] = useState(); useEffect(() => { if (!listenerRef) return; - logger.debug('useMessages(); applying listener', { roomId: room.roomId }); - let unmounted = false; - const sub = room.messages.subscribe(listenerRef); - - // set the getPreviousMessages method if a listener is provided - setGetPreviousMessages(() => { - logger.debug('useMessages(); setting getPreviousMessages state', { roomId: room.roomId }); - return (params: Omit) => { - // If we've unmounted, then the subscription is gone and we can't call getPreviousMessages - // So return a dummy object that should be thrown away anyway - if (unmounted) { - logger.debug('useMessages(); getPreviousMessages called after unmount', { roomId: room.roomId }); - return Promise.resolve({ - items: [], - hasNext: () => false, - isLast: () => true, - next: () => undefined as unknown as Promise> | null, - previous: () => undefined as unknown as Promise>, - current: () => undefined as unknown as Promise>, - first: () => undefined as unknown as Promise>, - }) as Promise>; - } - return sub.getPreviousMessages(params); - }; - }); - - return () => { - logger.debug('useMessages(); removing listener and getPreviousMessages state', { roomId: room.roomId }); - unmounted = true; - sub.unsubscribe(); - setGetPreviousMessages(undefined); - }; - }, [room, logger, listenerRef]); + + return wrapRoomPromise( + context.room, + (room) => { + let unmounted = false; + logger.debug('useMessages(); applying listener', { roomId: context.roomId }); + const sub = room.messages.subscribe(listenerRef); + + // set the getPreviousMessages method if a listener is provided + setGetPreviousMessages(() => { + logger.debug('useMessages(); setting getPreviousMessages state', { + roomId: context.roomId, + status: room.status, + unmounted, + }); + if (unmounted) { + return; + } + + return (params: Omit) => { + // If we've unmounted, then the subscription is gone and we can't call getPreviousMessages + // So return a dummy object that should be thrown away anyway + logger.debug('useMessages(); getPreviousMessages called', { roomId: context.roomId }); + if (unmounted) { + return Promise.reject(new Ably.ErrorInfo('component unmounted', 40000, 400)); + } + return sub.getPreviousMessages(params); + }; + }); + + return () => { + logger.debug('useMessages(); removing listener and getPreviousMessages state', { roomId: context.roomId }); + unmounted = true; + sub.unsubscribe(); + setGetPreviousMessages(undefined); + }; + }, + logger, + context.roomId, + ).unmount(); + }, [context, logger, listenerRef]); useEffect(() => { if (!onDiscontinuityRef) return; - logger.debug('useMessages(); applying onDiscontinuity listener', { roomId: room.roomId }); - const { off } = room.messages.onDiscontinuity(onDiscontinuityRef); - return () => { - logger.debug('useMessages(); removing onDiscontinuity listener', { roomId: room.roomId }); - off(); - }; - }, [room, logger, onDiscontinuityRef]); + return wrapRoomPromise( + context.room, + (room) => { + logger.debug('useMessages(); applying onDiscontinuity listener', { roomId: context.roomId }); + const { off } = room.messages.onDiscontinuity(onDiscontinuityRef); + return () => { + logger.debug('useMessages(); removing onDiscontinuity listener', { roomId: context.roomId }); + off(); + }; + }, + logger, + context.roomId, + ).unmount(); + }, [context, logger, onDiscontinuityRef]); return { + messages: useEventualRoomProperty((room) => room.messages), send, get, deleteMessage, - messages: room.messages, getPreviousMessages, connectionStatus, connectionError, diff --git a/src/react/hooks/use-occupancy.ts b/src/react/hooks/use-occupancy.ts index b752e481..cbd1ef98 100644 --- a/src/react/hooks/use-occupancy.ts +++ b/src/react/hooks/use-occupancy.ts @@ -1,13 +1,16 @@ import { Occupancy, OccupancyListener } from '@ably/chat'; import { useEffect, useState } from 'react'; +import { wrapRoomPromise } from '../helper/room-promise.js'; import { useEventListenerRef } from '../helper/use-event-listener-ref.js'; +import { useEventualRoomProperty } from '../helper/use-eventual-room.js'; +import { useRoomContext } from '../helper/use-room-context.js'; +import { useRoomStatus } from '../helper/use-room-status.js'; import { ChatStatusResponse } from '../types/chat-status-response.js'; import { Listenable } from '../types/listenable.js'; import { StatusParams } from '../types/status-params.js'; import { useChatConnection } from './use-chat-connection.js'; import { useLogger } from './use-logger.js'; -import { useRoom } from './use-room.js'; /** * The options for the {@link useOccupancy} hook. @@ -24,11 +27,6 @@ export interface UseOccupancyParams extends StatusParams, Listenable({ connections: 0, @@ -69,46 +72,67 @@ export const useOccupancy = (params?: UseOccupancyParams): UseOccupancyResponse // if provided, subscribes the user provided discontinuity listener useEffect(() => { if (!onDiscontinuityRef) return; - logger.debug('useOccupancy(); applying onDiscontinuity listener', { roomId: room.roomId }); - const { off } = room.occupancy.onDiscontinuity(onDiscontinuityRef); - return () => { - logger.debug('useOccupancy(); removing onDiscontinuity listener', { roomId: room.roomId }); - off(); - }; - }, [room, onDiscontinuityRef, logger]); + return wrapRoomPromise( + context.room, + (room) => { + logger.debug('useOccupancy(); applying onDiscontinuity listener', { roomId: context.roomId }); + const { off } = room.occupancy.onDiscontinuity(onDiscontinuityRef); + return () => { + logger.debug('useOccupancy(); removing onDiscontinuity listener', { roomId: context.roomId }); + off(); + }; + }, + logger, + context.roomId, + ).unmount(); + }, [context, onDiscontinuityRef, logger]); // subscribe to occupancy events internally, to update the state metrics useEffect(() => { - logger.debug('useOccupancy(); applying internal listener', { roomId: room.roomId }); - const { unsubscribe } = room.occupancy.subscribe((occupancyEvent) => { - setOccupancyMetrics({ - connections: occupancyEvent.connections, - presenceMembers: occupancyEvent.presenceMembers, - }); - }); - return () => { - logger.debug('useOccupancy(); cleaning up internal listener', { roomId: room.roomId }); - unsubscribe(); - }; - }, [room, logger]); + return wrapRoomPromise( + context.room, + (room) => { + logger.debug('useOccupancy(); applying internal listener', { roomId: context.roomId }); + const { unsubscribe } = room.occupancy.subscribe((occupancyEvent) => { + setOccupancyMetrics({ + connections: occupancyEvent.connections, + presenceMembers: occupancyEvent.presenceMembers, + }); + }); + return () => { + logger.debug('useOccupancy(); cleaning up internal listener', { roomId: context.roomId }); + unsubscribe(); + }; + }, + logger, + context.roomId, + ).unmount(); + }, [context, logger]); // if provided, subscribes the user provided listener to occupancy events useEffect(() => { if (!listenerRef) return; - logger.debug('useOccupancy(); applying listener', { roomId: room.roomId }); - const { unsubscribe } = room.occupancy.subscribe(listenerRef); - return () => { - logger.debug('useOccupancy(); cleaning up listener', { roomId: room.roomId }); - unsubscribe(); - }; - }, [listenerRef, room, logger]); + return wrapRoomPromise( + context.room, + (room) => { + logger.debug('useOccupancy(); applying listener', { roomId: context.roomId }); + const { unsubscribe } = room.occupancy.subscribe(listenerRef); + return () => { + logger.debug('useOccupancy(); cleaning up listener', { roomId: context.roomId }); + unsubscribe(); + }; + }, + logger, + context.roomId, + ).unmount(); + }, [listenerRef, context, logger]); return { + occupancy: useEventualRoomProperty((room) => room.occupancy), connectionStatus, connectionError, roomStatus, roomError, - occupancy: room.occupancy, connections: occupancyMetrics.connections, presenceMembers: occupancyMetrics.presenceMembers, }; diff --git a/src/react/hooks/use-presence-listener.ts b/src/react/hooks/use-presence-listener.ts index 2774af0a..63de9527 100644 --- a/src/react/hooks/use-presence-listener.ts +++ b/src/react/hooks/use-presence-listener.ts @@ -1,14 +1,17 @@ -import { ErrorCodes, errorInfoIs, Presence, PresenceListener, PresenceMember } from '@ably/chat'; +import { ErrorCodes, errorInfoIs, Presence, PresenceListener, PresenceMember, Room, RoomStatus } from '@ably/chat'; import * as Ably from 'ably'; import { useCallback, useEffect, useRef, useState } from 'react'; +import { wrapRoomPromise } from '../helper/room-promise.js'; import { useEventListenerRef } from '../helper/use-event-listener-ref.js'; +import { useEventualRoomProperty } from '../helper/use-eventual-room.js'; +import { useRoomContext } from '../helper/use-room-context.js'; +import { useRoomStatus } from '../helper/use-room-status.js'; import { ChatStatusResponse } from '../types/chat-status-response.js'; import { Listenable } from '../types/listenable.js'; import { StatusParams } from '../types/status-params.js'; import { useChatConnection } from './use-chat-connection.js'; import { useLogger } from './use-logger.js'; -import { useRoom } from './use-room.js'; /** * The interval between retries when fetching presence data. @@ -45,7 +48,7 @@ export interface UsePresenceListenerResponse extends ChatStatusResponse { /** * Provides access to the underlying {@link Presence} instance of the room. */ - readonly presence: Presence; + readonly presence?: Presence; /** * The error state of the presence listener. @@ -70,12 +73,12 @@ export const usePresenceListener = (params?: UsePresenceListenerParams): UsePres const { currentStatus: connectionStatus, error: connectionError } = useChatConnection({ onStatusChange: params?.onConnectionStatusChange, }); - const { room, roomError, roomStatus } = useRoom({ - onStatusChange: params?.onRoomStatusChange, - }); + + const context = useRoomContext('usePresenceListener'); + const { status: roomStatus, error: roomError } = useRoomStatus(params); const logger = useLogger(); - logger.trace('usePresenceListener();', { roomId: room.roomId }); + logger.trace('usePresenceListener();', { roomId: context.roomId }); const receivedEventNumber = useRef(0); const triggeredEventNumber = useRef(0); @@ -93,18 +96,18 @@ export const usePresenceListener = (params?: UsePresenceListenerParams): UsePres const setErrorState = useCallback( (error: Ably.ErrorInfo) => { - logger.debug('usePresenceListener(); setting error state', { error, roomId: room.roomId }); + logger.debug('usePresenceListener(); setting error state', { error, roomId: context.roomId }); errorRef.current = error; setError(error); }, - [logger, room.roomId], + [logger, context], ); const clearErrorState = useCallback(() => { - logger.debug('usePresenceListener(); clearing error state', { roomId: room.roomId }); + logger.debug('usePresenceListener(); clearing error state', { roomId: context.roomId }); errorRef.current = undefined; setError(undefined); - }, [logger, room.roomId]); + }, [logger, context]); useEffect(() => { // ensure we only process and return the latest presence data. @@ -123,142 +126,192 @@ export const usePresenceListener = (params?: UsePresenceListenerParams): UsePres }; const getAndSetState = (eventNumber: number) => { - room.presence - .get({ waitForSync: true }) - .then((presenceMembers) => { - logger.debug('usePresenceListener(); fetched presence data', { presenceMembers, roomId: room.roomId }); - - // clear the retry now we have resolved - if (retryTimeout.current) { - clearTimeout(retryTimeout.current); - retryTimeout.current = undefined; - numRetries.current = 0; - } - - // ensure the current event is still the latest - if (triggeredEventNumber.current >= eventNumber) { - return; - } - - triggeredEventNumber.current = eventNumber; - - // update the presence data - latestPresentData.current = presenceMembers; - setPresenceData(presenceMembers); - - // clear any previous errors as we have now resolved to the latest state - if (errorRef.current) { - clearErrorState(); - } - }) - .catch(() => { - const willReattempt = numRetries.current < PRESENCE_GET_MAX_RETRIES; - - if (!willReattempt) { - // since we have reached the maximum number of retries, set the error state - logger.error('usePresenceListener(); failed to fetch presence data after max retries', { - roomId: room.roomId, + wrapRoomPromise( + context.room, + (room: Room) => { + room.presence + .get({ waitForSync: true }) + .then((presenceMembers) => { + logger.debug('usePresenceListener(); fetched presence data', { presenceMembers, roomId: context.roomId }); + + // clear the retry now we have resolved + if (retryTimeout.current) { + clearTimeout(retryTimeout.current); + retryTimeout.current = undefined; + numRetries.current = 0; + } + + // ensure the current event is still the latest + if (triggeredEventNumber.current >= eventNumber) { + return; + } + + triggeredEventNumber.current = eventNumber; + + // update the presence data + latestPresentData.current = presenceMembers; + setPresenceData(presenceMembers); + + // clear any previous errors as we have now resolved to the latest state + if (errorRef.current) { + clearErrorState(); + } + }) + .catch(() => { + const willReattempt = numRetries.current < PRESENCE_GET_MAX_RETRIES; + + if (!willReattempt) { + // since we have reached the maximum number of retries, set the error state + logger.error('usePresenceListener(); failed to fetch presence data after max retries', { + roomId: context.roomId, + }); + setErrorState(new Ably.ErrorInfo(`failed to fetch presence data after max retries`, 50000, 500)); + return; + } + + // if we are currently waiting for a retry, do nothing as a new event has been received + if (retryTimeout.current) { + logger.debug('usePresenceListener(); waiting for retry but new event received', { + roomId: context.roomId, + }); + return; + } + + const waitBeforeRetry = Math.min( + PRESENCE_GET_RETRY_MAX_INTERVAL_MS, + PRESENCE_GET_RETRY_INTERVAL_MS * Math.pow(2, numRetries.current), + ); + + numRetries.current += 1; + logger.debug('usePresenceListener(); retrying to fetch presence data', { + numRetries: numRetries.current, + roomId: context.roomId, + }); + + retryTimeout.current = setTimeout(() => { + retryTimeout.current = undefined; + receivedEventNumber.current += 1; + getAndSetState(receivedEventNumber.current); + }, waitBeforeRetry); }); - setErrorState(new Ably.ErrorInfo(`failed to fetch presence data after max retries`, 50000, 500)); - return; - } - - // if we are currently waiting for a retry, do nothing as a new event has been received - if (retryTimeout.current) { - logger.debug('usePresenceListener(); waiting for retry but new event received', { roomId: room.roomId }); - return; - } - const waitBeforeRetry = Math.min( - PRESENCE_GET_RETRY_MAX_INTERVAL_MS, - PRESENCE_GET_RETRY_INTERVAL_MS * Math.pow(2, numRetries.current), - ); + return () => { + // No-op + }; + }, + logger, + context.roomId, + ); + }; - numRetries.current += 1; - logger.debug('usePresenceListener(); retrying to fetch presence data', { - numRetries: numRetries.current, - roomId: room.roomId, + return wrapRoomPromise( + context.room, + (room) => { + let unsubscribe: (() => void) | undefined; + // If the room isn't attached yet, we can't do the initial fetch + if (room.status === RoomStatus.Attached) { + room.presence + .get({ waitForSync: true }) + .then((presenceMembers) => { + logger.debug('usePresenceListener(); fetched initial presence data', { + presenceMembers, + roomId: context.roomId, + }); + // on mount, fetch the initial presence data + latestPresentData.current = presenceMembers; + setPresenceData(presenceMembers); + + // clear any previous errors + clearErrorState(); + }) + .catch((error: unknown) => { + const errorInfo = error as Ably.ErrorInfo; + if (errorInfoIs(errorInfo, ErrorCodes.RoomIsReleased)) return; + + logger.error('usePresenceListener(); error fetching initial presence data', { + error, + roomId: context.roomId, + }); + setErrorState(errorInfo); + }) + .finally(() => { + // subscribe to presence events + logger.debug('usePresenceListener(); subscribing internal listener to presence events', { + roomId: context.roomId, + }); + const result = room.presence.subscribe(() => { + updatePresenceData(); + }); + unsubscribe = result.unsubscribe; + }); + } else { + // subscribe to presence events + logger.debug('usePresenceListener(); not yet attached, subscribing internal listener to presence events', { + roomId: context.roomId, }); + const result = room.presence.subscribe(() => { + updatePresenceData(); + }); + unsubscribe = result.unsubscribe; + } - retryTimeout.current = setTimeout(() => { - retryTimeout.current = undefined; - receivedEventNumber.current += 1; - getAndSetState(receivedEventNumber.current); - }, waitBeforeRetry); - }); - }; - - let unsubscribe: (() => void) | undefined; - room.presence - .get({ waitForSync: true }) - .then((presenceMembers) => { - logger.debug('usePresenceListener(); fetched initial presence data', { presenceMembers, roomId: room.roomId }); - // on mount, fetch the initial presence data - latestPresentData.current = presenceMembers; - setPresenceData(presenceMembers); - - // clear any previous errors - clearErrorState(); - }) - .catch((error: unknown) => { - const errorInfo = error as Ably.ErrorInfo; - if (errorInfoIs(errorInfo, ErrorCodes.RoomIsReleased)) return; - - logger.error('usePresenceListener(); error fetching initial presence data', { - error, - roomId: room.roomId, - }); - setErrorState(errorInfo); - }) - .finally(() => { - // subscribe to presence events - logger.debug('usePresenceListener(); subscribing internal listener to presence events', { - roomId: room.roomId, - }); - const result = room.presence.subscribe(() => { - updatePresenceData(); - }); - unsubscribe = result.unsubscribe; - }); - return () => { - if (unsubscribe) { - logger.debug('usePresenceListener(); cleaning up internal listener', { roomId: room.roomId }); - unsubscribe(); - } - }; - }, [room, setErrorState, clearErrorState, logger]); + return () => { + if (unsubscribe) { + logger.debug('usePresenceListener(); cleaning up internal listener', { roomId: context.roomId }); + unsubscribe(); + } + }; + }, + logger, + context.roomId, + ).unmount(); + }, [context, setErrorState, clearErrorState, logger]); // subscribe the user provided listener to presence changes useEffect(() => { if (!listenerRef) return; - const { unsubscribe } = room.presence.subscribe(listenerRef); - logger.debug('usePresenceListener(); applying external listener', { roomId: room.roomId }); - - return () => { - logger.debug('usePresenceListener(); cleaning up external listener', { roomId: room.roomId }); - unsubscribe(); - }; - }, [room, listenerRef, logger]); + return wrapRoomPromise( + context.room, + (room) => { + logger.debug('usePresenceListener(); applying external listener', { roomId: context.roomId }); + const { unsubscribe } = room.presence.subscribe(listenerRef); + + return () => { + logger.debug('usePresenceListener(); cleaning up external listener', { roomId: context.roomId }); + unsubscribe(); + }; + }, + logger, + context.roomId, + ).unmount(); + }, [context, listenerRef, logger]); // subscribe the user provided onDiscontinuity listener useEffect(() => { if (!onDiscontinuityRef) return; - logger.debug('usePresenceListener(); applying onDiscontinuity listener', { roomId: room.roomId }); - const { off } = room.presence.onDiscontinuity(onDiscontinuityRef); - - return () => { - logger.debug('usePresenceListener(); removing onDiscontinuity listener', { roomId: room.roomId }); - off(); - }; - }, [room, onDiscontinuityRef, logger]); + return wrapRoomPromise( + context.room, + (room) => { + logger.debug('usePresenceListener(); applying onDiscontinuity listener', { roomId: context.roomId }); + const { off } = room.presence.onDiscontinuity(onDiscontinuityRef); + + return () => { + logger.debug('usePresenceListener(); removing onDiscontinuity listener', { roomId: context.roomId }); + off(); + }; + }, + logger, + context.roomId, + ).unmount(); + }, [context, onDiscontinuityRef, logger]); return { + presence: useEventualRoomProperty((room) => room.presence), connectionStatus, connectionError, roomStatus, roomError, error, presenceData: presenceData, - presence: room.presence, }; }; diff --git a/src/react/hooks/use-presence.ts b/src/react/hooks/use-presence.ts index c500bb7a..07e0c701 100644 --- a/src/react/hooks/use-presence.ts +++ b/src/react/hooks/use-presence.ts @@ -1,13 +1,16 @@ -import { ConnectionStatus, Presence, PresenceData, RoomStatus } from '@ably/chat'; +import { ConnectionStatus, Presence, PresenceData, Room, RoomStatus } from '@ably/chat'; import { type ErrorInfo } from 'ably'; import { useCallback, useEffect, useRef, useState } from 'react'; +import { wrapRoomPromise } from '../helper/room-promise.js'; import { useEventListenerRef } from '../helper/use-event-listener-ref.js'; +import { useEventualRoomProperty } from '../helper/use-eventual-room.js'; +import { useRoomContext } from '../helper/use-room-context.js'; +import { useRoomStatus } from '../helper/use-room-status.js'; import { ChatStatusResponse } from '../types/chat-status-response.js'; import { StatusParams } from '../types/status-params.js'; import { useChatConnection } from './use-chat-connection.js'; import { useLogger } from './use-logger.js'; -import { useRoom } from './use-room.js'; /** * The options for the {@link usePresence} hook. @@ -30,6 +33,11 @@ export interface UsePresenceResponse extends ChatStatusResponse { */ readonly update: Presence['update']; + /** + * Provides access to the underlying {@link Presence} instance of the room. + */ + readonly presence?: Presence; + /** * Indicates whether the current user is present in the room. */ @@ -39,11 +47,6 @@ export interface UsePresenceResponse extends ChatStatusResponse { * Indicates if an error occurred while entering or leaving the room. */ readonly error?: ErrorInfo; - - /** - * Provides access to the underlying {@link Presence} instance of the room. - */ - readonly presence: Presence; } /** @@ -64,11 +67,11 @@ export const usePresence = (params?: UsePresenceParams): UsePresenceResponse => const { currentStatus: connectionStatus, error: connectionError } = useChatConnection({ onStatusChange: params?.onConnectionStatusChange, }); - const { room, roomError, roomStatus } = useRoom({ - onStatusChange: params?.onRoomStatusChange, - }); + + const context = useRoomContext('usePresence'); + const { status: roomStatus, error: roomError } = useRoomStatus(params); const logger = useLogger(); - logger.trace('usePresence();', { params, roomId: room.roomId }); + logger.trace('usePresence();', { params, roomId: context.roomId }); const [isPresent, setIsPresent] = useState(false); const [error, setError] = useState(); @@ -92,66 +95,96 @@ export const usePresence = (params?: UsePresenceParams): UsePresenceResponse => // enter the room when the hook is mounted useEffect(() => { - const canJoinPresence = roomStatus === RoomStatus.Attached && !INACTIVE_CONNECTION_STATES.has(connectionStatus); - const canLeavePresence = - roomStatusAndConnectionStatusRef.current.roomStatus === RoomStatus.Attached && - !INACTIVE_CONNECTION_STATES.has(roomStatusAndConnectionStatusRef.current.connectionStatus); - - // wait until the room is attached before attempting to enter, and ensure the connection is active - if (!canJoinPresence) return; - room.presence - .enter(dataRef.current?.enterWithData) - .then(() => { - logger.debug('usePresence(); entered room', { roomId: room.roomId }); - setIsPresent(true); - setError(undefined); - }) - .catch((error: unknown) => { - logger.error('usePresence(); error entering room', { error, roomId: room.roomId }); - setError(error as ErrorInfo); - }); - - return () => { - // ensure we are still in an attached state before attempting to leave and the connection is active; - // a presence.leave call will produce an exception otherwise. - if (canLeavePresence) { + logger.debug('usePresence(); entering room', { roomId: context.roomId }); + return wrapRoomPromise( + context.room, + (room: Room) => { + const canJoinPresence = + room.status === RoomStatus.Attached && !INACTIVE_CONNECTION_STATES.has(connectionStatus); + + // wait until the room is attached before attempting to enter, and ensure the connection is active + if (!canJoinPresence) { + logger.debug('usePresence(); skipping enter room', { roomStatus, connectionStatus, roomId: context.roomId }); + return () => { + // no-op + }; + } + room.presence - .leave(dataRef.current?.leaveWithData) + .enter(dataRef.current?.enterWithData) .then(() => { - logger.debug('usePresence(); left room', { roomId: room.roomId }); - setIsPresent(false); + logger.debug('usePresence(); entered room', { roomId: context.roomId }); + setIsPresent(true); setError(undefined); }) .catch((error: unknown) => { - logger.error('usePresence(); error leaving room', { error, roomId: room.roomId }); + logger.error('usePresence(); error entering room', { error, roomId: context.roomId }); setError(error as ErrorInfo); }); - } - }; - }, [room, connectionStatus, roomStatus, logger]); + + return () => { + const canLeavePresence = + room.status === RoomStatus.Attached && + !INACTIVE_CONNECTION_STATES.has(roomStatusAndConnectionStatusRef.current.connectionStatus); + + logger.debug('usePresence(); unmounting', { + roomId: context.roomId, + canLeavePresence, + roomStatus, + connectionStatus, + }); + if (canLeavePresence) { + room.presence + .leave(dataRef.current?.leaveWithData) + .then(() => { + logger.debug('usePresence(); left room', { roomId: context.roomId }); + setIsPresent(false); + setError(undefined); + }) + .catch((error: unknown) => { + logger.error('usePresence(); error leaving room', { error, roomId: context.roomId }); + setError(error as ErrorInfo); + }); + } + }; + }, + logger, + context.roomId, + ).unmount(); + }, [context, connectionStatus, roomStatus, logger]); // if provided, subscribes the user provided onDiscontinuity listener useEffect(() => { if (!onDiscontinuityRef) return; - logger.debug('usePresence(); applying onDiscontinuity listener', { roomId: room.roomId }); - const { off } = room.presence.onDiscontinuity(onDiscontinuityRef); - return () => { - logger.debug('usePresence(); removing onDiscontinuity listener', { roomId: room.roomId }); - off(); - }; - }, [room, onDiscontinuityRef, logger]); + return wrapRoomPromise( + context.room, + (room: Room) => { + const { off } = room.presence.onDiscontinuity(onDiscontinuityRef); + return () => { + logger.debug('usePresence(); removing onDiscontinuity listener', { roomId: context.roomId }); + off(); + }; + }, + logger, + context.roomId, + ).unmount(); + }, [context, onDiscontinuityRef, logger]); // memoize the methods to avoid re-renders and ensure the same instance is used const update = useCallback( (data?: PresenceData) => - room.presence.update(data).then(() => { - setIsPresent(true); - setError(undefined); + context.room.then((room: Room) => { + return room.presence.update(data).then(() => { + setIsPresent(true); + setError(undefined); + }); }), - [room], + + [context], ); return { + presence: useEventualRoomProperty((room) => room.presence), connectionStatus, connectionError, roomStatus, @@ -159,6 +192,5 @@ export const usePresence = (params?: UsePresenceParams): UsePresenceResponse => update, isPresent, error, - presence: room.presence, }; }; diff --git a/src/react/hooks/use-room-reactions.ts b/src/react/hooks/use-room-reactions.ts index 177db5c9..23117128 100644 --- a/src/react/hooks/use-room-reactions.ts +++ b/src/react/hooks/use-room-reactions.ts @@ -1,13 +1,16 @@ import { RoomReactionListener, RoomReactions, SendReactionParams } from '@ably/chat'; import { useCallback, useEffect } from 'react'; +import { wrapRoomPromise } from '../helper/room-promise.js'; import { useEventListenerRef } from '../helper/use-event-listener-ref.js'; +import { useEventualRoomProperty } from '../helper/use-eventual-room.js'; +import { useRoomContext } from '../helper/use-room-context.js'; +import { useRoomStatus } from '../helper/use-room-status.js'; import { ChatStatusResponse } from '../types/chat-status-response.js'; import { Listenable } from '../types/listenable.js'; import { StatusParams } from '../types/status-params.js'; import { useChatConnection } from './use-chat-connection.js'; import { useLogger } from './use-logger.js'; -import { useRoom } from './use-room.js'; /** * The parameters for the {@link useRoomReactions} hook. @@ -31,7 +34,7 @@ export interface UseRoomReactionsResponse extends ChatStatusResponse { /** * Provides access to the underlying {@link RoomReactions} instance of the room. */ - readonly reactions: RoomReactions; + readonly reactions?: RoomReactions; } /** @@ -45,11 +48,11 @@ export const useRoomReactions = (params?: UseRoomReactionsParams): UseRoomReacti const { currentStatus: connectionStatus, error: connectionError } = useChatConnection({ onStatusChange: params?.onConnectionStatusChange, }); - const { room, roomError, roomStatus } = useRoom({ - onStatusChange: params?.onRoomStatusChange, - }); + + const context = useRoomContext('useRoomReactions'); + const { status: roomStatus, error: roomError } = useRoomStatus(params); const logger = useLogger(); - logger.trace('useRoomReactions();', { params, roomId: room.roomId }); + logger.trace('useRoomReactions();', { params, roomId: context.roomId }); // create stable references for the listeners const listenerRef = useEventListenerRef(params?.listener); @@ -58,33 +61,50 @@ export const useRoomReactions = (params?: UseRoomReactionsParams): UseRoomReacti // if provided, subscribes the user provided discontinuity listener useEffect(() => { if (!onDiscontinuityRef) return; - logger.debug('useRoomReactions(); applying onDiscontinuity listener', { roomId: room.roomId }); - const { off } = room.reactions.onDiscontinuity(onDiscontinuityRef); - return () => { - logger.debug('useRoomReactions(); removing onDiscontinuity listener', { roomId: room.roomId }); - off(); - }; - }, [room, onDiscontinuityRef, logger]); + return wrapRoomPromise( + context.room, + (room) => { + logger.debug('useRoomReactions(); applying onDiscontinuity listener', { roomId: context.roomId }); + const { off } = room.reactions.onDiscontinuity(onDiscontinuityRef); + return () => { + logger.debug('useRoomReactions(); removing onDiscontinuity listener', { roomId: context.roomId }); + off(); + }; + }, + logger, + context.roomId, + ).unmount(); + }, [context, onDiscontinuityRef, logger]); // if provided, subscribe the user provided listener to room reactions useEffect(() => { if (!listenerRef) return; - logger.debug('useRoomReactions(); applying listener', { roomId: room.roomId }); - const { unsubscribe } = room.reactions.subscribe(listenerRef); - return () => { - logger.debug('useRoomReactions(); removing listener', { roomId: room.roomId }); - unsubscribe(); - }; - }, [room, listenerRef, logger]); + return wrapRoomPromise( + context.room, + (room) => { + logger.debug('useRoomReactions(); applying listener', { roomId: context.roomId }); + const { unsubscribe } = room.reactions.subscribe(listenerRef); + return () => { + logger.debug('useRoomReactions(); removing listener', { roomId: context.roomId }); + unsubscribe(); + }; + }, + logger, + context.roomId, + ).unmount(); + }, [context, listenerRef, logger]); - const send = useCallback((params: SendReactionParams) => room.reactions.send(params), [room.reactions]); + const send = useCallback( + (params: SendReactionParams) => context.room.then((room) => room.reactions.send(params)), + [context], + ); return { + reactions: useEventualRoomProperty((room) => room.reactions), connectionStatus, connectionError, roomStatus, roomError, send, - reactions: room.reactions, }; }; diff --git a/src/react/hooks/use-room.ts b/src/react/hooks/use-room.ts index d1559dfa..77525677 100644 --- a/src/react/hooks/use-room.ts +++ b/src/react/hooks/use-room.ts @@ -1,9 +1,9 @@ -import { ConnectionStatusChange, Room, RoomStatus, RoomStatusChange } from '@ably/chat'; -import * as Ably from 'ably'; -import { useCallback, useContext, useEffect, useState } from 'react'; +import { ConnectionStatusChange, Room, RoomStatusChange } from '@ably/chat'; +import { useCallback } from 'react'; -import { ChatRoomContext } from '../contexts/chat-room-context.js'; -import { useEventListenerRef } from '../helper/use-event-listener-ref.js'; +import { useEventualRoom } from '../helper/use-eventual-room.js'; +import { useRoomContext } from '../helper/use-room-context.js'; +import { useRoomStatus } from '../helper/use-room-status.js'; import { ChatStatusResponse } from '../types/chat-status-response.js'; import { useChatConnection } from './use-chat-connection.js'; import { useLogger } from './use-logger.js'; @@ -31,8 +31,13 @@ export interface UseRoomParams { * The return type for the {@link useRoom} hook. */ export interface UseRoomResponse extends ChatStatusResponse { + /** + * The id of the room. + */ + readonly roomId: string; + /** The room object. */ - room: Room; + room?: Room; /** * Shortcut to {@link Room.attach}. Not needed if the {@link ChatRoomProvider} @@ -47,24 +52,6 @@ export interface UseRoomResponse extends ChatStatusResponse { detach: () => Promise; } -/** - * Helper function to create a status object from a change event or the room - * status. It makes sure error isn't set at all if it's undefined in the source. - */ -const makeStatusObject = (room: Room) => ({ - status: room.status, - error: room.error, -}); - -/** - * Helper function to create a status object from a change event or the room - * status. It makes sure error isn't set at all if it's undefined in the source. - */ -const makeStatusObjectFromChangeEvent = (event: RoomStatusChange) => ({ - status: event.current, - error: event.error, -}); - /** * A hook that provides access to the current room. * @@ -72,67 +59,26 @@ const makeStatusObjectFromChangeEvent = (event: RoomStatusChange) => ({ * @returns {@link UseRoomResponse} */ export const useRoom = (params?: UseRoomParams): UseRoomResponse => { - const context = useContext(ChatRoomContext); + const context = useRoomContext('useRoom'); + const roomId = context.roomId; const logger = useLogger(); - logger.trace('useRoom();', { roomId: context?.room.roomId }); - - if (!context) { - logger.error('useRoom(); must be used within a ChatRoomProvider'); - throw new Ably.ErrorInfo('useRoom hook must be used within a ChatRoomProvider', 40000, 400); - } + logger.debug('useRoom();', { roomId: roomId }); const { currentStatus: connectionStatus, error: connectionError } = useChatConnection({ onStatusChange: params?.onConnectionStatusChange, }); - const room = context.room; - // room error and status callbacks - const [roomStatus, setRoomStatus] = useState<{ - status: RoomStatus; - error?: Ably.ErrorInfo; - }>(makeStatusObject(room)); - - // create stable references for the listeners - const onRoomStatusChangeRef = useEventListenerRef(params?.onStatusChange); - - // Effect that keeps the roomStatus state up to date - useEffect(() => { - logger.debug('useRoom(); setting up room status listener', { roomId: room.roomId }); - const { off } = room.onStatusChange((change) => { - setRoomStatus(makeStatusObjectFromChangeEvent(change)); - }); - - // update react state if real state has changed since setting up the listener - setRoomStatus((prev) => { - if (room.status !== prev.status || room.error !== prev.error) { - return makeStatusObject(room); - } - return prev; - }); - - return () => { - logger.debug('useRoom(); removing room status listener', { roomId: room.roomId }); - off(); - }; - }, [room, logger]); - - // Effect that registers and removes the user-provided callback - useEffect(() => { - if (!onRoomStatusChangeRef) return; - logger.debug('useRoom(); applying user-provided listener', { roomId: room.roomId }); - const { off } = room.onStatusChange(onRoomStatusChangeRef); - return () => { - logger.debug('useRoom(); removing user-provided listener', { roomId: room.roomId }); - off(); - }; - }, [room, onRoomStatusChangeRef, logger]); + const roomStatus = useRoomStatus({ + onRoomStatusChange: params?.onStatusChange, + }); - const attach = useCallback(() => room.attach(), [room]); - const detach = useCallback(() => room.detach(), [room]); + const attach = useCallback(() => context.room.then((room: Room) => room.attach()), [context]); + const detach = useCallback(() => context.room.then((room: Room) => room.detach()), [context]); return { - room: room, + roomId: roomId, + room: useEventualRoom(), attach: attach, detach: detach, roomStatus: roomStatus.status, diff --git a/src/react/hooks/use-typing.ts b/src/react/hooks/use-typing.ts index 6ac2025b..f096fb92 100644 --- a/src/react/hooks/use-typing.ts +++ b/src/react/hooks/use-typing.ts @@ -1,14 +1,17 @@ -import { ErrorCodes, errorInfoIs, Typing, TypingEvent, TypingListener } from '@ably/chat'; +import { ErrorCodes, errorInfoIs, RoomStatus, Typing, TypingEvent, TypingListener } from '@ably/chat'; import * as Ably from 'ably'; import { useCallback, useEffect, useState } from 'react'; +import { wrapRoomPromise } from '../helper/room-promise.js'; import { useEventListenerRef } from '../helper/use-event-listener-ref.js'; +import { useEventualRoomProperty } from '../helper/use-eventual-room.js'; +import { useRoomContext } from '../helper/use-room-context.js'; +import { useRoomStatus } from '../helper/use-room-status.js'; import { ChatStatusResponse } from '../types/chat-status-response.js'; import { Listenable } from '../types/listenable.js'; import { StatusParams } from '../types/status-params.js'; import { useChatConnection } from './use-chat-connection.js'; import { useLogger } from './use-logger.js'; -import { useRoom } from './use-room.js'; /** * The parameters for the {@link useTyping} hook. @@ -42,7 +45,7 @@ export interface UseTypingResponse extends ChatStatusResponse { /** * Provides access to the underlying {@link Typing} instance of the room. */ - readonly typingIndicators: Typing; + readonly typingIndicators?: Typing; /** * A state value representing the current error state of the hook, this will be an instance of {@link Ably.ErrorInfo} or `undefined`. @@ -63,11 +66,11 @@ export const useTyping = (params?: TypingParams): UseTypingResponse => { const { currentStatus: connectionStatus, error: connectionError } = useChatConnection({ onStatusChange: params?.onConnectionStatusChange, }); - const { room, roomError, roomStatus } = useRoom({ - onStatusChange: params?.onRoomStatusChange, - }); + + const context = useRoomContext('useTyping'); + const { status: roomStatus, error: roomError } = useRoomStatus(params); const logger = useLogger(); - logger.trace('useTyping();', { roomId: room.roomId }); + logger.trace('useTyping();', { roomId: context.roomId }); const [currentlyTyping, setCurrentlyTyping] = useState>(new Set()); const [error, setError] = useState(); @@ -89,66 +92,98 @@ export const useTyping = (params?: TypingParams): UseTypingResponse => { const setErrorState = (error?: Ably.ErrorInfo) => { if (error === undefined) { - logger.debug('useTyping(); clearing error state', { roomId: room.roomId }); + logger.debug('useTyping(); clearing error state', { roomId: context.roomId }); } else { - logger.error('useTyping(); setting error state', { error, roomId: room.roomId }); + logger.error('useTyping(); setting error state', { error, roomId: context.roomId }); } setError(error); }; - room.typing - .get() - .then((currentlyTyping) => { - if (!mounted) return; - setCurrentlyTyping(currentlyTyping); + void context.room + .then((room) => { + // If we're not attached, we can't call typing.get() right now + if (room.status === RoomStatus.Attached) { + return room.typing + .get() + .then((currentlyTyping) => { + if (!mounted) return; + setCurrentlyTyping(currentlyTyping); + }) + .catch((error: unknown) => { + const errorInfo = error as Ably.ErrorInfo; + if (!mounted || errorInfoIs(errorInfo, ErrorCodes.RoomIsReleased)) return; + + setErrorState(errorInfo); + }); + } else { + logger.debug('useTyping(); room not attached, setting currentlyTyping to empty', { roomId: context.roomId }); + setCurrentlyTyping(new Set()); + } }) - .catch((error: unknown) => { - const errorInfo = error as Ably.ErrorInfo; - if (!mounted || errorInfoIs(errorInfo, ErrorCodes.RoomIsReleased)) return; - - setErrorState(errorInfo); - }); - - const subscription = room.typing.subscribe((event) => { - setErrorState(undefined); - setCurrentlyTyping(event.currentlyTyping); - }); - - // cleanup function - return () => { - logger.debug('useTyping(); unsubscribing from typing events', { roomId: room.roomId }); - mounted = false; - subscription.unsubscribe(); - }; - }, [room, logger]); + .catch(); + + return wrapRoomPromise( + context.room, + (room) => { + logger.debug('useTyping(); subscribing to typing events', { roomId: context.roomId }); + const { unsubscribe } = room.typing.subscribe((event) => { + setErrorState(undefined); + setCurrentlyTyping(event.currentlyTyping); + }); + + return () => { + logger.debug('useTyping(); unsubscribing from typing events', { roomId: context.roomId }); + mounted = false; + unsubscribe(); + }; + }, + logger, + context.roomId, + ).unmount(); + }, [context, logger]); // if provided, subscribes the user-provided onDiscontinuity listener useEffect(() => { if (!onDiscontinuityRef) return; - logger.debug('useTyping(); applying onDiscontinuity listener', { roomId: room.roomId }); - const { off } = room.typing.onDiscontinuity(onDiscontinuityRef); - return () => { - logger.debug('useTyping(); removing onDiscontinuity listener', { roomId: room.roomId }); - off(); - }; - }, [room, onDiscontinuityRef, logger]); + return wrapRoomPromise( + context.room, + (room) => { + logger.debug('useTyping(); applying onDiscontinuity listener', { roomId: context.roomId }); + const { off } = room.typing.onDiscontinuity(onDiscontinuityRef); + return () => { + logger.debug('useTyping(); removing onDiscontinuity listener', { roomId: context.roomId }); + off(); + }; + }, + logger, + context.roomId, + ).unmount(); + }, [context, onDiscontinuityRef, logger]); // if provided, subscribe the user-provided listener to TypingEvents useEffect(() => { if (!listenerRef) return; - logger.debug('useTyping(); applying listener', { roomId: room.roomId }); - const { unsubscribe } = room.typing.subscribe(listenerRef); - return () => { - logger.debug('useTyping(); removing listener', { roomId: room.roomId }); - unsubscribe(); - }; - }, [room, listenerRef, logger]); + return wrapRoomPromise( + context.room, + (room) => { + logger.debug('useTyping(); applying listener', { roomId: context.roomId }); + const { unsubscribe } = room.typing.subscribe(listenerRef); + return () => { + logger.debug('useTyping(); removing listener', { roomId: context.roomId }); + unsubscribe(); + }; + }, + logger, + context.roomId, + ).unmount(); + }, [context, listenerRef, logger]); // memoize the methods to avoid re-renders, and ensure the same instance is used - const start = useCallback(() => room.typing.start(), [room.typing]); - const stop = useCallback(() => room.typing.stop(), [room.typing]); + const start = useCallback(() => context.room.then((room) => room.typing.start()), [context]); + const stop = useCallback(() => context.room.then((room) => room.typing.stop()), [context]); return { + typingIndicators: useEventualRoomProperty((room) => room.typing), connectionStatus, connectionError, roomStatus, @@ -157,6 +192,5 @@ export const useTyping = (params?: TypingParams): UseTypingResponse => { start, stop, currentlyTyping, - typingIndicators: room.typing, }; }; diff --git a/src/react/providers/chat-client-provider.tsx b/src/react/providers/chat-client-provider.tsx index 57dd101e..e15fbf78 100644 --- a/src/react/providers/chat-client-provider.tsx +++ b/src/react/providers/chat-client-provider.tsx @@ -36,7 +36,7 @@ export const ChatClientProvider = ({ children, client }: ChatClientProviderProps const context = React.useContext(ChatClientContext); const value: ChatClientContextValue = React.useMemo(() => { // Set the internal useReact option to true to enable React-specific agent. - client.addReactAgent(); + (client as unknown as { addReactAgent(): void }).addReactAgent(); return { ...context, [DEFAULT_CHAT_CLIENT_ID]: { client: client } }; }, [client, context]); diff --git a/src/react/providers/chat-room-provider.tsx b/src/react/providers/chat-room-provider.tsx index eaa125aa..8edca9e1 100644 --- a/src/react/providers/chat-room-provider.tsx +++ b/src/react/providers/chat-room-provider.tsx @@ -1,8 +1,8 @@ // imported for docs linking -import { Room, RoomOptions, type RoomOptionsDefaults } from '@ably/chat'; // eslint-disable-line @typescript-eslint/no-unused-vars -import React, { ReactNode, useEffect, useState } from 'react'; +import { ChatClient, Logger, Room, RoomOptions, type RoomOptionsDefaults } from '@ably/chat'; // eslint-disable-line @typescript-eslint/no-unused-vars +import React, { ReactNode, useEffect, useRef, useState } from 'react'; -import { ChatRoomContext } from '../contexts/chat-room-context.js'; +import { ChatRoomContext, ChatRoomContextType } from '../contexts/chat-room-context.js'; import { useChatClient } from '../hooks/use-chat-client.js'; import { useLogger } from '../hooks/use-logger.js'; @@ -62,6 +62,57 @@ export interface ChatRoomProviderProps { children?: ReactNode | ReactNode[] | null; } +interface RoomReleaseOp { + id: string; + options: RoomOptions; + abort: AbortController; +} + +class RoomReleaseQueue { + private readonly _queue: RoomReleaseOp[]; + private readonly _logger: Logger; + constructor(logger: Logger) { + this._queue = []; + this._logger = logger; + } + + enqueue(client: ChatClient, id: string, options: RoomOptions) { + const abort = new AbortController(); + const op: RoomReleaseOp = { id, options, abort }; + this._queue.push(op); + this._logger.debug(`RoomReleaseQueue(); enqueued release`, { id, options }); + + void Promise.resolve() + .then(() => { + if (abort.signal.aborted) { + this._logger.debug(`RoomReleaseQueue(); aborting release`, { id, options }); + return; + } + + this._logger.debug(`RoomReleaseQueue(); releasing room`, { id, options }); + void client.rooms.release(id).catch(() => void 0); + }) + .catch(() => void 0) + .finally(() => { + this._logger.debug(`RoomReleaseQueue(); dequeued release`, { id, options }); + this._queue.splice(this._queue.indexOf(op), 1); + }); + } + + abort(id: string, options: RoomOptions) { + this._logger.debug(`RoomReleaseQueue(); checking for abort`, { id, options, length: this._queue.length }); + const op = this._queue.find((op) => op.id === id && op.options === options); + if (op) { + this._logger.debug(`RoomReleaseQueue(); triggering abort`, { id, options }); + op.abort.abort(); + } + } + + get logger(): Logger { + return this._logger; + } +} + /** * Provider for a {@link Room}. Must be wrapped in a {@link ChatClientProvider}. * @@ -77,42 +128,102 @@ export const ChatRoomProvider: React.FC = ({ }) => { const client = useChatClient(); const logger = useLogger(); - logger.trace(`ChatRoomProvider();`, { roomId, options, release, attach }); + logger.debug(`ChatRoomProvider();`, { roomId, options, release, attach }); + + // Set the initial room promise, we do this in a function to avoid rooms.get being called + // every time the component re-renders + // In StrictMode this will be called twice one after the other, but that's ok + const [value, setValue] = useState(() => { + logger.debug(`ChatRoomProvider(); initializing value`, { roomId, options }); + const room = client.rooms.get(roomId, options); + room.catch(() => void 0); + return { room: room, roomId: roomId, options: options, client: client }; + }); - const [value, setValue] = useState({ room: client.rooms.get(roomId, options) }); + // Create a queue to manage release ops + const roomReleaseQueue = useRef(new RoomReleaseQueue(logger)); + // update the release queue if the logger changes - as it means we have a new client + // and only if it actually changes, not because strict mode ran it twice useEffect(() => { + // Don't create a new queue if the logger hasn't actually changed + if (roomReleaseQueue.current.logger === logger) { + return; + } + + logger.debug(`ChatRoomProvider(); updating release queue`); + roomReleaseQueue.current = new RoomReleaseQueue(logger); + }, [logger]); + + // Create an effect that manages the room state, handles attaching and releasing + useEffect(() => { + logger.debug(`ChatRoomProvider(); running lifecycle useEffect`, { roomId: roomId }); + let unmounted = false; + let resolvedRoom: Room | undefined; + const currentReleaseQueue = roomReleaseQueue.current; + + // If there was a previous release queued for this room (same id and options), abort it + currentReleaseQueue.abort(roomId, options); + + // Get the room promise const room = client.rooms.get(roomId, options); + room.catch(() => void 0); - // Update state if room instance has changed. - setValue((prev) => { - if (prev.room === room) { + // If we've had a change in the room id or options, update the value in the state + setValue((prev: ChatRoomContextType) => { + // If the room id and options haven't changed, then we don't need to do anything + if (prev.client === client && prev.roomId === roomId && prev.options === options) { + logger.debug(`ChatRoomProvider(); no change in room id or options`, { roomId, options }); return prev; } - return { room }; + + logger.debug(`ChatRoomProvider(); updating value`, { roomId, options }); + return { room: room, roomId, options, client }; }); - if (attach) { - // attachment error and/or room status is available via useRoom - // or room.status, no need to do anything with the promise here - logger.debug(`ChatRoomProvider(); attaching room`, { roomId }); - void room.attach().catch(() => { - // Ignore, the error will be available via various room status properties - }); - } + // Use the room promise to attach + void room + .then((room: Room) => { + if (unmounted) { + logger.debug(`ChatRoomProvider(); unmounted before room resolved`, { roomId: roomId }); + return; + } + + logger.debug(`ChatRoomProvider(); room resolved`, { roomId: roomId }); + resolvedRoom = room; + + if (attach) { + // attachment error and/or room status is available via useRoom + // or room.status, no need to do anything with the promise here + logger.debug(`ChatRoomProvider(); attaching room`, { roomId: roomId }); + void room.attach().catch(() => { + // Ignore, the error will be available via various room status properties + }); + } + }) + .catch(() => void 0); + + // Cleanup function return () => { - // Releasing the room will implicitly detach if needed. + unmounted = true; + logger.debug(`ChatRoomProvider(); cleaning up lifecycle useEffect`, { roomId: roomId }); + + // If we're releasing, release the room. We'll do this in an abortable way so that we don't kill off the value + // when using StrictMode if (release) { - logger.debug(`ChatRoomProvider(); releasing room`, { roomId }); - void client.rooms.release(roomId); - } else if (attach) { - logger.debug(`ChatRoomProvider(); detaching room`, { roomId }); - void room.detach().catch(() => { + logger.debug(`ChatRoomProvider(); releasing room`, { roomId: roomId }); + currentReleaseQueue.enqueue(client, roomId, options); + return; + } else if (resolvedRoom && attach) { + // If we're not releasing, but we are attaching, then we should detach the room, but only iff the room + // was resolved in time + logger.debug(`ChatRoomProvider(); detaching room`, { roomId: roomId }); + void resolvedRoom.detach().catch(() => { // Ignore, the error will be available via various room status properties }); } }; - }, [client, roomId, options, release, attach, logger]); + }, [roomId, options, logger, attach, release, client]); return {children}; }; diff --git a/test/core/chat.integration.test.ts b/test/core/chat.integration.test.ts index d3601cc0..8ad66955 100644 --- a/test/core/chat.integration.test.ts +++ b/test/core/chat.integration.test.ts @@ -49,7 +49,7 @@ describe('Chat', () => { it('should work using basic auth', async () => { const chat = newChatClient({}, ablyRealtimeClient({})); - const room = getRandomRoom(chat); + const room = await getRandomRoom(chat); // Send a message, and expect it to succeed const message = await room.messages.send({ text: 'my message' }); @@ -68,7 +68,7 @@ describe('Chat', () => { it('should work using msgpack', async () => { const chat = newChatClient(undefined, ablyRealtimeClient({ useBinaryProtocol: true })); - const room = getRandomRoom(chat); + const room = await getRandomRoom(chat); // Send a message, and expect it to succeed const message = await room.messages.send({ text: 'my message' }); diff --git a/test/core/messages.integration.test.ts b/test/core/messages.integration.test.ts index 52f8ab76..f1458af3 100644 --- a/test/core/messages.integration.test.ts +++ b/test/core/messages.integration.test.ts @@ -40,8 +40,8 @@ describe('messages integration', () => { it('sets the agent version on the channel', async (context) => { const { chat } = context; - const room = getRandomRoom(chat); - const channel = (await room.messages.channel) as RealtimeChannelWithOptions; + const room = await getRandomRoom(chat); + const channel = room.messages.channel as RealtimeChannelWithOptions; expect(channel.channelOptions.params).toEqual(expect.objectContaining({ agent: CHANNEL_OPTIONS_AGENT_STRING })); }); @@ -49,7 +49,7 @@ describe('messages integration', () => { it('should be able to send and receive chat messages', async (context) => { const { chat } = context; - const room = getRandomRoom(chat); + const room = await getRandomRoom(chat); // Attach the room await room.attach(); @@ -84,7 +84,7 @@ describe('messages integration', () => { it('should be able to delete and receive deletion messages', async (context) => { const { chat } = context; - const room = getRandomRoom(chat); + const room = await getRandomRoom(chat); // Attach the room await room.attach(); @@ -144,7 +144,7 @@ describe('messages integration', () => { it('should be able to retrieve chat history', async (context) => { const { chat } = context; - const room = getRandomRoom(chat); + const room = await getRandomRoom(chat); // Publish 3 messages const message1 = await room.messages.send({ text: 'Hello there!' }); @@ -180,7 +180,7 @@ describe('messages integration', () => { it.skip('should be able to retrieve chat deletion in history', async (context) => { const { chat } = context; - const room = getRandomRoom(chat); + const room = await getRandomRoom(chat); // Publish 1 messages const message1 = await room.messages.send({ text: 'Hello there!' }); @@ -208,7 +208,7 @@ describe('messages integration', () => { it('should be able to paginate chat history', async (context) => { const { chat } = context; - const room = getRandomRoom(chat); + const room = await getRandomRoom(chat); // Publish 4 messages const message1 = await room.messages.send({ text: 'Hello there!' }); @@ -258,7 +258,7 @@ describe('messages integration', () => { it('should be able to paginate chat history, but backwards', async (context) => { const { chat } = context; - const room = getRandomRoom(chat); + const room = await getRandomRoom(chat); // Publish 4 messages const message1 = await room.messages.send({ text: 'Hello there!' }); @@ -308,7 +308,7 @@ describe('messages integration', () => { it('should be able to send, receive and query chat messages with metadata and headers', async (context) => { const { chat } = context; - const room = getRandomRoom(chat); + const room = await getRandomRoom(chat); // Subscribe to messages and add them to a list when they arrive const messages: Message[] = []; @@ -367,7 +367,7 @@ describe('messages integration', () => { it('should be able to get history for listener from attached timeserial', async (context) => { const { chat } = context; - const room = getRandomRoom(chat); + const room = await getRandomRoom(chat); // Publish some messages const message1 = await room.messages.send({ text: 'Hello there!' }); @@ -422,7 +422,7 @@ describe('messages integration', () => { it('should be able to get history for listener with latest message timeserial', async (context) => { const { chat } = context; - const room = getRandomRoom(chat); + const room = await getRandomRoom(chat); // Subscribe to messages, which will also set up the listener subscription point const { getPreviousMessages } = room.messages.subscribe(() => {}); @@ -463,7 +463,7 @@ describe('messages integration', () => { it('should be able to get history for multiple listeners', async (context) => { const { chat } = context; - const room = getRandomRoom(chat); + const room = await getRandomRoom(chat); await room.messages.send({ text: 'Hello there!' }); await room.messages.send({ text: 'I have the high ground!' }); @@ -489,7 +489,7 @@ describe('messages integration', () => { it('handles discontinuities', async (context) => { const { chat } = context; - const room = getRandomRoom(chat); + const room = await getRandomRoom(chat); // Attach the room await room.attach(); @@ -500,7 +500,7 @@ describe('messages integration', () => { discontinuityErrors.push(error); }); - const channelSuspendable = (await room.messages.channel) as Ably.RealtimeChannel & { + const channelSuspendable = room.messages.channel as Ably.RealtimeChannel & { notifyState(state: 'suspended' | 'attached'): void; }; @@ -538,7 +538,7 @@ describe('messages integration', () => { it('handles the room being released before getPreviousMessages is called', async (context) => { const chat = context.chat; const roomId = randomRoomId(); - const room = chat.rooms.get(roomId, RoomOptionsDefaults); + const room = await chat.rooms.get(roomId, RoomOptionsDefaults); // Create a subscription to messages room.messages.subscribe(() => {}); diff --git a/test/core/messages.test.ts b/test/core/messages.test.ts index 9ae29679..fa1f5594 100644 --- a/test/core/messages.test.ts +++ b/test/core/messages.test.ts @@ -52,11 +52,11 @@ const mockPaginatedResultWithItems = (items: Message[]): MockPaginatedResult => vi.mock('ably'); describe('Messages', () => { - beforeEach(async (context) => { + beforeEach((context) => { context.realtime = new Ably.Realtime({ clientId: 'clientId', key: 'key' }); context.chatApi = new ChatApi(context.realtime, makeTestLogger()); context.room = makeRandomRoom({ chatApi: context.chatApi, realtime: context.realtime }); - const channel = await context.room.messages.channel; + const channel = context.room.messages.channel; context.emulateBackendPublish = channelEventEmitter(channel); context.emulateBackendStateChange = channelStateEventEmitter(channel); }); @@ -665,7 +665,7 @@ describe('Messages', () => { return Promise.resolve(mockPaginatedResultWithItems([])); }); - const msgChannel = await room.messages.channel; + const msgChannel = room.messages.channel; // Force ts to recognize the channel properties const channel = msgChannel as RealtimeChannel & { @@ -725,7 +725,7 @@ describe('Messages', () => { return Promise.resolve(mockPaginatedResultWithItems([])); }); - const msgChannel = await room.messages.channel; + const msgChannel = room.messages.channel; // Force ts to recognize the channel properties const channel = msgChannel as RealtimeChannel & { @@ -762,7 +762,7 @@ describe('Messages', () => { return Promise.resolve(mockPaginatedResultWithItems([])); }); - const msgChannel = await room.messages.channel; + const msgChannel = room.messages.channel; const channel = msgChannel as RealtimeChannel & { properties: { attachSerial: string | undefined; @@ -859,7 +859,7 @@ describe('Messages', () => { return Promise.resolve(mockPaginatedResultWithItems([])); }); - const msgChannel = await room.messages.channel; + const msgChannel = room.messages.channel; const channel = msgChannel as RealtimeChannel & { properties: { attachSerial: string | undefined; @@ -947,7 +947,7 @@ describe('Messages', () => { return Promise.resolve(mockPaginatedResultWithItems([])); }); - const msgChannel = await room.messages.channel; + const msgChannel = room.messages.channel; const channel = msgChannel as RealtimeChannel & { properties: { attachSerial: string | undefined; @@ -1050,7 +1050,7 @@ describe('Messages', () => { // Create a room instance const { room } = context; - const msgChannel = await room.messages.channel; + const msgChannel = room.messages.channel; const channel = msgChannel as RealtimeChannel & { properties: { attachSerial: string | undefined; diff --git a/test/core/occupancy.integration.test.ts b/test/core/occupancy.integration.test.ts index 8f211b67..09b9145f 100644 --- a/test/core/occupancy.integration.test.ts +++ b/test/core/occupancy.integration.test.ts @@ -53,7 +53,7 @@ describe('occupancy', () => { it('should be able to get the occupancy of a chat room', { timeout: TEST_TIMEOUT }, async (context) => { const { chat } = context; - const room = getRandomRoom(chat); + const room = await getRandomRoom(chat); // Get the occupancy of the room, instantaneously it will be 0 await waitForExpectedInstantaneousOccupancy(room, { @@ -61,7 +61,7 @@ describe('occupancy', () => { presenceMembers: 0, }); - const { name: channelName } = await room.messages.channel; + const { name: channelName } = room.messages.channel; // In a separate realtime client, attach to the same room const realtimeClient = ablyRealtimeClientWithToken(); @@ -108,7 +108,7 @@ describe('occupancy', () => { it('allows subscriptions to inband occupancy', { timeout: TEST_TIMEOUT }, async (context) => { const { chat } = context; - const room = getRandomRoom(chat); + const room = await getRandomRoom(chat); // Subscribe to occupancy const occupancyUpdates: OccupancyEvent[] = []; @@ -131,7 +131,7 @@ describe('occupancy', () => { // In a separate realtime client, attach to the same room const realtimeClient = ablyRealtimeClientWithToken(); - const { name: channelName } = await room.messages.channel; + const { name: channelName } = room.messages.channel; const realtimeChannel = realtimeClient.channels.get(channelName); await realtimeChannel.attach(); await realtimeChannel.presence.enter(); @@ -150,7 +150,7 @@ describe('occupancy', () => { it('handles discontinuities', async (context) => { const { chat } = context; - const room = getRandomRoom(chat); + const room = await getRandomRoom(chat); // Attach the room await room.attach(); @@ -161,7 +161,7 @@ describe('occupancy', () => { discontinuityErrors.push(error); }); - const channelSuspendable = (await room.messages.channel) as Ably.RealtimeChannel & { + const channelSuspendable = room.messages.channel as Ably.RealtimeChannel & { notifyState(state: 'suspended' | 'attached'): void; }; diff --git a/test/core/occupancy.test.ts b/test/core/occupancy.test.ts index d2e540ce..a3ba6197 100644 --- a/test/core/occupancy.test.ts +++ b/test/core/occupancy.test.ts @@ -19,11 +19,11 @@ interface TestContext { vi.mock('ably'); describe('Occupancy', () => { - beforeEach(async (context) => { + beforeEach((context) => { context.realtime = new Ably.Realtime({ clientId: 'clientId', key: 'key' }); context.chatApi = new ChatApi(context.realtime, makeTestLogger()); context.room = makeRandomRoom({ chatApi: context.chatApi, realtime: context.realtime }); - const channel = await context.room.occupancy.channel; + const channel = context.room.occupancy.channel; context.emulateOccupancyUpdate = channelEventEmitter(channel); }); diff --git a/test/core/presence.integration.test.ts b/test/core/presence.integration.test.ts index 3a81cbbb..8f753663 100644 --- a/test/core/presence.integration.test.ts +++ b/test/core/presence.integration.test.ts @@ -97,17 +97,17 @@ const waitForEvent = ( describe('UserPresence', { timeout: 10000 }, () => { // Setup before each test, create a new Ably Realtime client and a new Room - beforeEach((context) => { + beforeEach(async (context) => { context.realtime = ablyRealtimeClient(); const roomId = randomRoomId(); context.chat = newChatClient(undefined, context.realtime); context.defaultTestClientId = context.realtime.auth.clientId; - context.chatRoom = context.chat.rooms.get(roomId, { presence: RoomOptionsDefaults.presence }); + context.chatRoom = await context.chat.rooms.get(roomId, { presence: RoomOptionsDefaults.presence }); }); // Test for successful entering with clientId and custom user data it('successfully enter presence with clientId and custom user data', async (context) => { - const messageChannel = await context.chatRoom.messages.channel; + const messageChannel = context.chatRoom.messages.channel; const messageChannelName = messageChannel.name; const enterEventPromise = waitForEvent( context.realtime, @@ -131,7 +131,7 @@ describe('UserPresence', { timeout: 10000 }, () => { // Test for successful sending of presence update with clientId and custom user data it('should successfully send presence update with clientId and custom user data', async (context) => { - const messageChannel = await context.chatRoom.messages.channel; + const messageChannel = context.chatRoom.messages.channel; const messageChannelName = messageChannel.name; const enterEventPromise = waitForEvent(context.realtime, 'update', messageChannelName, (member) => { expect(member.clientId, 'client id should be equal to defaultTestClientId').toEqual(context.defaultTestClientId); @@ -150,7 +150,7 @@ describe('UserPresence', { timeout: 10000 }, () => { // Test for successful leaving of presence it('should successfully leave presence', async (context) => { - const messageChannel = await context.chatRoom.messages.channel; + const messageChannel = context.chatRoom.messages.channel; const messageChannelName = messageChannel.name; const enterEventPromise = waitForEvent( context.realtime, @@ -175,7 +175,7 @@ describe('UserPresence', { timeout: 10000 }, () => { // Test for successful fetching of presence users it('should successfully fetch presence users ', async (context) => { - const { name: channelName } = await context.chatRoom.messages.channel; + const { name: channelName } = context.chatRoom.messages.channel; // Connect 3 clients to the same channel const client1 = ablyRealtimeClient({ clientId: 'clientId1' }).channels.get(channelName); @@ -388,7 +388,7 @@ describe('UserPresence', { timeout: 10000 }, () => { discontinuityErrors.push(error); }); - const channelSuspendable = (await room.presence.channel) as Ably.RealtimeChannel & { + const channelSuspendable = room.presence.channel as Ably.RealtimeChannel & { notifyState(state: 'suspended' | 'attached'): void; }; @@ -426,7 +426,7 @@ describe('UserPresence', { timeout: 10000 }, () => { it('prevents presence entry if room option prevents it', async (context) => { const { chat } = context; - const room = chat.rooms.get(randomRoomId(), { presence: { enter: false } }); + const room = await chat.rooms.get(randomRoomId(), { presence: { enter: false } }); await room.attach(); @@ -437,7 +437,7 @@ describe('UserPresence', { timeout: 10000 }, () => { it('does not receive presence events if room option prevents it', async (context) => { const { chat } = context; - const room = chat.rooms.get(randomRoomId(), { presence: { subscribe: false } }); + const room = await chat.rooms.get(randomRoomId(), { presence: { subscribe: false } }); await room.attach(); @@ -449,7 +449,7 @@ describe('UserPresence', { timeout: 10000 }, () => { // We need to create another chat client and enter presence on the same room const chat2 = newChatClient(); - const room2 = chat2.rooms.get(room.roomId, { presence: { enter: true } }); + const room2 = await chat2.rooms.get(room.roomId, { presence: { enter: true } }); // Entering presence await room2.attach(); diff --git a/test/core/room-lifecycle-manager.test.ts b/test/core/room-lifecycle-manager.test.ts index d3676932..1389b340 100644 --- a/test/core/room-lifecycle-manager.test.ts +++ b/test/core/room-lifecycle-manager.test.ts @@ -16,9 +16,7 @@ interface TestContext { vi.mock('ably'); -interface MockContributor { - contributor: ContributesToRoomLifecycle; - channel: Ably.RealtimeChannel; +interface MockContributor extends ContributesToRoomLifecycle { emulateStateChange: (change: Ably.ChannelStateChange, update?: boolean) => void; } @@ -194,12 +192,9 @@ const makeMockContributor = ( ): MockContributor => { const contributor: MockContributor = { channel: channel, - contributor: { - discontinuityDetected() {}, - attachmentErrorCode, - detachmentErrorCode, - channel: Promise.resolve(channel), - }, + discontinuityDetected() {}, + attachmentErrorCode, + detachmentErrorCode, emulateStateChange(change: Ably.ChannelStateChange, update?: boolean) { vi.spyOn(contributor.channel, 'state', 'get').mockReturnValue(change.current); vi.spyOn(contributor.channel, 'errorReason', 'get').mockReturnValue(change.reason ?? baseError); @@ -219,7 +214,7 @@ const makeMockContributor = ( ); vi.spyOn(channel, 'state', 'get').mockReturnValue('initialized'); vi.spyOn(channel, 'errorReason', 'get').mockReturnValue(new Ably.ErrorInfo('error', 500, 50000)); - vi.spyOn(contributor.contributor, 'discontinuityDetected'); + vi.spyOn(contributor, 'discontinuityDetected'); return contributor; }; @@ -250,7 +245,7 @@ describe('room lifecycle manager', () => { it('resolves to attached immediately if already attached', (context) => new Promise((resolve, reject) => { // Force our status and contributors into attached - const status = new DefaultRoomLifecycle(makeTestLogger()); + const status = new DefaultRoomLifecycle('roomId', makeTestLogger()); context.firstContributor.emulateStateChange({ current: AblyChannelState.Attached, previous: 'initialized', @@ -277,7 +272,7 @@ describe('room lifecycle manager', () => { it('resolves to attached if existing attempt completes', async (context) => new Promise((resolve, reject) => { // Force our status and contributors into attached - const status = new DefaultRoomLifecycle(makeTestLogger()); + const status = new DefaultRoomLifecycle('roomId', makeTestLogger()); context.firstContributor.emulateStateChange({ current: AblyChannelState.Attached, previous: 'initialized', @@ -308,7 +303,7 @@ describe('room lifecycle manager', () => { it('fails if the room is in the released state', async (context) => { // Force our status into released - const status = new DefaultRoomLifecycle(makeTestLogger()); + const status = new DefaultRoomLifecycle('roomId', makeTestLogger()); context.firstContributor.emulateStateChange({ current: AblyChannelState.Detached, previous: 'initialized', @@ -327,7 +322,7 @@ describe('room lifecycle manager', () => { it('fails if the room is in the releasing state', async (context) => { // Force our status into released - const status = new DefaultRoomLifecycle(makeTestLogger()); + const status = new DefaultRoomLifecycle('roomId', makeTestLogger()); context.firstContributor.emulateStateChange({ current: AblyChannelState.Detached, previous: 'initialized', @@ -346,7 +341,7 @@ describe('room lifecycle manager', () => { it('goes via the attaching state', async (context) => { // Force our status and contributors into attached - const status = new DefaultRoomLifecycle(makeTestLogger()); + const status = new DefaultRoomLifecycle('roomId', makeTestLogger()); context.firstContributor.emulateStateChange({ current: AblyChannelState.Initialized, previous: 'initialized', @@ -390,7 +385,7 @@ describe('room lifecycle manager', () => { it('attaches channels in sequence', async (context) => { // Force our status and contributors into attached - const status = new DefaultRoomLifecycle(makeTestLogger()); + const status = new DefaultRoomLifecycle('roomId', makeTestLogger()); context.firstContributor.emulateStateChange({ current: AblyChannelState.Initialized, previous: 'initialized', @@ -437,7 +432,7 @@ describe('room lifecycle manager', () => { it('rolls back channel attachments on channel suspending and retries', async (context) => { // Force our status and contributors into attached - const status = new DefaultRoomLifecycle(makeTestLogger()); + const status = new DefaultRoomLifecycle('roomId', makeTestLogger()); context.firstContributor.emulateStateChange({ current: AblyChannelState.Initialized, previous: 'initialized', @@ -522,7 +517,7 @@ describe('room lifecycle manager', () => { it('rolls back channel attachments on channel failure', async (context) => { // Force our status and contributors into attached - const status = new DefaultRoomLifecycle(makeTestLogger()); + const status = new DefaultRoomLifecycle('roomId', makeTestLogger()); context.firstContributor.emulateStateChange({ current: AblyChannelState.Initialized, previous: 'initialized', @@ -581,7 +576,7 @@ describe('room lifecycle manager', () => { it('rolls back channel attachments on channel entering unexpected state', async (context) => { // Force our status and contributors into attached - const status = new DefaultRoomLifecycle(makeTestLogger()); + const status = new DefaultRoomLifecycle('roomId', makeTestLogger()); context.firstContributor.emulateStateChange({ current: AblyChannelState.Initialized, previous: 'initialized', @@ -640,7 +635,7 @@ describe('room lifecycle manager', () => { it('rolls back until everything completes', async (context) => { // Force our status and contributors into attached - const status = new DefaultRoomLifecycle(makeTestLogger()); + const status = new DefaultRoomLifecycle('roomId', makeTestLogger()); context.firstContributor.emulateStateChange({ current: AblyChannelState.Initialized, previous: 'initialized', @@ -710,7 +705,7 @@ describe('room lifecycle manager', () => { it('sets status to failed if rollback fails', async (context) => { // Force our status and contributors into attached - const status = new DefaultRoomLifecycle(makeTestLogger()); + const status = new DefaultRoomLifecycle('roomId', makeTestLogger()); context.firstContributor.emulateStateChange({ current: AblyChannelState.Initialized, previous: 'initialized', @@ -771,7 +766,7 @@ describe('room lifecycle manager', () => { vi.useFakeTimers(); // Force our status and contributors into attached - const status = new DefaultRoomLifecycle(makeTestLogger()); + const status = new DefaultRoomLifecycle('roomId', makeTestLogger()); context.firstContributor.emulateStateChange({ current: AblyChannelState.Initialized, previous: 'initialized', @@ -866,7 +861,7 @@ describe('room lifecycle manager', () => { describe('detachment lifecycle', () => { it('resolves to detached immediately if already detached', async (context) => { // Force our status and contributors into detached - const status = new DefaultRoomLifecycle(makeTestLogger()); + const status = new DefaultRoomLifecycle('roomId', makeTestLogger()); context.firstContributor.emulateStateChange({ current: AblyChannelState.Detached, previous: 'initialized', @@ -885,7 +880,7 @@ describe('room lifecycle manager', () => { it('rejects detach if already in failed state', async (context) => { // Force our status and contributors into detached - const status = new DefaultRoomLifecycle(makeTestLogger()); + const status = new DefaultRoomLifecycle('roomId', makeTestLogger()); context.firstContributor.emulateStateChange({ current: AblyChannelState.Detached, previous: 'initialized', @@ -905,7 +900,7 @@ describe('room lifecycle manager', () => { it('resolves to detached if existing attempt completes', (context) => new Promise((resolve, reject) => { // Force our status and contributors into detached - const status = new DefaultRoomLifecycle(makeTestLogger()); + const status = new DefaultRoomLifecycle('roomId', makeTestLogger()); context.firstContributor.emulateStateChange({ current: AblyChannelState.Detached, previous: 'initialized', @@ -936,7 +931,7 @@ describe('room lifecycle manager', () => { it('fails if the room is in the released state', async (context) => { // Force our status into released - const status = new DefaultRoomLifecycle(makeTestLogger()); + const status = new DefaultRoomLifecycle('roomId', makeTestLogger()); context.firstContributor.emulateStateChange({ current: AblyChannelState.Attached, previous: 'initialized', @@ -955,7 +950,7 @@ describe('room lifecycle manager', () => { it('fails if the room is in the releasing state', async (context) => { // Force our status into released - const status = new DefaultRoomLifecycle(makeTestLogger()); + const status = new DefaultRoomLifecycle('roomId', makeTestLogger()); context.firstContributor.emulateStateChange({ current: AblyChannelState.Attached, previous: 'initialized', @@ -974,7 +969,7 @@ describe('room lifecycle manager', () => { it('goes via the detaching state', async (context) => { // Force our status and contributors into detached - const status = new DefaultRoomLifecycle(makeTestLogger()); + const status = new DefaultRoomLifecycle('roomId', makeTestLogger()); context.firstContributor.emulateStateChange({ current: AblyChannelState.Initialized, previous: 'initialized', @@ -1018,7 +1013,7 @@ describe('room lifecycle manager', () => { it('detaches channels in sequence', async (context) => { // Force our status and contributors into attached - const status = new DefaultRoomLifecycle(makeTestLogger()); + const status = new DefaultRoomLifecycle('roomId', makeTestLogger()); context.firstContributor.emulateStateChange({ current: AblyChannelState.Attached, previous: 'initialized', @@ -1062,7 +1057,7 @@ describe('room lifecycle manager', () => { it('detaches all channels but enters failed state if one fails', async (context) => { // Force our status and contributors into attached - const status = new DefaultRoomLifecycle(makeTestLogger()); + const status = new DefaultRoomLifecycle('roomId', makeTestLogger()); context.firstContributor.emulateStateChange({ current: AblyChannelState.Attached, previous: 'initialized', @@ -1112,7 +1107,7 @@ describe('room lifecycle manager', () => { it('keeps detaching until everything completes', async (context) => { // Force our status and contributors into attached - const status = new DefaultRoomLifecycle(makeTestLogger()); + const status = new DefaultRoomLifecycle('roomId', makeTestLogger()); context.firstContributor.emulateStateChange({ current: AblyChannelState.Attached, previous: 'initialized', @@ -1168,7 +1163,7 @@ describe('room lifecycle manager', () => { vi.useFakeTimers(); // Force our status and contributors into attached - const status = new DefaultRoomLifecycle(makeTestLogger()); + const status = new DefaultRoomLifecycle('roomId', makeTestLogger()); context.firstContributor.emulateStateChange({ current: AblyChannelState.Attached, previous: 'initialized', @@ -1270,7 +1265,7 @@ describe('room lifecycle manager', () => { it('handles transient detaches', (context) => { // Force our status and contributors into attached - const status = new DefaultRoomLifecycle(makeTestLogger()); + const status = new DefaultRoomLifecycle('roomId', makeTestLogger()); context.firstContributor.emulateStateChange({ current: AblyChannelState.Attached, previous: 'initialized', @@ -1310,7 +1305,7 @@ describe('room lifecycle manager', () => { it('handles transient detaches with multiple contributors', (context) => { // Force our status and contributors into attached - const status = new DefaultRoomLifecycle(makeTestLogger()); + const status = new DefaultRoomLifecycle('roomId', makeTestLogger()); context.firstContributor.emulateStateChange({ current: AblyChannelState.Attached, previous: 'initialized', @@ -1387,7 +1382,7 @@ describe('room lifecycle manager', () => { // Transient detach is where the channel goes back to attaching as a result of a DETACHED protocol message test('transitions to attaching when transient detach times out', async (context) => { // Force our status and contributors into attached - const status = new DefaultRoomLifecycle(makeTestLogger()); + const status = new DefaultRoomLifecycle('roomId', makeTestLogger()); context.firstContributor.emulateStateChange({ current: AblyChannelState.Attached, previous: 'initialized', @@ -1441,7 +1436,7 @@ describe('room lifecycle manager', () => { it('transitions to failed if an underlying channel fails', (context) => { // Force our status and contributors into attached - const status = new DefaultRoomLifecycle(makeTestLogger()); + const status = new DefaultRoomLifecycle('roomId', makeTestLogger()); context.firstContributor.emulateStateChange({ current: AblyChannelState.Attached, previous: 'initialized', @@ -1501,7 +1496,7 @@ describe('room lifecycle manager', () => { it('recovers from an underlying channel entering the suspended state', async (context) => { // Force our status and contributors into attached - const status = new DefaultRoomLifecycle(makeTestLogger()); + const status = new DefaultRoomLifecycle('roomId', makeTestLogger()); context.firstContributor.emulateStateChange({ current: AblyChannelState.Attached, previous: 'initialized', @@ -1575,7 +1570,7 @@ describe('room lifecycle manager', () => { it('recovers from a re-attachment cycle without detaching channels', async (context) => { // Force our status and contributors into attached - const status = new DefaultRoomLifecycle(makeTestLogger()); + const status = new DefaultRoomLifecycle('roomId', makeTestLogger()); context.firstContributor.emulateStateChange({ current: AblyChannelState.Attached, previous: 'initialized', @@ -1649,7 +1644,7 @@ describe('room lifecycle manager', () => { it('recovers from a suspended channel via retries', async (context) => { // Force our status and contributors into attached - const status = new DefaultRoomLifecycle(makeTestLogger()); + const status = new DefaultRoomLifecycle('roomId', makeTestLogger()); context.firstContributor.emulateStateChange({ current: AblyChannelState.Attached, previous: 'initialized', @@ -1755,7 +1750,7 @@ describe('room lifecycle manager', () => { it('recovers from a suspended channel via many retries', async (context) => { // Force our status and contributors into attached - const status = new DefaultRoomLifecycle(makeTestLogger()); + const status = new DefaultRoomLifecycle('roomId', makeTestLogger()); context.firstContributor.emulateStateChange({ current: AblyChannelState.Attached, previous: 'initialized', @@ -1946,7 +1941,7 @@ describe('room lifecycle manager', () => { it('enters failed if a contributor fails during retry', async (context) => { // Force our status and contributors into attached - const status = new DefaultRoomLifecycle(makeTestLogger()); + const status = new DefaultRoomLifecycle('roomId', makeTestLogger()); context.firstContributor.emulateStateChange({ current: AblyChannelState.Attached, previous: 'initialized', @@ -2066,7 +2061,7 @@ describe('room lifecycle manager', () => { describe('via update', () => { it('ignores a discontinuity event if the channel never made it to attached', async (context) => { // Force our status and contributors into initialized - const status = new DefaultRoomLifecycle(makeTestLogger()); + const status = new DefaultRoomLifecycle('roomId', makeTestLogger()); context.firstContributor.emulateStateChange({ current: AblyChannelState.Initialized, previous: 'initialized', @@ -2101,7 +2096,7 @@ describe('room lifecycle manager', () => { expect(status.status).toEqual(RoomStatus.Suspended); // We should not have seen a discontinuity event - expect(context.firstContributor.contributor.discontinuityDetected).not.toHaveBeenCalled(); + expect(context.firstContributor.discontinuityDetected).not.toHaveBeenCalled(); // Now try to attach again mockChannelAttachSuccess(context.firstContributor.channel); @@ -2111,12 +2106,12 @@ describe('room lifecycle manager', () => { expect(status.status).toEqual(RoomStatus.Attached); // But we still shouldn't have seen a discontinuity event - expect(context.firstContributor.contributor.discontinuityDetected).not.toHaveBeenCalled(); + expect(context.firstContributor.discontinuityDetected).not.toHaveBeenCalled(); }); it('registers a discontinuity event immediately if fully attached and an update event is received', async (context) => { // Force our status and contributors into attached - const status = new DefaultRoomLifecycle(makeTestLogger()); + const status = new DefaultRoomLifecycle('roomId', makeTestLogger()); context.firstContributor.emulateStateChange({ current: AblyChannelState.Attached, previous: 'initialized', @@ -2164,12 +2159,12 @@ describe('room lifecycle manager', () => { // Our first contributor should have registered a discontinuity event expect(status.status).toEqual(RoomStatus.Attached); - expect(context.firstContributor.contributor.discontinuityDetected).toBeCalledWith(baseError); + expect(context.firstContributor.discontinuityDetected).toBeCalledWith(baseError); }); it('registers a discontinuity after re-attachment if room is detached at the time', async (context) => { // Force our status and contributors into attached - const status = new DefaultRoomLifecycle(makeTestLogger()); + const status = new DefaultRoomLifecycle('roomId', makeTestLogger()); context.firstContributor.emulateStateChange({ current: AblyChannelState.Attached, previous: 'initialized', @@ -2224,19 +2219,19 @@ describe('room lifecycle manager', () => { // We shouldn't have registered a discontinuity event yet expect(status.status).toEqual(RoomStatus.Detached); - expect(context.firstContributor.contributor.discontinuityDetected).not.toHaveBeenCalled(); + expect(context.firstContributor.discontinuityDetected).not.toHaveBeenCalled(); // Now re-attach the room await monitor.attach(); // Our first contributor should have registered a discontinuity event now expect(status.status).toEqual(RoomStatus.Attached); - expect(context.firstContributor.contributor.discontinuityDetected).toBeCalledWith(baseError); + expect(context.firstContributor.discontinuityDetected).toBeCalledWith(baseError); }); it('should prefer the first discontinuity event if multiple are received', async (context) => { // Force our status and contributors into attached - const status = new DefaultRoomLifecycle(makeTestLogger()); + const status = new DefaultRoomLifecycle('roomId', makeTestLogger()); context.firstContributor.emulateStateChange({ current: AblyChannelState.Attached, previous: 'initialized', @@ -2303,20 +2298,20 @@ describe('room lifecycle manager', () => { // We shouldn't have registered a discontinuity event yet expect(status.status).toEqual(RoomStatus.Detached); - expect(context.firstContributor.contributor.discontinuityDetected).not.toHaveBeenCalled(); + expect(context.firstContributor.discontinuityDetected).not.toHaveBeenCalled(); // Now re-attach the room await monitor.attach(); // Our first contributor should have registered a discontinuity event now expect(status.status).toEqual(RoomStatus.Attached); - expect(context.firstContributor.contributor.discontinuityDetected).toBeCalledWith(error1); + expect(context.firstContributor.discontinuityDetected).toBeCalledWith(error1); }); }); describe('via attach event', () => { it('does not register a discontinuity event on initial attach', async (context) => { - const status = new DefaultRoomLifecycle(makeTestLogger()); + const status = new DefaultRoomLifecycle('roomId', makeTestLogger()); context.firstContributor.emulateStateChange({ current: AblyChannelState.Initialized, previous: 'initialized', @@ -2334,12 +2329,12 @@ describe('room lifecycle manager', () => { // We shouldn't have registered a discontinuity event expect(status.status).toEqual(RoomStatus.Attached); - expect(context.firstContributor.contributor.discontinuityDetected).not.toHaveBeenCalled(); + expect(context.firstContributor.discontinuityDetected).not.toHaveBeenCalled(); }); it('registers a discontinuity immediately post-attach if one of the attach events was a failed resume', async (context) => { // Force our status and contributors into initialized - const status = new DefaultRoomLifecycle(makeTestLogger()); + const status = new DefaultRoomLifecycle('roomId', makeTestLogger()); context.firstContributor.emulateStateChange({ current: AblyChannelState.Initialized, previous: 'initialized', @@ -2383,7 +2378,7 @@ describe('room lifecycle manager', () => { // There should be no discontinuity event yet expect(status.status).toEqual(RoomStatus.Detached); - expect(context.firstContributor.contributor.discontinuityDetected).not.toHaveBeenCalled(); + expect(context.firstContributor.discontinuityDetected).not.toHaveBeenCalled(); // Now do a re-attach, but make the first channel fail to resume mockChannelAttachSuccessWithResumeFailure(context.firstContributor.channel); @@ -2393,12 +2388,12 @@ describe('room lifecycle manager', () => { // Our first contributor should have registered a discontinuity event now expect(status.status).toEqual(RoomStatus.Attached); - expect(context.firstContributor.contributor.discontinuityDetected).toBeCalledWith(baseError); + expect(context.firstContributor.discontinuityDetected).toBeCalledWith(baseError); }); it('prefers the first discontinuity event if multiple are received', async (context) => { // Force our status and contributors into initialized - const status = new DefaultRoomLifecycle(makeTestLogger()); + const status = new DefaultRoomLifecycle('roomId', makeTestLogger()); context.firstContributor.emulateStateChange({ current: AblyChannelState.Initialized, previous: 'initialized', @@ -2442,7 +2437,7 @@ describe('room lifecycle manager', () => { // There should be no discontinuity event yet expect(status.status).toEqual(RoomStatus.Detached); - expect(context.firstContributor.contributor.discontinuityDetected).not.toHaveBeenCalled(); + expect(context.firstContributor.discontinuityDetected).not.toHaveBeenCalled(); // Now we attach again, but fail the second channel so we get a detach event const firstError = new Ably.ErrorInfo('first', 1, 1); @@ -2462,7 +2457,7 @@ describe('room lifecycle manager', () => { expect(status.status).toEqual(RoomStatus.Suspended); // And still no discontinuity event - expect(context.firstContributor.contributor.discontinuityDetected).not.toHaveBeenCalled(); + expect(context.firstContributor.discontinuityDetected).not.toHaveBeenCalled(); // Now we attach in full with a second resume fail const secondError = new Ably.ErrorInfo('second', 2, 2); @@ -2483,14 +2478,14 @@ describe('room lifecycle manager', () => { // Our first contributor should have registered a discontinuity event now expect(status.status).toEqual(RoomStatus.Attached); - expect(context.firstContributor.contributor.discontinuityDetected).toBeCalledWith(firstError); + expect(context.firstContributor.discontinuityDetected).toBeCalledWith(firstError); }); }); }); describe('release lifecycle', () => { it('resolves immediately if the room is already released', async (context) => { - const status = new DefaultRoomLifecycle(makeTestLogger()); + const status = new DefaultRoomLifecycle('roomId', makeTestLogger()); status.setStatus({ status: RoomStatus.Released }); const monitor = new RoomLifecycleManager(status, [context.firstContributor], makeTestLogger(), 5); @@ -2499,7 +2494,7 @@ describe('room lifecycle manager', () => { }); it('resolves immediately and transitions to released if the room is detached', async (context) => { - const status = new DefaultRoomLifecycle(makeTestLogger()); + const status = new DefaultRoomLifecycle('roomId', makeTestLogger()); context.firstContributor.emulateStateChange({ current: AblyChannelState.Detached, previous: 'initialized', @@ -2520,7 +2515,7 @@ describe('room lifecycle manager', () => { vi.useFakeTimers(); // Force our status and contributors into detached - const status = new DefaultRoomLifecycle(makeTestLogger()); + const status = new DefaultRoomLifecycle('roomId', makeTestLogger()); context.firstContributor.emulateStateChange({ current: AblyChannelState.Attached, previous: 'initialized', @@ -2566,7 +2561,7 @@ describe('room lifecycle manager', () => { })); it('transitions via releasing', async (context) => { - const status = new DefaultRoomLifecycle(makeTestLogger()); + const status = new DefaultRoomLifecycle('roomId', makeTestLogger()); context.firstContributor.emulateStateChange({ current: AblyChannelState.Attached, previous: 'initialized', @@ -2589,7 +2584,7 @@ describe('room lifecycle manager', () => { it('detaches all contributors during release', async (context) => { // Force our status and contributors into attached - const status = new DefaultRoomLifecycle(makeTestLogger()); + const status = new DefaultRoomLifecycle('roomId', makeTestLogger()); context.firstContributor.emulateStateChange({ current: AblyChannelState.Attached, previous: 'initialized', @@ -2633,7 +2628,7 @@ describe('room lifecycle manager', () => { it('allows channels detaching into failed', async (context) => { // Force our status and contributors into attached - const status = new DefaultRoomLifecycle(makeTestLogger()); + const status = new DefaultRoomLifecycle('roomId', makeTestLogger()); context.firstContributor.emulateStateChange({ current: AblyChannelState.Attached, previous: 'initialized', @@ -2677,7 +2672,7 @@ describe('room lifecycle manager', () => { it('allows channels detaching into suspended', async (context) => { // Force our status and contributors into attached - const status = new DefaultRoomLifecycle(makeTestLogger()); + const status = new DefaultRoomLifecycle('roomId', makeTestLogger()); context.firstContributor.emulateStateChange({ current: AblyChannelState.Attached, previous: 'initialized', @@ -2729,7 +2724,7 @@ describe('room lifecycle manager', () => { it('continues to run the detach cycle until a resolution is reached', async (context) => { // Force our status and contributors into attached - const status = new DefaultRoomLifecycle(makeTestLogger()); + const status = new DefaultRoomLifecycle('roomId', makeTestLogger()); context.firstContributor.emulateStateChange({ current: AblyChannelState.Attached, previous: 'initialized', diff --git a/test/core/room-reactions.integration.test.ts b/test/core/room-reactions.integration.test.ts index e056da5d..e5026807 100644 --- a/test/core/room-reactions.integration.test.ts +++ b/test/core/room-reactions.integration.test.ts @@ -48,8 +48,8 @@ describe('room-level reactions integration test', () => { it('sets the agent version on the channel', async (context) => { const { chat } = context; - const room = getRandomRoom(chat); - const channel = (await room.messages.channel) as RealtimeChannelWithOptions; + const room = await getRandomRoom(chat); + const channel = room.messages.channel as RealtimeChannelWithOptions; expect(channel.channelOptions.params).toEqual(expect.objectContaining({ agent: CHANNEL_OPTIONS_AGENT_STRING })); }); @@ -57,7 +57,7 @@ describe('room-level reactions integration test', () => { it('sends and receives a reaction', async (context) => { const { chat } = context; - const room = getRandomRoom(chat); + const room = await getRandomRoom(chat); const expectedReactions = ['like', 'like', 'love', 'hate']; const reactions: string[] = []; @@ -81,7 +81,7 @@ describe('room-level reactions integration test', () => { it('handles discontinuities', async (context) => { const { chat } = context; - const room = getRandomRoom(chat); + const room = await getRandomRoom(chat); // Attach the room await room.attach(); @@ -92,7 +92,7 @@ describe('room-level reactions integration test', () => { discontinuityErrors.push(error); }); - const channelSuspendable = (await room.reactions.channel) as Ably.RealtimeChannel & { + const channelSuspendable = room.reactions.channel as Ably.RealtimeChannel & { notifyState(state: 'suspended' | 'attached'): void; }; diff --git a/test/core/room-reactions.test.ts b/test/core/room-reactions.test.ts index af3e1fde..31ff3f03 100644 --- a/test/core/room-reactions.test.ts +++ b/test/core/room-reactions.test.ts @@ -21,7 +21,7 @@ interface TestContext { vi.mock('ably'); describe('Reactions', () => { - beforeEach(async (context) => { + beforeEach((context) => { const clientId = 'd.vader'; context.realtime = new Ably.Realtime({ clientId: clientId, key: 'key' }); @@ -33,7 +33,7 @@ describe('Reactions', () => { }; context.room = makeRandomRoom({ chatApi: context.chatApi, realtime: context.realtime }); - const channel = await context.room.reactions.channel; + const channel = context.room.reactions.channel; context.emulateBackendPublish = channelEventEmitter(channel); vi.spyOn(channel, 'publish').mockImplementation((message: Ably.Message) => { diff --git a/test/core/room-status.test.ts b/test/core/room-status.test.ts index 0169a02c..6c9d9657 100644 --- a/test/core/room-status.test.ts +++ b/test/core/room-status.test.ts @@ -7,15 +7,15 @@ import { makeTestLogger } from '../helper/logger.ts'; const baseError = new Ably.ErrorInfo('error', 500, 50000); describe('room status', () => { - it('defaults to initializing', () => { - const status = new DefaultRoomLifecycle(makeTestLogger()); - expect(status.status).toEqual(RoomStatus.Initializing); + it('defaults to initialized', () => { + const status = new DefaultRoomLifecycle('roomId', makeTestLogger()); + expect(status.status).toEqual(RoomStatus.Initialized); expect(status.error).toBeUndefined(); }); it('listeners can be added', () => new Promise((done, reject) => { - const status = new DefaultRoomLifecycle(makeTestLogger()); + const status = new DefaultRoomLifecycle('roomId', makeTestLogger()); status.onChange((status) => { expect(status.current).toEqual(RoomStatus.Attached); expect(status.error).toEqual(baseError); @@ -28,7 +28,7 @@ describe('room status', () => { it('listeners can be removed', () => new Promise((done, reject) => { - const status = new DefaultRoomLifecycle(makeTestLogger()); + const status = new DefaultRoomLifecycle('roomId', makeTestLogger()); const { off } = status.onChange(() => { reject(new Error('Expected onChange to not be called')); }); @@ -40,7 +40,7 @@ describe('room status', () => { it('listeners can all be removed', () => new Promise((done, reject) => { - const status = new DefaultRoomLifecycle(makeTestLogger()); + const status = new DefaultRoomLifecycle('roomId', makeTestLogger()); status.onChange(() => { reject(new Error('Expected onChange to not be called')); }); diff --git a/test/core/room.integration.test.ts b/test/core/room.integration.test.ts index 629e4422..5dba72cc 100644 --- a/test/core/room.integration.test.ts +++ b/test/core/room.integration.test.ts @@ -12,9 +12,9 @@ interface TestContext { } describe('Room', () => { - beforeEach((context) => { + beforeEach(async (context) => { context.chat = newChatClient(); - context.room = getRandomRoom(context.chat); + context.room = await getRandomRoom(context.chat); }); it('should be attachable', async ({ room }) => { @@ -24,19 +24,19 @@ describe('Room', () => { expect(room.status).toEqual(RoomStatus.Attached); // If we check the underlying channels, they should be attached too - const messagesChannel = await room.messages.channel; + const messagesChannel = room.messages.channel; expect(messagesChannel.state).toEqual('attached'); - const reactionsChannel = await room.reactions.channel; + const reactionsChannel = room.reactions.channel; expect(reactionsChannel.state).toEqual('attached'); - const typingChannel = await room.typing.channel; + const typingChannel = room.typing.channel; expect(typingChannel.state).toEqual('attached'); - const presenceChannel = await room.presence.channel; + const presenceChannel = room.presence.channel; expect(presenceChannel.state).toEqual('attached'); - const occupancyChannel = await room.occupancy.channel; + const occupancyChannel = room.occupancy.channel; expect(occupancyChannel.state).toEqual('attached'); }); @@ -48,19 +48,19 @@ describe('Room', () => { expect(room.status).toEqual(RoomStatus.Detached); // If we check the underlying channels, they should be detached too - const messagesChannel = await room.messages.channel; + const messagesChannel = room.messages.channel; expect(messagesChannel.state).toEqual('detached'); - const reactionsChannel = await room.reactions.channel; + const reactionsChannel = room.reactions.channel; expect(reactionsChannel.state).toEqual('detached'); - const typingChannel = await room.typing.channel; + const typingChannel = room.typing.channel; expect(typingChannel.state).toEqual('detached'); - const presenceChannel = await room.presence.channel; + const presenceChannel = room.presence.channel; expect(presenceChannel.state).toEqual('detached'); - const occupancyChannel = await room.occupancy.channel; + const occupancyChannel = room.occupancy.channel; expect(occupancyChannel.state).toEqual('detached'); }); diff --git a/test/core/room.test.ts b/test/core/room.test.ts index cb3d2e78..5760ecd8 100644 --- a/test/core/room.test.ts +++ b/test/core/room.test.ts @@ -1,9 +1,8 @@ import * as Ably from 'ably'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { messagesChannelName } from '../../src/core/channel.ts'; import { ChatApi } from '../../src/core/chat-api.ts'; -import { ErrorCodes } from '../../src/core/errors.ts'; +import { randomId } from '../../src/core/id.ts'; import { DefaultRoom, Room } from '../../src/core/room.ts'; import { RoomLifecycleManager } from '../../src/core/room-lifecycle-manager.ts'; import { RoomOptions, RoomOptionsDefaults } from '../../src/core/room-options.ts'; @@ -18,7 +17,7 @@ vi.mock('ably'); interface TestContext { realtime: Ably.Realtime; - getRoom: (options: RoomOptions, initAfter?: Promise) => Room; + getRoom: (options: RoomOptions) => Room; } describe('Room', () => { @@ -26,12 +25,8 @@ describe('Room', () => { context.realtime = ablyRealtimeClient(); const logger = makeTestLogger(); const chatApi = new ChatApi(context.realtime, logger); - context.getRoom = (options: RoomOptions, initAfter?: Promise) => { - if (!initAfter) { - initAfter = Promise.resolve(); - } - return new DefaultRoom(randomRoomId(), options, context.realtime, chatApi, logger, initAfter); - }; + context.getRoom = (options: RoomOptions) => + new DefaultRoom(randomRoomId(), randomId(), options, context.realtime, chatApi, logger); }); describe.each([ @@ -84,7 +79,7 @@ describe('Room', () => { describe('room status', () => { it('should have a room status and error', async (context) => { const room = context.getRoom(defaultRoomOptions); - expect(room.status).toBe(RoomStatus.Initializing); + expect(room.status).toBe(RoomStatus.Initialized); // Wait for the room to be initialized await waitForRoomStatus(room, RoomStatus.Initialized); @@ -125,7 +120,7 @@ describe('Room', () => { // Now change its status to releasing lifecycle.setStatus({ status: RoomStatus.Releasing }); - expect(statuses).toEqual([RoomStatus.Initialized, RoomStatus.Failed, RoomStatus.Releasing]); + expect(statuses).toEqual([RoomStatus.Failed, RoomStatus.Releasing]); expect(errors).toEqual([new Ably.ErrorInfo('test', 50000, 500)]); // Remove the listener @@ -135,7 +130,7 @@ describe('Room', () => { lifecycle.setStatus({ status: RoomStatus.Released }); // Change should not be recorded - expect(statuses).toEqual([RoomStatus.Initialized, RoomStatus.Failed, RoomStatus.Releasing]); + expect(statuses).toEqual([RoomStatus.Failed, RoomStatus.Releasing]); }); it('should allow all subscriptions to be removed', async (context) => { @@ -167,9 +162,9 @@ describe('Room', () => { lifecycle.setStatus({ status: RoomStatus.Failed, error: new Ably.ErrorInfo('test', 50000, 500) }); // Check both subscriptions received the change - expect(statuses).toEqual([RoomStatus.Initialized, RoomStatus.Failed]); + expect(statuses).toEqual([RoomStatus.Failed]); expect(errors).toEqual([new Ably.ErrorInfo('test', 50000, 500)]); - expect(statuses2).toEqual([RoomStatus.Initialized, RoomStatus.Failed]); + expect(statuses2).toEqual([RoomStatus.Failed]); expect(errors2).toEqual([new Ably.ErrorInfo('test', 50000, 500)]); // Now remove all subscriptions @@ -177,9 +172,9 @@ describe('Room', () => { // Send another event and check that its not received lifecycle.setStatus({ status: RoomStatus.Failed }); - expect(statuses).toEqual([RoomStatus.Initialized, RoomStatus.Failed]); + expect(statuses).toEqual([RoomStatus.Failed]); expect(errors).toEqual([new Ably.ErrorInfo('test', 50000, 500)]); - expect(statuses2).toEqual([RoomStatus.Initialized, RoomStatus.Failed]); + expect(statuses2).toEqual([RoomStatus.Failed]); expect(errors2).toEqual([new Ably.ErrorInfo('test', 50000, 500)]); }); }); @@ -187,9 +182,6 @@ describe('Room', () => { describe('room release', () => { it('should release the room', async (context) => { const room = context.getRoom(defaultRoomOptions) as DefaultRoom; - - // Wait for the room to be initialized - await room.initializationStatus(); const lifecycleManager = (room as unknown as { _lifecycleManager: RoomLifecycleManager })._lifecycleManager; // Setup spies on the realtime client and the room lifecycle manager @@ -205,27 +197,24 @@ describe('Room', () => { // Every underlying feature channel should have been released expect(context.realtime.channels.release).toHaveBeenCalledTimes(5); - const messagesChannel = await room.messages.channel; + const messagesChannel = room.messages.channel; expect(context.realtime.channels.release).toHaveBeenCalledWith(messagesChannel.name); - const presenceChannel = await room.presence.channel; + const presenceChannel = room.presence.channel; expect(context.realtime.channels.release).toHaveBeenCalledWith(presenceChannel.name); - const typingChannel = await room.typing.channel; + const typingChannel = room.typing.channel; expect(context.realtime.channels.release).toHaveBeenCalledWith(typingChannel.name); - const reactionsChannel = await room.reactions.channel; + const reactionsChannel = room.reactions.channel; expect(context.realtime.channels.release).toHaveBeenCalledWith(reactionsChannel.name); - const occupancyChannel = await room.occupancy.channel; + const occupancyChannel = room.occupancy.channel; expect(context.realtime.channels.release).toHaveBeenCalledWith(occupancyChannel.name); }); it('should only release with enabled features', async (context) => { const room = context.getRoom({ typing: RoomOptionsDefaults.typing }) as DefaultRoom; - - // Wait for the room to be initialized - await room.initializationStatus(); const lifecycleManager = (room as unknown as { _lifecycleManager: RoomLifecycleManager })._lifecycleManager; // Setup spies on the realtime client and the room lifecycle manager @@ -241,18 +230,15 @@ describe('Room', () => { // Every underlying feature channel should have been released expect(context.realtime.channels.release).toHaveBeenCalledTimes(2); - const messagesChannel = await room.messages.channel; + const messagesChannel = room.messages.channel; expect(context.realtime.channels.release).toHaveBeenCalledWith(messagesChannel.name); - const typingChannel = await room.typing.channel; + const typingChannel = room.typing.channel; expect(context.realtime.channels.release).toHaveBeenCalledWith(typingChannel.name); }); it('releasing multiple times is idempotent', async (context) => { const room = context.getRoom(defaultRoomOptions) as DefaultRoom; - - // Wait for the room to be initialized - await room.initializationStatus(); const lifecycleManager = (room as unknown as { _lifecycleManager: RoomLifecycleManager })._lifecycleManager; // Setup spies on the realtime client and the room lifecycle manager @@ -274,351 +260,6 @@ describe('Room', () => { }); }); - describe('room async initialization', () => { - it('should wait for initAfter before initializing', async (context) => { - let resolve = () => {}; - const initAfter = new Promise((res) => { - resolve = res; - }); - - vi.spyOn(context.realtime.channels, 'get'); - - const room = context.getRoom(defaultRoomOptions, initAfter); - expect(room.status).toBe(RoomStatus.Initializing); - - // allow a tick to happen - await new Promise((res) => setTimeout(res, 0)); - - // expect no channel to be initialized yet - expect(context.realtime.channels.get).not.toHaveBeenCalled(); - - resolve(); - - // await for room to become initialized - await (room as DefaultRoom).initializationStatus(); - - expect(room.status).toBe(RoomStatus.Initialized); - expect(context.realtime.channels.get).toHaveBeenCalledTimes(5); // once for each feature - }); - - it('should wait for initAfter before initializing - should work even if initAfter is rejected', async (context) => { - let reject = () => {}; - const initAfter = new Promise((_res, rej) => { - reject = rej; - }); - - vi.spyOn(context.realtime.channels, 'get'); - - const room = context.getRoom(defaultRoomOptions, initAfter); - expect(room.status).toBe(RoomStatus.Initializing); - - // allow a tick to happen - await new Promise((res) => setTimeout(res, 0)); - - // expect no channel to be initialized yet - expect(context.realtime.channels.get).not.toHaveBeenCalled(); - - reject(); - - // await for room to become initialized - await (room as DefaultRoom).initializationStatus(); - - expect(room.status).toBe(RoomStatus.Initialized); - expect(context.realtime.channels.get).toHaveBeenCalledTimes(5); // once for each feature - }); - - it('should wait for features to be initialized before setting the status to initialized', async (context) => { - let resolve = () => {}; - const initAfter = new Promise((res) => { - resolve = res; - }); - - vi.spyOn(context.realtime.channels, 'get'); - - const room = context.getRoom({}, initAfter); - expect(room.status).toBe(RoomStatus.Initializing); - - let msgResolve: (channel: Ably.RealtimeChannel) => void = () => void 0; - const messagesChannelPromise = new Promise((res) => { - msgResolve = res; - }); - - vi.spyOn(room.messages, 'channel', 'get').mockReturnValue(messagesChannelPromise); - - // allow a tick to happen - await new Promise((res) => setTimeout(res, 0)); - - // expect no channel to be initialized yet - expect(context.realtime.channels.get).not.toHaveBeenCalled(); - - resolve(); - - // allow a tick to happen - await new Promise((res) => setTimeout(res, 0)); - - // must still be initializing since messages channel is not yet initialized - expect(room.status).toBe(RoomStatus.Initializing); - - // this is the actual channel - const channel = await (room.messages as unknown as { _channel: Promise })._channel; - msgResolve(channel); - - // await for room to become initialized - await (room as DefaultRoom).initializationStatus(); - - expect(room.status).toBe(RoomStatus.Initialized); - expect(context.realtime.channels.get).toHaveBeenCalledTimes(1); // once, only for messages (others are disabled) - }); - }); - - it('should not initialize any features if release called before initAfter resolved', async (context) => { - const initAfter = new Promise(() => {}); - - vi.spyOn(context.realtime.channels, 'get'); - - const room = context.getRoom(defaultRoomOptions, initAfter); - expect(room.status).toBe(RoomStatus.Initializing); - - // allow a tick to happen - await new Promise((res) => setTimeout(res, 0)); - - // expect no channel to be initialized yet - expect(context.realtime.channels.get).not.toHaveBeenCalled(); - - const initStatus = (room as DefaultRoom).initializationStatus(); - - // release the room before it is initialized - const releasePromise = (room as DefaultRoom).release(); - - // expect the release promise to be the initAfter promise because - // we're in the case where the "previous" room hasn't finished - // releasing and the "next" room will still have to wait for it. - expect(releasePromise === initAfter).toBe(true); - - expect(context.realtime.channels.get).not.toHaveBeenCalled(); - await expect(initStatus).rejects.toBeErrorInfoWithCode(ErrorCodes.RoomIsReleased); - expect(room.status).toBe(RoomStatus.Released); - }); - - it('should finish full initialization if released called after features started initializing', async (context) => { - let resolve = () => {}; - const initAfter = new Promise((res) => { - resolve = res; - }); - - vi.spyOn(context.realtime.channels, 'get'); - - const room = context.getRoom({}, initAfter); - expect(room.status).toBe(RoomStatus.Initializing); - - // record all status changes - const statuses: string[] = []; - room.onStatusChange((status) => { - statuses.push(status.current); - }); - - let msgResolve: (channel: Ably.RealtimeChannel) => void = () => void 0; - const messagesChannelPromise = new Promise((res) => { - msgResolve = res; - }); - - vi.spyOn(room.messages, 'channel', 'get').mockReturnValue(messagesChannelPromise); - - // allow a tick to happen - await new Promise((res) => setTimeout(res, 0)); - - // expect no channel to be initialized yet - expect(context.realtime.channels.get).not.toHaveBeenCalled(); - - resolve(); - - // allow a tick to happen - await new Promise((res) => setTimeout(res, 0)); - - // must still be initializing since messages channel is not yet initialized - expect(room.status).toBe(RoomStatus.Initializing); - const releasePromise = (room as DefaultRoom).release(); - - // expect the release promise to be different to initAfter now, because - // the room has already started initialization of features. - expect(releasePromise !== initAfter).toBe(true); - - // this is the actual channel. this should get resolved because - // initialization of channels must complete - const channel = await (room.messages as unknown as { _channel: Promise })._channel; - msgResolve(channel); - - await releasePromise; - - expect(statuses).toEqual([RoomStatus.Initialized, RoomStatus.Releasing, RoomStatus.Released]); - }); - - it('should wait for initialization to finish before attaching the room', async (context) => { - let resolve = () => {}; - const initAfter = new Promise((res) => { - resolve = res; - }); - - const room = context.getRoom({}, initAfter); - - // expect no init to have started - vi.spyOn(context.realtime.channels, 'get'); - expect(context.realtime.channels.get).not.toHaveBeenCalled(); - - // spy on the channel - const channelName = messagesChannelName(room.roomId); - const channel = context.realtime.channels.get(channelName); - vi.spyOn(channel, 'attach'); - - expect(channel.attach).not.toHaveBeenCalled(); - - const attachPromise = room.attach(); - - expect(channel.attach).not.toHaveBeenCalled(); - - let attached = false; - let attachedError = false; - attachPromise - .then(() => { - attached = true; - }) - .catch(() => { - attachedError = true; - }); - expect(attached).toBe(false); - expect(attachedError).toBe(false); - - // allow a tick to happen - await new Promise((resolve) => setTimeout(resolve, 0)); - - expect(channel.attach).not.toHaveBeenCalled(); - expect(attached).toBe(false); - expect(attachedError).toBe(false); - expect(channel.attach).not.toHaveBeenCalled(); - - resolve(); - await attachPromise; - - expect(channel.attach).toHaveBeenCalled(); - expect(attached).toBe(true); - expect(attachedError).toBe(false); - }); - - it('should fail attaching if release called before initialization starts', async (context) => { - const initAfter = new Promise(() => {}); - - const room = context.getRoom({}, initAfter); - - // expect no init to have started - vi.spyOn(context.realtime.channels, 'get'); - expect(context.realtime.channels.get).not.toHaveBeenCalled(); - - // spy on the channel - const channelName = messagesChannelName(room.roomId); - const channel = context.realtime.channels.get(channelName); - vi.spyOn(channel, 'attach'); - - expect(channel.attach).not.toHaveBeenCalled(); - - const attachPromise = room.attach(); - - expect(channel.attach).not.toHaveBeenCalled(); - let attached = false; - let attachedError = false; - attachPromise - .then(() => { - attached = true; - }) - .catch(() => { - attachedError = true; - }); - expect(attached).toBe(false); - expect(attachedError).toBe(false); - - // allow a tick to happen - await new Promise((resolve) => setTimeout(resolve, 0)); - - expect(channel.attach).not.toHaveBeenCalled(); - expect(attached).toBe(false); - expect(attachedError).toBe(false); - expect(channel.attach).not.toHaveBeenCalled(); - - // Can't await for release here because it returns the original initAfter promise. - const releasePromise = (room as DefaultRoom).release(); - expect(releasePromise === initAfter).toBe(true); - void releasePromise; - - // We can await for attachPromise to be rejected instead. - await new Promise((accept) => { - attachPromise.catch(() => { - accept(); - }); - }); - - expect(channel.attach).not.toHaveBeenCalled(); - expect(attached).toBe(false); - expect(attachedError).toBe(true); - }); - - it('should fail attaching if release called before initialization finishes (and after initialization starts)', async (context) => { - let resolve = () => {}; - const initAfter = new Promise((res) => { - resolve = res; - }); - - vi.spyOn(context.realtime.channels, 'get'); - - const room = context.getRoom({}, initAfter); - - // spy on the channel - const channelName = messagesChannelName(room.roomId); - const channel = context.realtime.channels.get(channelName); - vi.spyOn(channel, 'attach'); - - let msgResolve: (channel: Ably.RealtimeChannel) => void = () => void 0; - const messagesChannelPromise = new Promise((res) => { - msgResolve = res; - }); - - vi.spyOn(room.messages, 'channel', 'get').mockReturnValue(messagesChannelPromise); - - // allow a tick to happen - await new Promise((res) => setTimeout(res, 0)); - - // attach room - const attachPromise = room.attach(); - let attached = false; - let attachedError = false; - attachPromise - .then(() => { - attached = true; - }) - .catch(() => { - attachedError = true; - }); - expect(attached).toBe(false); - expect(attachedError).toBe(false); - - // allow initialization to start - resolve(); - - // release the room - void (room as DefaultRoom).release(); - - // resolve the channel (trigger finish init) - msgResolve(channel); - - await new Promise((resolve) => { - attachPromise.catch(() => { - resolve(); - }); - }); - - expect(channel.attach).not.toHaveBeenCalled(); - expect(attached).toBe(false); - expect(attachedError).toBe(true); - }); - it('can be released immediately without unhandled rejections', async (context) => { const room = context.getRoom(defaultRoomOptions); diff --git a/test/core/rooms.integration.test.ts b/test/core/rooms.integration.test.ts index 375caaee..54c3b32b 100644 --- a/test/core/rooms.integration.test.ts +++ b/test/core/rooms.integration.test.ts @@ -1,3 +1,4 @@ +import { RoomOptionsDefaults } from '@ably/chat'; import * as Ably from 'ably'; import { describe, expect, it } from 'vitest'; @@ -7,40 +8,55 @@ import { newChatClient } from '../helper/chat.ts'; import { waitForRoomStatus } from '../helper/room.ts'; describe('Rooms', () => { - it('throws an error if you create the same room with different options', () => { + it('throws an error if you create the same room with different options', async () => { const chat = newChatClient({ logLevel: LogLevel.Silent }); - chat.rooms.get('test', { typing: { timeoutMs: 1000 } }); - expect(() => { - chat.rooms.get('test', { typing: { timeoutMs: 2000 } }); - }).toThrowErrorInfoWithCode(40000); + await chat.rooms.get('test', { typing: { timeoutMs: 1000 } }); + await expect(chat.rooms.get('test', { typing: { timeoutMs: 2000 } })).rejects.toBeErrorInfoWithCode(40000); }); - it('gets the same room if you create it with the same options', () => { + it('gets the same room if you create it with the same options', async () => { const chat = newChatClient(); - const room1 = chat.rooms.get('test', { typing: { timeoutMs: 1000 } }); - const room2 = chat.rooms.get('test', { typing: { timeoutMs: 1000 } }); + const room1 = await chat.rooms.get('test', { typing: { timeoutMs: 1000 } }); + const room2 = await chat.rooms.get('test', { typing: { timeoutMs: 1000 } }); expect(room1).toBe(room2); }); it('releases a room', async () => { // Create a room, then release, then create another room with different options const chat = newChatClient(); - const room1 = chat.rooms.get('test', { typing: { timeoutMs: 1000 } }); + const room1 = await chat.rooms.get('test', { typing: { timeoutMs: 1000 } }); await chat.rooms.release('test'); - const room = chat.rooms.get('test', { typing: { timeoutMs: 2000 } }); + const room = await chat.rooms.get('test', { typing: { timeoutMs: 2000 } }); expect(room.options().typing?.timeoutMs).toBe(2000); expect(room).not.toBe(room1); }); + it('releases and recreates a room in cycle', async () => { + // Create a room, then release, then create another room with different options + // We include presence options here because that invokes a change to channel modes - which would flag up + // an error if we were doing releases in the wrong order etc + const chat = newChatClient(); + const room1 = await chat.rooms.get('test', { typing: { timeoutMs: 1000 }, presence: RoomOptionsDefaults.presence }); + await room1.attach(); + await chat.rooms.release('test'); + + const room2 = await chat.rooms.get('test', { typing: { timeoutMs: 2000 }, presence: RoomOptionsDefaults.presence }); + await room2.attach(); + await chat.rooms.release('test'); + + await chat.rooms.get('test', { typing: { timeoutMs: 3000 }, presence: RoomOptionsDefaults.presence }); + await chat.rooms.release('test'); + }); + it('releases a failed room', async () => { // Create a room, fail it, then release. const chat = newChatClient(); - const room = chat.rooms.get('test', { typing: { timeoutMs: 1000 } }); + const room = await chat.rooms.get('test', { typing: { timeoutMs: 1000 } }); // Make sure our room is attached await room.attach(); - const channelFailable = (await room.messages.channel) as Ably.RealtimeChannel & { + const channelFailable = room.messages.channel as Ably.RealtimeChannel & { notifyState(state: 'failed'): void; }; channelFailable.notifyState('failed'); diff --git a/test/core/rooms.test.ts b/test/core/rooms.test.ts index 266f6101..a47ac697 100644 --- a/test/core/rooms.test.ts +++ b/test/core/rooms.test.ts @@ -1,8 +1,8 @@ +import { ErrorCodes } from '@ably/chat'; import * as Ably from 'ably'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { normalizeClientOptions } from '../../src/core/config.ts'; -import { DefaultRoom } from '../../src/core/room.ts'; import { RoomOptions } from '../../src/core/room-options.ts'; import { DefaultRooms, Rooms } from '../../src/core/rooms.ts'; import { randomRoomId } from '../helper/identifier.ts'; @@ -17,20 +17,50 @@ interface TestContext { rooms: Rooms; } -describe('Room', () => { +describe('rooms', () => { beforeEach((context) => { context.realtime = ablyRealtimeClient(); const logger = makeTestLogger(); context.rooms = new DefaultRooms(context.realtime, normalizeClientOptions({}), logger); }); - describe('room get-release lifecycle', () => { - it('should return a the same room if rooms.get called twice', (context) => { + describe('room get', () => { + it('throws error if room with same ID but different options already exists', async (context) => { const roomId = randomRoomId(); const roomOptions: RoomOptions = defaultRoomOptions; const room1 = context.rooms.get(roomId, roomOptions); - const room2 = context.rooms.get(roomId, roomOptions); - expect(room1 === room2).toBeTruthy(); + const room2 = context.rooms.get(roomId, { reactions: {} }); + await expect(room1).resolves.toBeDefined(); + await expect(room2).rejects.toBeErrorInfo({ + statusCode: 400, + code: 40000, + message: 'room already exists with different options', + }); + }); + + it('returns a fresh room instance if room does not exist', async (context) => { + const roomId = randomRoomId(); + const roomOptions: RoomOptions = defaultRoomOptions; + const room = context.rooms.get(roomId, roomOptions); + await expect(room).resolves.toBeDefined(); + }); + + it('returns the same room instance if room already exists', async (context) => { + const roomId = randomRoomId(); + const roomOptions: RoomOptions = defaultRoomOptions; + const room1 = await context.rooms.get(roomId, roomOptions); + const room2 = await context.rooms.get(roomId, roomOptions); + expect(room1).toBe(room2); + }); + }); + + describe('room get-release lifecycle', () => { + it('should return a the same room if rooms.get called twice', async (context) => { + const roomId = randomRoomId(); + const roomOptions: RoomOptions = defaultRoomOptions; + const room1 = await context.rooms.get(roomId, roomOptions); + const room2 = await context.rooms.get(roomId, roomOptions); + expect(room1).toBe(room2); }); it('should return a fresh room in room.get if previous one is currently releasing', (context) => { @@ -39,42 +69,98 @@ describe('Room', () => { const room1 = context.rooms.get(roomId, roomOptions); void context.rooms.release(roomId); const room2 = context.rooms.get(roomId, roomOptions); - expect(room1 === room2).not.toBeTruthy(); + expect(room1).not.toBe(room2); }); - it('should correctly forward releasing promises to new room instances', async (context) => { + it('releasing a room should abort any get operations', async (context) => { const roomId = randomRoomId(); const roomOptions: RoomOptions = defaultRoomOptions; const room1 = context.rooms.get(roomId, roomOptions); + const releasePromise1 = context.rooms.release(roomId); + const room2 = context.rooms.get(roomId, roomOptions); + const releasedPromise2 = context.rooms.release(roomId); - let resolveReleasePromise: () => void = () => void 0; - const releasePromise = new Promise((resolve) => { - resolveReleasePromise = resolve; + await expect(releasePromise1).resolves.toBeUndefined(); + await expect(room1).resolves.toBeDefined(); + await expect(room2).rejects.toBeErrorInfo({ + statusCode: 400, + code: ErrorCodes.RoomReleasedBeforeOperationCompleted, + message: 'room released before get operation could complete', }); + await expect(releasedPromise2).resolves.toBeUndefined(); + }); - vi.spyOn(room1 as DefaultRoom, 'release').mockImplementationOnce(() => { - return releasePromise; + it('releasing a room should abort any get operations from previous get', async (context) => { + const roomId = randomRoomId(); + const roomOptions: RoomOptions = defaultRoomOptions; + const room1 = context.rooms.get(roomId, roomOptions); + const releasePromise1 = context.rooms.release(roomId); + const room2 = context.rooms.get(roomId, roomOptions); + const releasedPromise2 = context.rooms.release(roomId); + const room3 = context.rooms.get(roomId, roomOptions); + const room4 = context.rooms.get(roomId, roomOptions); + const releasePromise3 = context.rooms.release(roomId); + const releasePromise4 = context.rooms.release(roomId); + const finalRoom = context.rooms.get(roomId, roomOptions); + + await expect(room1).resolves.toBeDefined(); + await expect(releasePromise1).resolves.toBeUndefined(); + await expect(room2).rejects.toBeErrorInfo({ + statusCode: 400, + code: ErrorCodes.RoomReleasedBeforeOperationCompleted, + message: 'room released before get operation could complete', }); + await expect(releasedPromise2).resolves.toBeUndefined(); + await expect(room3).rejects.toBeErrorInfo({ + statusCode: 400, + code: ErrorCodes.RoomReleasedBeforeOperationCompleted, + message: 'room released before get operation could complete', + }); + await expect(room4).rejects.toBeErrorInfo({ + statusCode: 400, + code: ErrorCodes.RoomReleasedBeforeOperationCompleted, + message: 'room released before get operation could complete', + }); + await expect(releasePromise3).resolves.toBeUndefined(); + await expect(releasePromise4).resolves.toBeUndefined(); + await expect(finalRoom).resolves.toBeDefined(); + + const initialRoom = await room1; + const finalRoomInstance = await finalRoom; + expect(initialRoom).not.toBe(finalRoomInstance); + }); + it('multiple gets on a releasing room return the same room instance', async (context) => { + const roomId = randomRoomId(); + const roomOptions: RoomOptions = defaultRoomOptions; + const room1 = context.rooms.get(roomId, roomOptions); + const releasePromise1 = context.rooms.release(roomId); const room2 = context.rooms.get(roomId, roomOptions); + const room3 = context.rooms.get(roomId, roomOptions); + const room4 = context.rooms.get(roomId, roomOptions); - // this should forward the previous room's release() promise - const secondReleasePromise = (room2 as DefaultRoom).release(); + await expect(room1).resolves.toBeDefined(); + await expect(releasePromise1).resolves.toBeUndefined(); - // test that when we resolve the first promise the second one gets resolved - let secondReleasePromiseResolved = false; - void secondReleasePromise.then(() => { - secondReleasePromiseResolved = true; - }); + const resolvedRoom2 = await room2; + const resolvedRoom3 = await room3; + const resolvedRoom4 = await room4; - // make sure second one doesn't just get resolved by itself - await new Promise((resolve) => setTimeout(resolve, 0)); - expect(secondReleasePromiseResolved).toBeFalsy(); + expect(resolvedRoom2).toBe(resolvedRoom3); + expect(resolvedRoom2).toBe(resolvedRoom4); + }); + + it('no-ops if releasing room that does not exist', async (context) => { + const roomId = randomRoomId(); + const releasePromise = context.rooms.release(roomId); + await expect(releasePromise).resolves.toBeUndefined(); + }); + }); - // resolve first, wait for second - resolveReleasePromise(); - await secondReleasePromise; - expect(secondReleasePromiseResolved).toBeTruthy(); + describe('client options', () => { + it('returns the client options', (context) => { + const clientOptions = context.rooms.clientOptions; + expect(clientOptions).toEqual(normalizeClientOptions({})); }); }); }); diff --git a/test/core/typing.integration.test.ts b/test/core/typing.integration.test.ts index 9f198d80..bfb8a714 100644 --- a/test/core/typing.integration.test.ts +++ b/test/core/typing.integration.test.ts @@ -58,11 +58,11 @@ const waitForTypingEvent = (events: TypingEvent[], expected: TypingEvent) => { describe('Typing', () => { // Setup before each test, create a new Ably Realtime client and a new Room - beforeEach((context) => { + beforeEach(async (context) => { context.realtime = ablyRealtimeClient(); context.chat = new DefaultRooms(context.realtime, normalizeClientOptions({}), makeTestLogger()); context.clientId = context.realtime.auth.clientId; - context.chatRoom = context.chat.get(randomRoomId(), { typing: { timeoutMs: 500 } }); + context.chatRoom = await context.chat.get(randomRoomId(), { typing: { timeoutMs: 500 } }); }); // Test to check if typing starts and then stops typing after the default timeout @@ -135,14 +135,17 @@ describe('Typing', () => { const roomOptions = { typing: { timeoutMs: 15000 } }; + const client1Room = await client1.get(context.chatRoom.roomId, roomOptions); + const client2Room = await client2.get(context.chatRoom.roomId, roomOptions); + // Attach the rooms await context.chatRoom.attach(); - await client1.get(context.chatRoom.roomId, roomOptions).attach(); - await client2.get(context.chatRoom.roomId, roomOptions).attach(); + await client1Room.attach(); + await client2Room.attach(); // send typing event for client1 and client2 - await client1.get(context.chatRoom.roomId, roomOptions).typing.start(); - await client2.get(context.chatRoom.roomId, roomOptions).typing.start(); + await client1Room.typing.start(); + await client2Room.typing.start(); // Wait for the typing events to be received await waitForTypingEvent(events, { currentlyTyping: new Set([clientId1, clientId2]) }); // Get the currently typing client ids @@ -153,7 +156,7 @@ describe('Typing', () => { events = []; // Try stopping typing for one of the clients - await client1.get(context.chatRoom.roomId, roomOptions).typing.stop(); + await client1Room.typing.stop(); // Wait for the typing events to be received await waitForTypingEvent(events, { currentlyTyping: new Set([clientId2]) }); // Get the currently typing client ids @@ -163,7 +166,7 @@ describe('Typing', () => { expect(currentlyTypingClientIdsAfterStop.has(clientId1), 'client1 should not be typing').toEqual(false); // stop typing 2, clears typing timeout - await client2.get(context.chatRoom.roomId, roomOptions).typing.stop(); + await client2Room.typing.stop(); }, TEST_TIMEOUT, ); @@ -171,7 +174,7 @@ describe('Typing', () => { it('handles discontinuities', async (context) => { const { chat } = context; - const room = chat.get(randomRoomId(), { typing: RoomOptionsDefaults.typing }); + const room = await chat.get(randomRoomId(), { typing: RoomOptionsDefaults.typing }); // Attach the room await room.attach(); @@ -184,7 +187,7 @@ describe('Typing', () => { discontinuityErrors.push(error); }); - const channelSuspendable = (await room.typing.channel) as Ably.RealtimeChannel & { + const channelSuspendable = room.typing.channel as Ably.RealtimeChannel & { notifyState(state: 'suspended' | 'attached'): void; }; diff --git a/test/core/typing.test.ts b/test/core/typing.test.ts index 776794fe..70d03db4 100644 --- a/test/core/typing.test.ts +++ b/test/core/typing.test.ts @@ -58,11 +58,11 @@ const waitForMessages = (messages: TypingEvent[], expectedCount: number, timeout }; describe('Typing', () => { - beforeEach(async (context) => { + beforeEach((context) => { context.realtime = new Ably.Realtime({ clientId: 'clientId', key: 'key' }); context.chatApi = new ChatApi(context.realtime, makeTestLogger()); context.room = makeRandomRoom(context); - const channel = await context.room.typing.channel; + const channel = context.room.typing.channel; context.emulateBackendPublish = channelPresenceEventEmitter(channel); }); @@ -90,7 +90,7 @@ describe('Typing', () => { it('when stop is called, immediately stops typing', async (context) => { const { realtime, room } = context; - const channel = await room.typing.channel; + const channel = room.typing.channel; const presence = realtime.channels.get(channel.name).presence; // If stop is called, it should call leaveClient @@ -122,7 +122,7 @@ describe('Typing', () => { allEvents.push(event); }); - const channel = await context.room.typing.channel; + const channel = context.room.typing.channel; let arrayToReturn = presenceGetResponse(['otherClient']); @@ -187,7 +187,7 @@ describe('Typing', () => { receivedEvents2.push(event); }); - const channel = await context.room.typing.channel; + const channel = context.room.typing.channel; let arrayToReturn = presenceGetResponse(['otherClient']); vi.spyOn(channel.presence, 'get').mockImplementation(() => { return Promise.resolve(arrayToReturn); @@ -290,7 +290,7 @@ describe('Typing', () => { it('should not emit the same typing set twice', async (context) => { const { room } = context; - const channel = await context.room.typing.channel; + const channel = context.room.typing.channel; // Add a listener const events: TypingEvent[] = []; @@ -340,7 +340,7 @@ describe('Typing', () => { it('should retry on failure', async (context) => { const { room } = context; - const channel = await context.room.typing.channel; + const channel = context.room.typing.channel; // Add a listener const events: TypingEvent[] = []; @@ -371,7 +371,7 @@ describe('Typing', () => { it('should not return stale responses even if they resolve out of order', async (context) => { const { room } = context; - const channel = await context.room.typing.channel; + const channel = context.room.typing.channel; // Add a listener const events: TypingEvent[] = []; diff --git a/test/helper/room.ts b/test/helper/room.ts index 0a149a10..3bfcb87b 100644 --- a/test/helper/room.ts +++ b/test/helper/room.ts @@ -4,6 +4,7 @@ import { vi } from 'vitest'; import { ChatClient } from '../../src/core/chat.ts'; import { ChatApi } from '../../src/core/chat-api.ts'; import { ErrorCodes } from '../../src/core/errors.ts'; +import { randomId } from '../../src/core/id.ts'; import { DefaultRoom, Room } from '../../src/core/room.ts'; import { RoomOptions, RoomOptionsDefaults } from '../../src/core/room-options.ts'; import { RoomLifecycle, RoomStatus } from '../../src/core/room-status.ts'; @@ -26,7 +27,8 @@ export const waitForRoomError = async (status: RoomLifecycle, expected: ErrorCod }; // Gets a random room with default options from the chat client -export const getRandomRoom = (chat: ChatClient): Room => chat.rooms.get(randomRoomId(), defaultRoomOptions); +export const getRandomRoom = async (chat: ChatClient): Promise => + chat.rooms.get(randomRoomId(), defaultRoomOptions); // Return a default set of room options export const defaultRoomOptions: RoomOptions = { @@ -44,12 +46,5 @@ export const makeRandomRoom = (params: { const realtime = params.realtime ?? ablyRealtimeClient(); const chatApi = params.chatApi ?? new ChatApi(realtime, logger); - return new DefaultRoom( - randomRoomId(), - params.options ?? defaultRoomOptions, - realtime, - chatApi, - logger, - Promise.resolve(), - ); + return new DefaultRoom(randomRoomId(), randomId(), params.options ?? defaultRoomOptions, realtime, chatApi, logger); }; diff --git a/test/helper/wait-for-eventual-hook.ts b/test/helper/wait-for-eventual-hook.ts new file mode 100644 index 00000000..30074b6a --- /dev/null +++ b/test/helper/wait-for-eventual-hook.ts @@ -0,0 +1,26 @@ +import { expect, vi } from 'vitest'; + +export const waitForEventualHookValueToBeDefined = async ( + result: { current: T }, + check: (value: T) => unknown, +): Promise => { + return vi.waitFor( + () => { + expect(check(result.current)).toBeDefined(); + }, + { timeout: 3000 }, + ); +}; + +export const waitForEventualHookValue = async ( + result: { current: HookReturn }, + expected: Value, + getValue: (current: HookReturn) => Value | undefined, +): Promise => { + return vi.waitFor( + () => { + expect(getValue(result.current)).toBe(expected); + }, + { timeout: 3000 }, + ); +}; diff --git a/test/react/helper/room-promise.test.ts b/test/react/helper/room-promise.test.ts new file mode 100644 index 00000000..3d9249b9 --- /dev/null +++ b/test/react/helper/room-promise.test.ts @@ -0,0 +1,94 @@ +import { Room } from '@ably/chat'; +import { describe, expect, it, vi } from 'vitest'; + +import { wrapRoomPromise } from '../../../src/react/helper/room-promise.ts'; +import { makeTestLogger } from '../../helper/logger.ts'; +import { makeRandomRoom } from '../../helper/room.ts'; + +describe('room-promise', () => { + it('should mount and unmount with promise resolution', async () => { + let shouldResolve = false; + let hasResolved = false; + const roomPromise = new Promise((resolve) => { + const interval = setInterval(() => { + if (shouldResolve) { + clearInterval(interval); + resolve(makeRandomRoom({})); + } + }, 150); + }); + + // Wrap the promise + let hasUnmounted = false; + const wrapped = wrapRoomPromise( + roomPromise, + (room) => { + hasResolved = true; + expect(room).toBeDefined(); + + return () => { + hasUnmounted = true; + }; + }, + makeTestLogger(), + 'test-room', + ); + + // Now say the promise should resolve + shouldResolve = true; + await vi.waitFor(() => { + expect(hasResolved).toBe(true); + }); + + // Now call unmount + wrapped.unmount()(); + + expect(hasUnmounted).toBe(true); + }); + + it('should mount and unmount before promise resolution', async () => { + let shouldResolve = false; + const roomPromise = new Promise((resolve) => { + const interval = setInterval(() => { + if (shouldResolve) { + clearInterval(interval); + resolve(makeRandomRoom({})); + } + }, 150); + }); + + // Wrap the promise + const wrapped = wrapRoomPromise( + roomPromise, + () => { + // Should never be called + expect(true).toBe(false); + + return () => { + expect(true).toBe(false); + }; + }, + makeTestLogger(), + 'test-room', + ); + + // Now call unmount + wrapped.unmount()(); + + // Now say the promise should resolve + shouldResolve = true; + + // Wait for 5 intervals of 150ms to confirm the callback was never called + for (let i = 0; i < 5; i++) { + await new Promise((resolve) => setTimeout(resolve, 150)); + } + + // Calling unmount again should be a noop + wrapped.unmount()(); + + // Wait for another set of intervals to confirm the callback was never called + for (let i = 0; i < 5; i++) { + await new Promise((resolve) => setTimeout(resolve, 150)); + } + }); +}); diff --git a/test/react/helper/use-eventual-room.test.tsx b/test/react/helper/use-eventual-room.test.tsx new file mode 100644 index 00000000..1d16bc3e --- /dev/null +++ b/test/react/helper/use-eventual-room.test.tsx @@ -0,0 +1,111 @@ +import { Logger, Room, RoomOptionsDefaults } from '@ably/chat'; +import { cleanup, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useEventualRoom, useEventualRoomProperty } from '../../../src/react/helper/use-eventual-room.ts'; +import { makeTestLogger } from '../../helper/logger.ts'; +import { makeRandomRoom } from '../../helper/room.ts'; + +let mockRoom: Room; +let mockRoomContext: { room: Promise }; +let mockLogger: Logger; + +vi.mock('../../../src/react/helper/use-room-context.js', () => ({ + useRoomContext: () => mockRoomContext, +})); + +vi.mock('../../../src/react/hooks/use-logger.js', () => ({ + useLogger: () => mockLogger, +})); + +vi.mock('ably'); + +const updateMockRoom = (newRoom: Room) => { + mockRoom = newRoom; + mockRoomContext = { room: Promise.resolve(newRoom) }; +}; + +describe('eventual rooms', () => { + beforeEach(() => { + mockLogger = makeTestLogger(); + updateMockRoom(makeRandomRoom({ options: RoomOptionsDefaults })); + }); + + afterEach(() => { + cleanup(); + }); + + describe('useEventualRoom', () => { + it('returns the room', async () => { + const { result } = renderHook(() => useEventualRoom()); + + // We should start with the room being undefined + expect(result.current).toBeUndefined(); + + // Eventually, the room should resolve + await vi.waitFor(() => { + expect(result.current).toBe(mockRoom); + }); + }); + + it('updates the room', async () => { + const { result, rerender } = renderHook(() => useEventualRoom()); + + // We should start with the room being undefined + expect(result.current).toBeUndefined(); + + // Eventually, the room should resolve + await vi.waitFor(() => { + expect(result.current).toBe(mockRoom); + }); + + // Now update the room and re-render + const newRoom = makeRandomRoom({ options: RoomOptionsDefaults }); + updateMockRoom(newRoom); + + rerender(); + + // Eventually, the room should resolve + await vi.waitFor(() => { + expect(result.current).toBe(newRoom); + }); + }); + }); + + describe('useEventualRoomProperty', () => { + it('returns the room property', async () => { + const { result } = renderHook(() => useEventualRoomProperty(() => mockRoom.messages)); + + // We should start with the room being undefined + expect(result.current).toBeUndefined(); + + // Eventually, the room should resolve + await vi.waitFor(() => { + expect(result.current).toBe(mockRoom.messages); + }); + }); + + it('updates the room property', async () => { + const { result, rerender } = renderHook(() => useEventualRoomProperty(() => mockRoom.messages)); + + // We should start with the room being undefined + expect(result.current).toBeUndefined(); + + // Eventually, the room should resolve + await vi.waitFor(() => { + expect(result.current).toBe(mockRoom.messages); + }); + + // Now update the room and re-render + const newRoom = makeRandomRoom({ options: RoomOptionsDefaults }); + updateMockRoom(newRoom); + + rerender(); + + // Eventually, the room should resolve + await vi.waitFor(() => { + expect(result.current).toBe(newRoom.messages); + }); + }); + }); +}); diff --git a/test/react/helper/use-room-context.test.tsx b/test/react/helper/use-room-context.test.tsx new file mode 100644 index 00000000..3ed69c0b --- /dev/null +++ b/test/react/helper/use-room-context.test.tsx @@ -0,0 +1,59 @@ +import { RoomOptionsDefaults } from '@ably/chat'; +import { cleanup, render } from '@testing-library/react'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { useRoomContext } from '../../../src/react/helper/use-room-context.ts'; +import { ChatRoomProvider } from '../../../src/react/index.ts'; +import { ChatClientProvider } from '../../../src/react/providers/chat-client-provider.tsx'; +import { newChatClient } from '../../helper/chat.ts'; + +describe('useRoom', () => { + afterEach(() => { + cleanup(); + }); + + it('should throw an error if used outside of ChatRoomProvider', () => { + const chatClient = newChatClient(); + + const TestThrowError: React.FC = () => { + expect(() => useRoomContext('foo')).toThrowErrorInfo({ + code: 40000, + message: 'foo hook must be used within a ', + }); + return null; + }; + + const TestProvider = () => ( + + + + ); + + render(); + }); + + it('should return the context if used within ChatRoomProvider', () => { + const chatClient = newChatClient(); + + const TestUseRoom: React.FC = () => { + const context = useRoomContext('foo'); + expect(context).toBeDefined(); + expect(context.roomId).toBe('foo'); + expect(context.options).toBe(RoomOptionsDefaults); + return null; + }; + + const TestProvider = () => ( + + + + + + ); + + render(); + }); +}); diff --git a/test/react/helper/use-room-status.test.tsx b/test/react/helper/use-room-status.test.tsx new file mode 100644 index 00000000..7eeb83e3 --- /dev/null +++ b/test/react/helper/use-room-status.test.tsx @@ -0,0 +1,149 @@ +import { Logger, Room, RoomOptionsDefaults, RoomStatus, RoomStatusChange } from '@ably/chat'; +import { cleanup, renderHook } from '@testing-library/react'; +import * as Ably from 'ably'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { InternalRoomLifecycle } from '../../../src/core/room-status.ts'; +import { useRoomStatus } from '../../../src/react/helper/use-room-status.ts'; +import { makeTestLogger } from '../../helper/logger.ts'; +import { makeRandomRoom } from '../../helper/room.ts'; +import { waitForEventualHookValue } from '../../helper/wait-for-eventual-hook.ts'; + +let mockRoom: Room; +let mockRoomContext: { room: Promise }; +let mockLogger: Logger; + +vi.mock('../../../src/react/helper/use-room-context.js', () => ({ + useRoomContext: () => mockRoomContext, +})); + +vi.mock('../../../src/react/hooks/use-logger.js', () => ({ + useLogger: () => mockLogger, +})); + +vi.mock('ably'); + +const updateMockRoom = (newRoom: Room) => { + mockRoom = newRoom; + mockRoomContext = { room: Promise.resolve(newRoom) }; +}; + +describe('useRoomStatus', () => { + beforeEach(() => { + mockLogger = makeTestLogger(); + updateMockRoom(makeRandomRoom({ options: RoomOptionsDefaults })); + }); + + afterEach(() => { + cleanup(); + }); + + it('sets instantaneous room state values', () => { + const { result } = renderHook(() => useRoomStatus()); + + // We should have initialized and no error + expect(result.current.status).toBe(RoomStatus.Initializing); + expect(result.current.error).toBeUndefined(); + }); + + it('sets room status values after useEffect', async () => { + // Before we render the hook, lets update the mock room to have a status and an error + const error = new Ably.ErrorInfo('test', 50000, 500); + (mockRoom as unknown as { _lifecycle: InternalRoomLifecycle })._lifecycle.setStatus({ + status: RoomStatus.Failed, + error, + }); + + // Render the hook + const { result } = renderHook(() => useRoomStatus()); + expect(result.current.status).toBe(RoomStatus.Initializing); + expect(result.current.error).toBeUndefined(); + + // Now wait until the hook has updated the status and error + await waitForEventualHookValue(result, RoomStatus.Failed, (value) => value.status); + await waitForEventualHookValue(result, error, (value) => value.error); + }); + + it('subscribes to changing room status', async () => { + // Before we render the hook, lets update the mock room to have a status and an error + const error = new Ably.ErrorInfo('test', 50000, 500); + (mockRoom as unknown as { _lifecycle: InternalRoomLifecycle })._lifecycle.setStatus({ + status: RoomStatus.Failed, + error, + }); + + // Render the hook + const { result } = renderHook(() => useRoomStatus()); + expect(result.current.status).toBe(RoomStatus.Initializing); + expect(result.current.error).toBeUndefined(); + + // Now wait until the hook has updated the status and error + await waitForEventualHookValue(result, RoomStatus.Failed, (value) => value.status); + await waitForEventualHookValue(result, error, (value) => value.error); + + // Now update the status and error again + const newError = new Ably.ErrorInfo('test', 50001, 500); + (mockRoom as unknown as { _lifecycle: InternalRoomLifecycle })._lifecycle.setStatus({ + status: RoomStatus.Detached, + error: newError, + }); + + // Now wait until the hook has updated the status and error + await waitForEventualHookValue(result, RoomStatus.Detached, (value) => value.status); + await waitForEventualHookValue(result, newError, (value) => value.error); + }); + + it('subscribes user-provided listeners to changing room status', async () => { + const receivedEvents: RoomStatusChange[] = []; + + // Before we render the hook, lets update the mock room to have a status and an error + const error = new Ably.ErrorInfo('test', 50000, 500); + (mockRoom as unknown as { _lifecycle: InternalRoomLifecycle })._lifecycle.setStatus({ + status: RoomStatus.Failed, + error, + }); + + // Render the hook + const { unmount } = renderHook(() => + useRoomStatus({ + onRoomStatusChange: (change) => { + receivedEvents.push(change); + }, + }), + ); + + // Wait until we have an event + await vi.waitFor(() => { + expect(receivedEvents).toHaveLength(1); + }); + + // At this point we should have two listeners on the room status + expect((mockRoom as unknown as { _lifecycle: { listeners(): unknown[] } })._lifecycle.listeners()).toHaveLength(2); + + // Check the event + expect(receivedEvents[0]?.current).toBe(RoomStatus.Failed); + expect(receivedEvents[0]?.previous).toBe(RoomStatus.Initializing); + expect(receivedEvents[0]?.error).toBe(error); + + // Now do another status change + const newError = new Ably.ErrorInfo('test', 50001, 500); + (mockRoom as unknown as { _lifecycle: InternalRoomLifecycle })._lifecycle.setStatus({ + status: RoomStatus.Detached, + error: newError, + }); + + // Wait until we have another event + await vi.waitFor(() => { + expect(receivedEvents).toHaveLength(2); + }); + + // Check the event + expect(receivedEvents[1]?.current).toBe(RoomStatus.Detached); + expect(receivedEvents[1]?.previous).toBe(RoomStatus.Failed); + expect(receivedEvents[1]?.error).toBe(newError); + + // After unmount we should have no listeners + unmount(); + expect((mockRoom as unknown as { _lifecycle: { listeners(): unknown[] } })._lifecycle.listeners()).toBeNull(); + }); +}); diff --git a/test/react/helper/use-stable-reference.test.tsx b/test/react/helper/use-stable-reference.test.tsx new file mode 100644 index 00000000..49a80311 --- /dev/null +++ b/test/react/helper/use-stable-reference.test.tsx @@ -0,0 +1,26 @@ +import { renderHook } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { useStableReference } from '../../../src/react/helper/use-stable-reference.js'; + +describe('useStableReference', () => { + it('creates a ref', () => { + const originalCallback = vi.fn(); + const { result, rerender } = renderHook(({ callback }) => useStableReference(callback), { + initialProps: { callback: originalCallback }, + }); + + const initialResult = result.current; + + (result.current as (arg0: string, arg1: string) => void)('arg1', 'arg2'); + expect(originalCallback).toHaveBeenCalledWith('arg1', 'arg2'); + + const newCallback = vi.fn(); + rerender({ callback: newCallback }); + + (result.current as (arg0: string, arg1: string) => void)('arg3', 'arg4'); + expect(newCallback).toHaveBeenCalledWith('arg3', 'arg4'); + + expect(result.current).toBe(initialResult); + }); +}); diff --git a/test/react/hooks/use-chat-client.test.tsx b/test/react/hooks/use-chat-client.test.tsx index 7809652f..4a094584 100644 --- a/test/react/hooks/use-chat-client.test.tsx +++ b/test/react/hooks/use-chat-client.test.tsx @@ -6,7 +6,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { RealtimeWithOptions } from '../../../src/core/realtime-extensions.ts'; import { VERSION } from '../../../src/core/version.ts'; import { useChatClient } from '../../../src/react/hooks/use-chat-client.ts'; -import { useRoom } from '../../../src/react/hooks/use-room.ts'; import { ChatClientProvider } from '../../../src/react/providers/chat-client-provider.tsx'; import { newChatClient } from '../../helper/chat.ts'; @@ -25,7 +24,7 @@ describe('useChatClient', () => { it('should throw an error if used outside of ChatClientProvider', () => { const TestThrowError: React.FC = () => { - expect(() => useRoom()).toThrowErrorInfo({ + expect(() => useChatClient()).toThrowErrorInfo({ code: 40000, message: 'useChatClient hook must be used within a chat client provider', }); @@ -36,7 +35,7 @@ describe('useChatClient', () => { }); it('should get the chat client from the context without error and with the correct agent', () => { - const chatClient = newChatClient() as unknown as ChatClient; + const chatClient = newChatClient(); const TestProvider = () => ( { return ; }; - const chatClient = newChatClient() as unknown as ChatClient; + const chatClient = newChatClient(); render( @@ -78,8 +77,8 @@ describe('useChatClient', () => { expect(client1).toEqual(client2); }); it('should handle context updates correctly', () => { - const client1 = newChatClient() as unknown as ChatClient; - const client2 = newChatClient() as unknown as ChatClient; + const client1 = newChatClient(); + const client2 = newChatClient(); const { rerender } = render( { return ; }; - const chatClient = newChatClient() as unknown as ChatClient; + const chatClient = newChatClient(); render(
@@ -146,8 +145,8 @@ describe('useChatClient', () => { return
; }; - const chatClientInner = newChatClient() as unknown as ChatClient; - const chatClientOuter = newChatClient() as unknown as ChatClient; + const chatClientInner = newChatClient(); + const chatClientOuter = newChatClient(); render( diff --git a/test/react/hooks/use-messages.integration.test.tsx b/test/react/hooks/use-messages.integration.test.tsx index c5dbf470..3bd6da4c 100644 --- a/test/react/hooks/use-messages.integration.test.tsx +++ b/test/react/hooks/use-messages.integration.test.tsx @@ -31,12 +31,12 @@ describe('useMessages', () => { it('should send messages correctly', async () => { // create new clients - const chatClientOne = newChatClient() as unknown as ChatClient; - const chatClientTwo = newChatClient() as unknown as ChatClient; + const chatClientOne = newChatClient(); + const chatClientTwo = newChatClient(); // create a second room and attach it, so we can listen for messages const roomId = randomRoomId(); - const roomTwo = chatClientTwo.rooms.get(roomId, RoomOptionsDefaults); + const roomTwo = await chatClientTwo.rooms.get(roomId, RoomOptionsDefaults); await roomTwo.attach(); // start listening for messages @@ -80,7 +80,7 @@ describe('useMessages', () => { // create a second room and attach it, so we can listen for deletions const roomId = randomRoomId(); - const roomTwo = chatClientTwo.rooms.get(roomId, RoomOptionsDefaults); + const roomTwo = await chatClientTwo.rooms.get(roomId, RoomOptionsDefaults); await roomTwo.attach(); // start listening for deletions @@ -129,12 +129,12 @@ describe('useMessages', () => { it('should receive messages on a subscribed listener', async () => { // create new clients - const chatClientOne = newChatClient() as unknown as ChatClient; - const chatClientTwo = newChatClient() as unknown as ChatClient; + const chatClientOne = newChatClient(); + const chatClientTwo = newChatClient(); // create a second room so we can send messages from it const roomId = randomRoomId(); - const roomTwo = chatClientTwo.rooms.get(roomId, RoomOptionsDefaults); + const roomTwo = await chatClientTwo.rooms.get(roomId, RoomOptionsDefaults); // start listening for messages const messagesRoomOne: Message[] = []; @@ -184,12 +184,12 @@ describe('useMessages', () => { it('should receive previous messages for a subscribed listener', async () => { // create new clients - const chatClientOne = newChatClient() as unknown as ChatClient; - const chatClientTwo = newChatClient() as unknown as ChatClient; + const chatClientOne = newChatClient(); + const chatClientTwo = newChatClient(); // create a second room instance so we can send messages from it const roomId = randomRoomId(); - const roomTwo = chatClientTwo.rooms.get(roomId, RoomOptionsDefaults); + const roomTwo = await chatClientTwo.rooms.get(roomId, RoomOptionsDefaults); await roomTwo.attach(); // send a few messages before the first room has subscribed @@ -248,11 +248,11 @@ describe('useMessages', () => { }, 10000); it('should reset getPreviousMessages if the listener becomes undefined then redefined', async () => { - const chatClient = newChatClient() as unknown as ChatClient; + const chatClient = newChatClient(); // create a second room instance so we can send messages from it const roomId = randomRoomId(); - const room = chatClient.rooms.get(roomId, RoomOptionsDefaults); + const room = await chatClient.rooms.get(roomId, RoomOptionsDefaults); await room.attach(); let lastSeenMessageText: string | undefined; @@ -382,11 +382,11 @@ describe('useMessages', () => { }, 20000); it('should persist the getPreviousMessages subscription point across renders, if listener remains defined', async () => { - const chatClient = newChatClient() as unknown as ChatClient; + const chatClient = newChatClient(); // create a second room instance so we can send messages from it const roomId = randomRoomId(); - const room = chatClient.rooms.get(roomId, RoomOptionsDefaults); + const room = await chatClient.rooms.get(roomId, RoomOptionsDefaults); await room.attach(); let lastSeenMessageText: string | undefined; diff --git a/test/react/hooks/use-messages.test.tsx b/test/react/hooks/use-messages.test.tsx index 3ee2e414..1d0221fd 100644 --- a/test/react/hooks/use-messages.test.tsx +++ b/test/react/hooks/use-messages.test.tsx @@ -17,8 +17,10 @@ import { PaginatedResult } from '../../../src/core/query.ts'; import { useMessages } from '../../../src/react/hooks/use-messages.ts'; import { makeTestLogger } from '../../helper/logger.ts'; import { makeRandomRoom } from '../../helper/room.ts'; +import { waitForEventualHookValue, waitForEventualHookValueToBeDefined } from '../../helper/wait-for-eventual-hook.ts'; let mockRoom: Room; +let mockRoomContext: { room: Promise }; let mockCurrentConnectionStatus: ConnectionStatus; let mockCurrentRoomStatus: RoomStatus; let mockConnectionError: Ably.ErrorInfo; @@ -33,12 +35,12 @@ vi.mock('../../../src/react/hooks/use-chat-connection.js', () => ({ }), })); -vi.mock('../../../src/react/hooks/use-room.js', () => ({ - useRoom: () => ({ - room: mockRoom, - roomStatus: mockCurrentRoomStatus, - roomError: mockRoomError, - }), +vi.mock('../../../src/react/helper/use-room-context.js', () => ({ + useRoomContext: () => mockRoomContext, +})); + +vi.mock('../../../src/react/helper/use-room-status.js', () => ({ + useRoomStatus: () => ({ status: mockCurrentRoomStatus, error: mockRoomError }), })); vi.mock('../../../src/react/hooks/use-logger.js', () => ({ @@ -47,6 +49,11 @@ vi.mock('../../../src/react/hooks/use-logger.js', () => ({ vi.mock('ably'); +const updateMockRoom = (newRoom: Room) => { + mockRoom = newRoom; + mockRoomContext = { room: Promise.resolve(newRoom) }; +}; + describe('useMessages', () => { beforeEach(() => { // create a new mock room before each test, enabling messages @@ -54,14 +61,14 @@ describe('useMessages', () => { testLogger = makeTestLogger(); mockCurrentConnectionStatus = ConnectionStatus.Connected; mockCurrentRoomStatus = RoomStatus.Attached; - mockRoom = makeRandomRoom({}); + updateMockRoom(makeRandomRoom({})); }); afterEach(() => { cleanup(); }); - it('should provide the messages instance and chat status response metrics', () => { + it('should provide the messages instance and chat status response metrics', async () => { // set the connection and room errors to check that they are correctly provided mockConnectionError = new Ably.ErrorInfo('test error', 40000, 400); mockRoomError = new Ably.ErrorInfo('test error', 40000, 400); @@ -69,7 +76,7 @@ describe('useMessages', () => { const { result } = renderHook(() => useMessages()); // check that the messages instance and metrics are correctly provided - expect(result.current.messages).toBe(mockRoom.messages); + await waitForEventualHookValue(result, mockRoom.messages, (value) => value.messages); // check connection and room metrics are correctly provided expect(result.current.roomStatus).toBe(RoomStatus.Attached); @@ -96,6 +103,7 @@ describe('useMessages', () => { }), ); + await waitForEventualHookValueToBeDefined(result, (value) => value.getPreviousMessages); const getPreviousMessages = result.current.getPreviousMessages; // verify that subscribe was called with the mock listener on mount by invoking it @@ -192,23 +200,24 @@ describe('useMessages', () => { }); }); - it('should handle rerender if the room instance changes', () => { + it('should handle rerender if the room instance changes', async () => { const { result, rerender } = renderHook(() => useMessages()); // check the initial state of the messages instance + await waitForEventualHookValue(result, mockRoom.messages, (value) => value.messages); expect(result.current.messages).toBe(mockRoom.messages); // change the mock room instance - mockRoom = makeRandomRoom({}); + updateMockRoom(makeRandomRoom({})); // re-render to trigger the useEffect rerender(); // check that the messages instance is updated - expect(result.current.messages).toBe(mockRoom.messages); + await waitForEventualHookValue(result, mockRoom.messages, (value) => value.messages); }); - it('should subscribe and unsubscribe to discontinuity events', () => { + it('should subscribe and unsubscribe to discontinuity events', async () => { const mockOff = vi.fn(); const mockDiscontinuityListener = vi.fn(); @@ -224,7 +233,7 @@ describe('useMessages', () => { // check that the listener was subscribed to the discontinuity events by invoking it const errorInfo = new Ably.ErrorInfo('test error', 40000, 400); - expect(discontinuityListener).toBeDefined(); + await vi.waitFor(() => discontinuityListener !== undefined); discontinuityListener?.(errorInfo); expect(mockDiscontinuityListener).toHaveBeenCalledWith(errorInfo); diff --git a/test/react/hooks/use-occupancy.integration.test.tsx b/test/react/hooks/use-occupancy.integration.test.tsx index dfea9264..e8399b1f 100644 --- a/test/react/hooks/use-occupancy.integration.test.tsx +++ b/test/react/hooks/use-occupancy.integration.test.tsx @@ -1,4 +1,4 @@ -import { ChatClient, OccupancyEvent, OccupancyListener, RoomOptionsDefaults } from '@ably/chat'; +import { OccupancyEvent, OccupancyListener, RoomOptionsDefaults } from '@ably/chat'; import { cleanup, render } from '@testing-library/react'; import { dequal } from 'dequal'; import React from 'react'; @@ -18,14 +18,14 @@ describe('useOccupancy', () => { it('should receive occupancy updates', { timeout: 20000 }, async () => { // create new clients - const chatClient = newChatClient() as unknown as ChatClient; - const chatClientTwo = newChatClient() as unknown as ChatClient; - const chatClientThree = newChatClient() as unknown as ChatClient; + const chatClient = newChatClient(); + const chatClientTwo = newChatClient(); + const chatClientThree = newChatClient(); // create two more rooms and attach to contribute towards occupancy metrics const roomId = randomRoomId(); - const roomTwo = chatClientTwo.rooms.get(roomId, RoomOptionsDefaults); - const roomThree = chatClientThree.rooms.get(roomId, RoomOptionsDefaults); + const roomTwo = await chatClientTwo.rooms.get(roomId, RoomOptionsDefaults); + const roomThree = await chatClientThree.rooms.get(roomId, RoomOptionsDefaults); await roomTwo.attach(); await roomThree.attach(); diff --git a/test/react/hooks/use-occupancy.test.tsx b/test/react/hooks/use-occupancy.test.tsx index 5137e4e9..b999399c 100644 --- a/test/react/hooks/use-occupancy.test.tsx +++ b/test/react/hooks/use-occupancy.test.tsx @@ -13,17 +13,22 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { useOccupancy } from '../../../src/react/hooks/use-occupancy.ts'; import { makeTestLogger } from '../../helper/logger.ts'; import { makeRandomRoom } from '../../helper/room.ts'; +import { waitForEventualHookValue, waitForEventualHookValueToBeDefined } from '../../helper/wait-for-eventual-hook.ts'; let mockRoom: Room; +let mockRoomContext: { room: Promise }; let mockLogger: ReturnType; // apply mocks for the useChatConnection and useRoom hooks vi.mock('../../../src/react/hooks/use-chat-connection.js', () => ({ useChatConnection: () => ({ currentStatus: ConnectionStatus.Connected }), })); +vi.mock('../../../src/react/helper/use-room-context.js', () => ({ + useRoomContext: () => mockRoomContext, +})); -vi.mock('../../../src/react/hooks/use-room.js', () => ({ - useRoom: () => ({ room: mockRoom, roomStatus: RoomStatus.Attached }), +vi.mock('../../../src/react/helper/use-room-status.js', () => ({ + useRoomStatus: () => ({ status: RoomStatus.Attached }), })); vi.mock('../../../src/react/hooks/use-logger.js', () => ({ @@ -32,23 +37,28 @@ vi.mock('../../../src/react/hooks/use-logger.js', () => ({ vi.mock('ably'); +const updateMockRoom = (newRoom: Room) => { + mockRoom = newRoom; + mockRoomContext = { room: Promise.resolve(newRoom) }; +}; + describe('useOccupancy', () => { beforeEach(() => { // create a new mock room before each test, enabling occupancy vi.resetAllMocks(); mockLogger = makeTestLogger(); - mockRoom = makeRandomRoom({ options: { occupancy: {} } }); + updateMockRoom(makeRandomRoom({ options: { occupancy: {} } })); }); afterEach(() => { cleanup(); }); - it('should provide the occupancy instance, associated metrics, and chat status response metrics', () => { + it('should provide the occupancy instance, associated metrics, and chat status response metrics', async () => { const { result } = renderHook(() => useOccupancy()); // check that the occupancy instance and metrics are correctly provided - expect(result.current.occupancy).toBe(mockRoom.occupancy); + await waitForEventualHookValue(result, mockRoom.occupancy, (value) => value.occupancy); expect(result.current.connections).toBe(0); expect(result.current.presenceMembers).toBe(0); @@ -59,7 +69,7 @@ describe('useOccupancy', () => { expect(result.current.connectionError).toBeUndefined(); }); - it('should correctly subscribe and unsubscribe to occupancy events', () => { + it('should correctly subscribe and unsubscribe to occupancy events', async () => { // mock listener and associated unsubscribe function const mockListener = vi.fn(); const mockUnsubscribe = vi.fn(); @@ -85,9 +95,11 @@ describe('useOccupancy', () => { }; // update the mock room with the new occupancy object - mockRoom = { ...mockRoom, occupancy: mockOccupancy }; + updateMockRoom({ ...mockRoom, occupancy: mockOccupancy }); + + const { result, unmount } = renderHook(() => useOccupancy({ listener: mockListener })); - const { unmount } = renderHook(() => useOccupancy({ listener: mockListener })); + await waitForEventualHookValueToBeDefined(result, (value) => value.occupancy); // verify that subscribe was called with the mock listener on mount by triggering an occupancy event mockOccupancy.callAllListeners({ connections: 5, presenceMembers: 3 }); @@ -98,7 +110,7 @@ describe('useOccupancy', () => { expect(mockUnsubscribe).toHaveBeenCalled(); }); - it('should update the occupancy metrics on new occupancy events', () => { + it('should update the occupancy metrics on new occupancy events', async () => { let subscribedListener: OccupancyListener; // spy on the subscribe method of the occupancy instance @@ -116,6 +128,8 @@ describe('useOccupancy', () => { // render the hook and check the initial state of the occupancy metrics const { result } = renderHook(() => useOccupancy()); + await waitForEventualHookValueToBeDefined(result, (value) => value.occupancy); + expect(result.current.connections).toBe(0); expect(result.current.presenceMembers).toBe(0); @@ -129,23 +143,24 @@ describe('useOccupancy', () => { expect(result.current.presenceMembers).toBe(3); }); - it('should handle rerender if the room instance changes', () => { + it('should handle rerender if the room instance changes', async () => { const { result, rerender } = renderHook(() => useOccupancy()); // check the initial state of the occupancy instance + await waitForEventualHookValue(result, mockRoom.occupancy, (value) => value.occupancy); expect(result.current.occupancy).toBe(mockRoom.occupancy); // change the mock room instance - mockRoom = makeRandomRoom({ options: { occupancy: {} } }); + updateMockRoom(makeRandomRoom({ options: { occupancy: {} } })); // re-render to trigger the useEffect rerender(); // check that the occupancy instance is updated - expect(result.current.occupancy).toBe(mockRoom.occupancy); + await waitForEventualHookValue(result, mockRoom.occupancy, (value) => value.occupancy); }); - it('should subscribe and unsubscribe to discontinuity events', () => { + it('should subscribe and unsubscribe to discontinuity events', async () => { const mockOff = vi.fn(); const mockDiscontinuityListener = vi.fn(); @@ -170,10 +185,12 @@ describe('useOccupancy', () => { }; // update the mock room with the new occupancy object - mockRoom = { ...mockRoom, occupancy: mockOccupancy }; + updateMockRoom({ ...mockRoom, occupancy: mockOccupancy }); // render the hook with a discontinuity listener - const { unmount } = renderHook(() => useOccupancy({ onDiscontinuity: mockDiscontinuityListener })); + const { result, unmount } = renderHook(() => useOccupancy({ onDiscontinuity: mockDiscontinuityListener })); + + await waitForEventualHookValueToBeDefined(result, (value) => value.occupancy); // check that the listener was subscribed to the discontinuity events by triggering one const errorInfo = new Ably.ErrorInfo('test', 50000, 500); diff --git a/test/react/hooks/use-presence-listener.integration.test.tsx b/test/react/hooks/use-presence-listener.integration.test.tsx index 2edceead..3080fd3b 100644 --- a/test/react/hooks/use-presence-listener.integration.test.tsx +++ b/test/react/hooks/use-presence-listener.integration.test.tsx @@ -1,11 +1,4 @@ -import { - ChatClient, - PresenceEvent, - PresenceListener, - PresenceMember, - RoomOptionsDefaults, - RoomStatus, -} from '@ably/chat'; +import { PresenceEvent, PresenceListener, PresenceMember, RoomOptionsDefaults, RoomStatus } from '@ably/chat'; import { cleanup, render, waitFor } from '@testing-library/react'; import React, { useEffect } from 'react'; import { afterEach, describe, expect, it } from 'vitest'; @@ -38,12 +31,12 @@ describe('usePresenceListener', () => { it('should correctly listen to presence events', async () => { // create new clients - const chatClientOne = newChatClient() as unknown as ChatClient; - const chatClientTwo = newChatClient() as unknown as ChatClient; + const chatClientOne = newChatClient(); + const chatClientTwo = newChatClient(); // create a second room and attach it, so we can send presence events with it const roomId = randomRoomId(); - const roomTwo = chatClientTwo.rooms.get(roomId, RoomOptionsDefaults); + const roomTwo = await chatClientTwo.rooms.get(roomId, RoomOptionsDefaults); await roomTwo.attach(); // store the current presence member state diff --git a/test/react/hooks/use-presence-listener.test.tsx b/test/react/hooks/use-presence-listener.test.tsx index 799da672..2d64c2ee 100644 --- a/test/react/hooks/use-presence-listener.test.tsx +++ b/test/react/hooks/use-presence-listener.test.tsx @@ -14,11 +14,14 @@ import * as Ably from 'ably'; import { ErrorInfo } from 'ably'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { InternalRoomLifecycle } from '../../../src/core/room-status.ts'; import { usePresenceListener } from '../../../src/react/hooks/use-presence-listener.ts'; import { makeTestLogger } from '../../helper/logger.ts'; import { makeRandomRoom } from '../../helper/room.ts'; +import { waitForEventualHookValue, waitForEventualHookValueToBeDefined } from '../../helper/wait-for-eventual-hook.ts'; let mockRoom: Room; +let mockRoomContext: { room: Promise }; let mockLogger: Logger; let mockCurrentConnectionStatus: ConnectionStatus; @@ -34,14 +37,12 @@ vi.mock('../../../src/react/hooks/use-chat-connection.js', () => ({ }), })); -vi.mock('../../../src/react/hooks/use-room.js', () => ({ - useRoom: () => { - return { - room: mockRoom, - roomStatus: mockCurrentRoomStatus, - roomError: mockRoomError, - }; - }, +vi.mock('../../../src/react/helper/use-room-context.js', () => ({ + useRoomContext: () => mockRoomContext, +})); + +vi.mock('../../../src/react/helper/use-room-status.js', () => ({ + useRoomStatus: () => ({ status: mockCurrentRoomStatus, error: mockRoomError }), })); vi.mock('../../../src/react/hooks/use-logger.js', () => ({ @@ -50,18 +51,26 @@ vi.mock('../../../src/react/hooks/use-logger.js', () => ({ vi.mock('ably'); +const updateMockRoom = (newRoom: Room) => { + mockRoom = newRoom; + (mockRoom as unknown as { _lifecycle: InternalRoomLifecycle })._lifecycle.setStatus({ status: RoomStatus.Attached }); + mockRoomContext = { room: Promise.resolve(newRoom) }; +}; + describe('usePresenceListener', () => { beforeEach(() => { // create a new mock room before each test - mockRoom = makeRandomRoom({ options: { presence: { subscribe: true } } }); + updateMockRoom(makeRandomRoom({ options: { presence: { subscribe: true } } })); mockLogger = makeTestLogger(); + mockCurrentRoomStatus = RoomStatus.Attached; + mockCurrentConnectionStatus = ConnectionStatus.Connected; }); afterEach(() => { vi.restoreAllMocks(); cleanup(); }); - it('should provide the room presence instance, presence data and correct chat status response metrics', () => { + it('should provide the room presence instance, presence data and correct chat status response metrics', async () => { mockConnectionError = new Ably.ErrorInfo('test', 500, 50000); mockRoomError = new Ably.ErrorInfo('test', 500, 50000); mockCurrentRoomStatus = RoomStatus.Attached; @@ -70,6 +79,7 @@ describe('usePresenceListener', () => { const { result } = renderHook(() => usePresenceListener()); // check that the room presence instance is correctly provided + await waitForEventualHookValue(result, mockRoom.presence, (value) => value.presence); expect(result.current.presence).toBe(mockRoom.presence); expect(result.current.presenceData).toEqual([]); @@ -80,7 +90,7 @@ describe('usePresenceListener', () => { expect(result.current.connectionError).toBe(mockConnectionError); }); - it('should correctly subscribe and unsubscribe to presence', () => { + it('should correctly subscribe and unsubscribe to presence', async () => { // mock listener and associated unsubscribe function const mockListener = vi.fn(); const mockUnsubscribe = vi.fn(); @@ -93,7 +103,9 @@ describe('usePresenceListener', () => { }); vi.spyOn(mockRoom.presence, 'get').mockResolvedValue([]); - const { unmount } = renderHook(() => usePresenceListener({ listener: mockListener })); + const { result, unmount } = renderHook(() => usePresenceListener({ listener: mockListener })); + + await waitForEventualHookValueToBeDefined(result, () => result.current.presence); // verify that subscribe was called with the mock listener on mount by triggering a presence event const testPresenceEvent: PresenceEvent = { @@ -112,20 +124,21 @@ describe('usePresenceListener', () => { expect(mockUnsubscribe).toHaveBeenCalled(); }); - it('should handle rerender if the room instance changes', () => { + it('should handle rerender if the room instance changes', async () => { const { result, rerender } = renderHook(() => usePresenceListener()); // check the initial state of the presence object + await waitForEventualHookValue(result, mockRoom.presence, (value) => value.presence); expect(result.current.presence).toBe(mockRoom.presence); // change the mock room instance - mockRoom = makeRandomRoom({ options: { presence: { subscribe: true } } }); + updateMockRoom(makeRandomRoom({ options: { presence: { subscribe: true } } })); // re-render to trigger the useEffect rerender(); // check that the room presence instance is updated - expect(result.current.presence).toBe(mockRoom.presence); + await waitForEventualHookValue(result, mockRoom.presence, (value) => value.presence); }); it('should set the initial present clients on mount', async () => { @@ -156,6 +169,7 @@ describe('usePresenceListener', () => { // render the hook and check the initial state const { result } = renderHook(() => usePresenceListener()); + await waitForEventualHookValueToBeDefined(result, () => result.current.presence); await waitFor( () => { @@ -254,7 +268,7 @@ describe('usePresenceListener', () => { ); }); - it('should subscribe and unsubscribe to discontinuity events', () => { + it('should subscribe and unsubscribe to discontinuity events', async () => { const mockOff = vi.fn(); const mockDiscontinuityListener = vi.fn(); @@ -270,7 +284,8 @@ describe('usePresenceListener', () => { // check that the listener was subscribed to the discontinuity events by triggering a discontinuity event const errorInfo = new Ably.ErrorInfo('test', 500, 50000); - expect(discontinuityListener).toBeDefined(); + + await waitFor(() => discontinuityListener !== undefined); discontinuityListener?.(errorInfo); // unmount the hook and verify that the listener was unsubscribed @@ -300,6 +315,8 @@ describe('usePresenceListener', () => { return subscribedListener !== undefined; }); + // wait until the + if (!subscribedListener) { expect.fail('subscribedListener is undefined'); } @@ -333,7 +350,10 @@ describe('usePresenceListener', () => { data: undefined, }); - expect(mockRoom.presence.get).toBeCalledTimes(1); + // Wait for our mock room's presence to be called + await waitFor(() => { + expect(mockRoom.presence.get).toBeCalledTimes(1); + }); // wait for the hook to retry the presence.get call await waitFor( @@ -426,8 +446,10 @@ describe('usePresenceListener', () => { data: undefined, }); - // ensure that the first call to presence.get was made - expect(mockRoom.presence.get).toBeCalledTimes(1); + // ensure that the first call to presence.get was made - eventually + await waitFor(() => { + expect(mockRoom.presence.get).toBeCalledTimes(1); + }); // now emit the second presence event, this should trigger the second call to presence.get subscribedListener({ @@ -437,8 +459,10 @@ describe('usePresenceListener', () => { data: undefined, }); - // ensure that the second call to presence.get was made - expect(mockRoom.presence.get).toBeCalledTimes(2); + // ensure that the first call to presence.get was made - eventually + await waitFor(() => { + expect(mockRoom.presence.get).toBeCalledTimes(2); + }); // since we already have a newer event, triggering the first event to resolve // should result in the first event being discarded diff --git a/test/react/hooks/use-presence.integration.test.tsx b/test/react/hooks/use-presence.integration.test.tsx index ec8381be..3174839d 100644 --- a/test/react/hooks/use-presence.integration.test.tsx +++ b/test/react/hooks/use-presence.integration.test.tsx @@ -1,4 +1,4 @@ -import { ChatClient, PresenceData, PresenceEvent, PresenceEvents, RoomOptionsDefaults } from '@ably/chat'; +import { PresenceData, PresenceEvent, PresenceEvents, RoomOptionsDefaults } from '@ably/chat'; import { cleanup, render, waitFor } from '@testing-library/react'; import { useEffect } from 'react'; import { afterEach, describe, expect, it } from 'vitest'; @@ -43,12 +43,12 @@ describe('usePresence', () => { it('should send presence events', async () => { // create new clients - const chatClientOne = newChatClient() as unknown as ChatClient; - const chatClientTwo = newChatClient() as unknown as ChatClient; + const chatClientOne = newChatClient(); + const chatClientTwo = newChatClient(); // create a second room and attach it, so we can listen for presence events const roomId = randomRoomId(); - const roomTwo = chatClientTwo.rooms.get(roomId, RoomOptionsDefaults); + const roomTwo = await chatClientTwo.rooms.get(roomId, RoomOptionsDefaults); await roomTwo.attach(); // start listening for presence events on room two diff --git a/test/react/hooks/use-presence.test.tsx b/test/react/hooks/use-presence.test.tsx index 7ad80d79..2afc8348 100644 --- a/test/react/hooks/use-presence.test.tsx +++ b/test/react/hooks/use-presence.test.tsx @@ -3,11 +3,14 @@ import { act, cleanup, renderHook, waitFor } from '@testing-library/react'; import * as Ably from 'ably'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { InternalRoomLifecycle } from '../../../src/core/room-status.ts'; import { usePresence } from '../../../src/react/hooks/use-presence.ts'; import { makeTestLogger } from '../../helper/logger.ts'; import { makeRandomRoom } from '../../helper/room.ts'; +import { waitForEventualHookValue } from '../../helper/wait-for-eventual-hook.ts'; let mockRoom: Room; +let mockRoomContext: { room: Promise }; let mockCurrentConnectionStatus: ConnectionStatus; let mockCurrentRoomStatus: RoomStatus; let mockConnectionError: Ably.ErrorInfo; @@ -22,12 +25,12 @@ vi.mock('../../../src/react/hooks/use-chat-connection.js', () => ({ }), })); -vi.mock('../../../src/react/hooks/use-room.js', () => ({ - useRoom: () => ({ - room: mockRoom, - roomStatus: mockCurrentRoomStatus, - roomError: mockRoomError, - }), +vi.mock('../../../src/react/helper/use-room-context.js', () => ({ + useRoomContext: () => mockRoomContext, +})); + +vi.mock('../../../src/react/helper/use-room-status.js', () => ({ + useRoomStatus: () => ({ status: mockCurrentRoomStatus, error: mockRoomError }), })); vi.mock('../../../src/react/hooks/use-logger.js', () => ({ @@ -36,6 +39,12 @@ vi.mock('../../../src/react/hooks/use-logger.js', () => ({ vi.mock('ably'); +const updateMockRoom = (newRoom: Room) => { + mockRoom = newRoom; + (mockRoom as unknown as { _lifecycle: InternalRoomLifecycle })._lifecycle.setStatus({ status: RoomStatus.Attached }); + mockRoomContext = { room: Promise.resolve(newRoom) }; +}; + describe('usePresence', () => { beforeEach(() => { // create a new mock room before each test, enabling presence @@ -43,21 +52,23 @@ describe('usePresence', () => { mockLogger = makeTestLogger(); mockCurrentConnectionStatus = ConnectionStatus.Connected; mockCurrentRoomStatus = RoomStatus.Attached; - mockRoom = makeRandomRoom({ - options: { - presence: { - enter: true, - subscribe: true, + updateMockRoom( + makeRandomRoom({ + options: { + presence: { + enter: true, + subscribe: true, + }, }, - }, - }); + }), + ); }); afterEach(() => { cleanup(); }); - it('should provide the presence instance and chat status response metrics', () => { + it('should provide the presence instance and chat status response metrics', async () => { // set the connection and room errors to check that they are correctly provided mockConnectionError = new Ably.ErrorInfo('test error', 40000, 400); mockRoomError = new Ably.ErrorInfo('test error', 40000, 400); @@ -65,8 +76,8 @@ describe('usePresence', () => { const { result } = renderHook(() => usePresence()); // check that the presence instance and metrics are correctly provided - expect(result.current.presence).toBe(mockRoom.presence); - expect(result.current.isPresent).toBe(false); + await waitForEventualHookValue(result, mockRoom.presence, (value) => value.presence); + expect(result.current.isPresent).toBe(true); expect(result.current.error).toBeUndefined(); // check connection and room metrics are correctly provided @@ -82,7 +93,9 @@ describe('usePresence', () => { const { result, rerender } = renderHook(() => usePresence({ enterWithData: { test: 'data' } })); // ensure we have entered presence - expect(mockRoom.presence.enter).toHaveBeenCalledWith({ test: 'data' }); + await waitFor(() => { + expect(mockRoom.presence.enter).toHaveBeenCalledWith({ test: 'data' }); + }); await waitFor(() => result.current.isPresent); @@ -91,14 +104,16 @@ describe('usePresence', () => { expect(result.current.isPresent).toBe(true); // change the mock room instance - mockRoom = makeRandomRoom({ - options: { - presence: { - enter: true, - subscribe: true, + updateMockRoom( + makeRandomRoom({ + options: { + presence: { + enter: true, + subscribe: true, + }, }, - }, - }); + }), + ); vi.spyOn(mockRoom.presence, 'enter'); @@ -106,7 +121,9 @@ describe('usePresence', () => { rerender(); // ensure we have entered presence on the new room instance - expect(mockRoom.presence.enter).toHaveBeenCalledWith({ test: 'data' }); + await waitFor(() => { + expect(mockRoom.presence.enter).toHaveBeenCalledWith({ test: 'data' }); + }); await waitFor(() => result.current.isPresent); expect(result.current.isPresent).toBe(true); @@ -164,7 +181,9 @@ describe('usePresence', () => { const { result } = renderHook(() => usePresence({ enterWithData: { test: 'data' } })); // expect the enter method to be called - expect(enterSpy).toHaveBeenCalled(); + await waitFor(() => { + expect(enterSpy).toHaveBeenCalled(); + }); // wait for the error to be set from the useEffect await waitFor( @@ -212,7 +231,7 @@ describe('usePresence', () => { } }); - it('should subscribe and unsubscribe to discontinuity events', () => { + it('should subscribe and unsubscribe to discontinuity events', async () => { const mockOff = vi.fn(); const mockDiscontinuityListener = vi.fn(); @@ -228,7 +247,9 @@ describe('usePresence', () => { // check that the listener was subscribed to the discontinuity events by triggering the listener const errorInfo = new Ably.ErrorInfo('test', 50000, 500); - expect(discontinuityListener).toBeDefined(); + await waitFor(() => { + expect(discontinuityListener).toBeDefined(); + }); discontinuityListener?.(errorInfo); expect(mockDiscontinuityListener).toHaveBeenCalledWith(errorInfo); diff --git a/test/react/hooks/use-room-reactions.integration.test.tsx b/test/react/hooks/use-room-reactions.integration.test.tsx index 480fb56b..48ea3f83 100644 --- a/test/react/hooks/use-room-reactions.integration.test.tsx +++ b/test/react/hooks/use-room-reactions.integration.test.tsx @@ -1,4 +1,4 @@ -import { ChatClient, Reaction, RoomOptionsDefaults, RoomReactionListener, RoomStatus } from '@ably/chat'; +import { Reaction, RoomOptionsDefaults, RoomReactionListener, RoomStatus } from '@ably/chat'; import { cleanup, render, waitFor } from '@testing-library/react'; import React, { useEffect } from 'react'; import { afterEach, describe, expect, it } from 'vitest'; @@ -31,12 +31,12 @@ describe('useRoomReactions', () => { it('should send a room reaction', async () => { // create new clients - const chatClientOne = newChatClient() as unknown as ChatClient; - const chatClientTwo = newChatClient() as unknown as ChatClient; + const chatClientOne = newChatClient(); + const chatClientTwo = newChatClient(); // create a second room and attach it, so we can receive reactions const roomId = randomRoomId(); - const roomTwo = chatClientTwo.rooms.get(roomId, RoomOptionsDefaults); + const roomTwo = await chatClientTwo.rooms.get(roomId, RoomOptionsDefaults); await roomTwo.attach(); // store the received reactions @@ -82,12 +82,12 @@ describe('useRoomReactions', () => { it('should receive room reactions', async () => { // create new clients - const chatClientOne = newChatClient() as unknown as ChatClient; - const chatClientTwo = newChatClient() as unknown as ChatClient; + const chatClientOne = newChatClient(); + const chatClientTwo = newChatClient(); // create a second room and attach it, so we can send a reaction const roomId = randomRoomId(); - const roomTwo = chatClientTwo.rooms.get(roomId, RoomOptionsDefaults); + const roomTwo = await chatClientTwo.rooms.get(roomId, RoomOptionsDefaults); await roomTwo.attach(); // store the received reactions @@ -119,6 +119,7 @@ describe('useRoomReactions', () => { render(); // wait for the room to be attached + chatClientOne.logger.info('got here'); await waitFor( () => { expect(currentRoomStatus).toBe(RoomStatus.Attached); diff --git a/test/react/hooks/use-room-reactions.test.tsx b/test/react/hooks/use-room-reactions.test.tsx index f1dbea5d..b148ffaa 100644 --- a/test/react/hooks/use-room-reactions.test.tsx +++ b/test/react/hooks/use-room-reactions.test.tsx @@ -6,17 +6,26 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { useRoomReactions } from '../../../src/react/hooks/use-room-reactions.ts'; import { makeTestLogger } from '../../helper/logger.ts'; import { makeRandomRoom } from '../../helper/room.ts'; +import { waitForEventualHookValue, waitForEventualHookValueToBeDefined } from '../../helper/wait-for-eventual-hook.ts'; let mockRoom: Room; let mockLogger: ReturnType; +let mockRoomContext: { room: Promise }; // apply mocks for the useChatConnection and useRoom hooks vi.mock('../../../src/react/hooks/use-chat-connection.js', () => ({ useChatConnection: () => ({ currentStatus: ConnectionStatus.Connected }), })); -vi.mock('../../../src/react/hooks/use-room.js', () => ({ - useRoom: () => ({ room: mockRoom, roomStatus: RoomStatus.Attached }), +vi.mock('../../../src/react/helper/use-room-context.js', () => ({ + useRoomContext: () => { + mockLogger.debug('useRoomContext() called;'); + return mockRoomContext; + }, +})); + +vi.mock('../../../src/react/helper/use-room-status.js', () => ({ + useRoomStatus: () => ({ status: RoomStatus.Attached }), })); vi.mock('../../../src/react/hooks/use-logger.js', () => ({ @@ -25,23 +34,30 @@ vi.mock('../../../src/react/hooks/use-logger.js', () => ({ vi.mock('ably'); +const updateMockRoom = (room: Room) => { + mockRoom = room; + mockRoomContext = { room: Promise.resolve(mockRoom) }; +}; + describe('useRoomReactions', () => { beforeEach(() => { // create a new mock room before each test vi.resetAllMocks(); mockLogger = makeTestLogger(); - mockRoom = makeRandomRoom({ options: { reactions: {} } }); + updateMockRoom(makeRandomRoom({ options: { reactions: {} } })); }); afterEach(() => { cleanup(); }); - it('should provide the room reactions instance and correct chat status response metrics', () => { + it('should provide the room reactions instance and correct chat status response metrics', async () => { const { result } = renderHook(() => useRoomReactions()); - // check that the room reactions instance is correctly provided - expect(result.current.reactions).toBe(mockRoom.reactions); + // check that the room reactions instance is correctly provided - eventually + await vi.waitFor(() => { + expect(result.current.reactions).toBe(mockRoom.reactions); + }); // check connection and room metrics are correctly provided expect(result.current.roomStatus).toBe(RoomStatus.Attached); @@ -65,7 +81,7 @@ describe('useRoomReactions', () => { expect(sendSpy).toHaveBeenCalledWith({ type: 'like' }); }); - it('should correctly subscribe and unsubscribe to reactions', () => { + it('should correctly subscribe and unsubscribe to reactions', async () => { // mock listener and associated unsubscribe function const mockListener = vi.fn(); const mockUnsubscribe = vi.fn(); @@ -86,9 +102,10 @@ describe('useRoomReactions', () => { }; // update the mock room with the new reactions object - mockRoom = { ...mockRoom, reactions: mockReactions }; + updateMockRoom({ ...mockRoom, reactions: mockReactions }); - const { unmount } = renderHook(() => useRoomReactions({ listener: mockListener })); + const { result, unmount } = renderHook(() => useRoomReactions({ listener: mockListener })); + await waitForEventualHookValueToBeDefined(result, (value) => value.reactions); // verify that subscribe was called with the mock listener on mount by triggering a reaction event const reaction = { @@ -110,23 +127,24 @@ describe('useRoomReactions', () => { expect(mockUnsubscribe).toHaveBeenCalled(); }); - it('should handle rerender if the room instance changes', () => { + it('should handle rerender if the room instance changes', async () => { const { result, rerender } = renderHook(() => useRoomReactions()); // check the initial state of the reactions object - expect(result.current.reactions).toBe(mockRoom.reactions); + await waitForEventualHookValue(result, mockRoom.reactions, (value) => value.reactions); // change the mock room instance - mockRoom = makeRandomRoom({ options: { reactions: {} } }); + updateMockRoom(makeRandomRoom({ options: { reactions: {} } })); + mockLogger.debug('rerendering with new room instance'); // re-render to trigger the useEffect rerender(); // check that the room reactions instance is updated - expect(result.current.reactions).toBe(mockRoom.reactions); + await waitForEventualHookValue(result, mockRoom.reactions, (value) => value.reactions); }); - it('should subscribe and unsubscribe to discontinuity events', () => { + it('should subscribe and unsubscribe to discontinuity events', async () => { const mockOff = vi.fn(); const mockDiscontinuityListener = vi.fn(); @@ -138,7 +156,8 @@ describe('useRoomReactions', () => { }); // render the hook with a discontinuity listener - const { unmount } = renderHook(() => useRoomReactions({ onDiscontinuity: mockDiscontinuityListener })); + const { result, unmount } = renderHook(() => useRoomReactions({ onDiscontinuity: mockDiscontinuityListener })); + await waitForEventualHookValueToBeDefined(result, (value) => value.reactions); // check that the listener was subscribed to the discontinuity events by triggering one const errorInfo = new Ably.ErrorInfo('test', 50000, 500); diff --git a/test/react/hooks/use-room.test.tsx b/test/react/hooks/use-room.test.tsx index 62b99f05..f9b6e6ec 100644 --- a/test/react/hooks/use-room.test.tsx +++ b/test/react/hooks/use-room.test.tsx @@ -1,4 +1,4 @@ -import { ChatClient, RoomOptionsDefaults, RoomStatus, RoomStatusListener } from '@ably/chat'; +import { RoomOptionsDefaults, RoomStatus, RoomStatusListener } from '@ably/chat'; import { act, cleanup, render, renderHook } from '@testing-library/react'; import * as Ably from 'ably'; import React from 'react'; @@ -6,7 +6,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { ChatRoomProvider, useRoom, UseRoomResponse } from '../../../src/react/index.ts'; import { ChatClientProvider } from '../../../src/react/providers/chat-client-provider.tsx'; -import { newChatClient as newChatClientLib } from '../../helper/chat.ts'; +import { newChatClient } from '../../helper/chat.ts'; import { randomRoomId } from '../../helper/identifier.ts'; const TestComponent: React.FC<{ callback?: (room: UseRoomResponse) => void }> = ({ callback }) => { @@ -17,10 +17,6 @@ const TestComponent: React.FC<{ callback?: (room: UseRoomResponse) => void }> = vi.mock('ably'); -function newChatClient() { - return newChatClientLib() as unknown as ChatClient; -} - describe('useRoom', () => { afterEach(() => { cleanup(); @@ -32,7 +28,7 @@ describe('useRoom', () => { const TestThrowError: React.FC = () => { expect(() => useRoom()).toThrowErrorInfo({ code: 40000, - message: 'useRoom hook must be used within a ChatRoomProvider', + message: 'useRoom hook must be used within a ', }); return null; }; @@ -48,10 +44,7 @@ describe('useRoom', () => { it('should get the room from the context without error', async () => { const chatClient = newChatClient(); - let setResponse: (response: UseRoomResponse) => void; - const responsePromise = new Promise((resolve) => { - setResponse = resolve; - }); + let latestResponse: UseRoomResponse | undefined; const roomId = randomRoomId(); const TestProvider = () => ( @@ -63,21 +56,22 @@ describe('useRoom', () => { > { - setResponse(response); + latestResponse = response; }} /> ); render(); - const response = await responsePromise; - expect(response.room.roomId).toBe(roomId); - expect(response.attach).toBeTruthy(); - expect(response.detach).toBeTruthy(); - expect(response.roomStatus).toBe(RoomStatus.Initializing); + await vi.waitFor(() => { + expect(latestResponse?.room?.roomId).toBe(roomId); + }); + expect(latestResponse?.attach).toBeTruthy(); + expect(latestResponse?.detach).toBeTruthy(); + expect(latestResponse?.roomStatus).toBe(RoomStatus.Initialized); }); - it('should return working shortcuts for attach and detach functions', () => { + it('should return working shortcuts for attach and detach functions', async () => { const chatClient = newChatClient(); let called = false; const roomId = randomRoomId(); @@ -91,6 +85,8 @@ describe('useRoom', () => { > { + if (!response.room) return; + vi.spyOn(response.room, 'attach').mockImplementation(() => Promise.resolve()); vi.spyOn(response.room, 'detach').mockImplementation(() => Promise.resolve()); // no awaiting since we don't care about result, just that the relevant function was called @@ -105,15 +101,16 @@ describe('useRoom', () => { ); render(); - expect(called).toBe(true); + + await vi.waitFor(() => called, { timeout: 5000 }); }); - it('should attach, detach and release correctly with the same room twice', () => { + it('should attach, detach and release correctly with the same room twice', async () => { const chatClient = newChatClient(); let called1 = 0; let called2 = 0; const roomId = randomRoomId(); - const room = chatClient.rooms.get(roomId, RoomOptionsDefaults); + const room = await chatClient.rooms.get(roomId, RoomOptionsDefaults); vi.spyOn(room, 'attach').mockImplementation(() => Promise.resolve()); vi.spyOn(room, 'detach').mockImplementation(() => Promise.resolve()); @@ -164,9 +161,12 @@ describe('useRoom', () => { room2={true} />, ); + expect(called1).toBe(1); expect(called2).toBe(1); - expect(room.attach).toHaveBeenCalledTimes(1); + await vi.waitFor(() => { + expect(room.attach).toHaveBeenCalledTimes(1); + }); expect(room.detach).toHaveBeenCalledTimes(0); expect(chatClient.rooms.release).toHaveBeenCalledTimes(0); @@ -176,9 +176,11 @@ describe('useRoom', () => { room2={true} />, ); - expect(called1).toBe(1); - expect(called2).toBe(2); - expect(room.attach).toHaveBeenCalledTimes(1); + expect(called1).toBe(2); + expect(called2).toBe(3); + await vi.waitFor(() => { + expect(room.attach).toHaveBeenCalledTimes(1); + }); expect(room.detach).toHaveBeenCalledTimes(0); expect(chatClient.rooms.release).toHaveBeenCalledTimes(0); @@ -188,9 +190,11 @@ describe('useRoom', () => { room2={true} />, ); - expect(called1).toBe(2); - expect(called2).toBe(3); - expect(room.attach).toHaveBeenCalledTimes(1); + expect(called1).toBe(3); + expect(called2).toBe(4); + await vi.waitFor(() => { + expect(room.attach).toHaveBeenCalledTimes(1); + }); expect(room.detach).toHaveBeenCalledTimes(0); expect(chatClient.rooms.release).toHaveBeenCalledTimes(0); @@ -200,9 +204,11 @@ describe('useRoom', () => { room2={true} />, ); - expect(called1).toBe(2); - expect(called2).toBe(4); - expect(room.attach).toHaveBeenCalledTimes(1); + expect(called1).toBe(3); + expect(called2).toBe(5); + await vi.waitFor(() => { + expect(room.attach).toHaveBeenCalledTimes(1); + }); expect(room.detach).toHaveBeenCalledTimes(0); expect(chatClient.rooms.release).toHaveBeenCalledTimes(0); @@ -212,18 +218,22 @@ describe('useRoom', () => { room2={false} />, ); - expect(called1).toBe(2); - expect(called2).toBe(4); - expect(room.attach).toHaveBeenCalledTimes(1); + expect(called1).toBe(3); + expect(called2).toBe(5); + await vi.waitFor(() => { + expect(room.attach).toHaveBeenCalledTimes(1); + }); // room.detach is not called when releasing, detach happens via lifecycleManager but skipping the public API expect(room.detach).toHaveBeenCalledTimes(0); - expect(chatClient.rooms.release).toHaveBeenCalledWith(roomId); + await vi.waitFor(() => { + expect(chatClient.rooms.release).toHaveBeenCalledWith(roomId); + }); }); it('should correctly set room status callback', async () => { const chatClient = newChatClient(); const roomId = randomRoomId(); - const room = chatClient.rooms.get(roomId, RoomOptionsDefaults); + const room = await chatClient.rooms.get(roomId, RoomOptionsDefaults); let listeners: RoomStatusListener[] = []; @@ -264,6 +274,11 @@ describe('useRoom', () => { { wrapper: WithClient }, ); + // useEffect is async, so we need to wait for it to run + await vi.waitFor(() => { + expect(listeners.length).toBe(2); + }); + act(() => { for (const l of listeners) l(expectedChange); }); @@ -275,7 +290,7 @@ describe('useRoom', () => { it('should correctly set room status and error state variables', async () => { const chatClient = newChatClient(); const roomId = randomRoomId(); - const room = chatClient.rooms.get(roomId, RoomOptionsDefaults); + const room = await chatClient.rooms.get(roomId, RoomOptionsDefaults); let listeners: RoomStatusListener[] = []; @@ -303,12 +318,19 @@ describe('useRoom', () => { const { result } = renderHook(() => useRoom(), { wrapper: WithClient }); + // Because useEffect adds listeners async, wait until we have a listener + await vi.waitFor(() => { + expect(listeners.length).toBe(1); + }); + act(() => { const change = { current: RoomStatus.Attached, previous: RoomStatus.Attaching }; for (const l of listeners) l(change); }); - expect(result.current.roomStatus).toBe(RoomStatus.Attached); + await vi.waitFor(() => { + expect(result.current.roomStatus).toBe(RoomStatus.Attached); + }); expect(result.current.roomError).toBeUndefined(); act(() => { @@ -316,7 +338,9 @@ describe('useRoom', () => { for (const l of listeners) l(change); }); - expect(result.current.roomStatus).toBe(RoomStatus.Detaching); + await vi.waitFor(() => { + expect(result.current.roomStatus).toBe(RoomStatus.Detaching); + }); expect(result.current.roomError).toBeUndefined(); const err = new Ably.ErrorInfo('test', 123, 456); @@ -325,8 +349,12 @@ describe('useRoom', () => { for (const l of listeners) l(change); }); - expect(result.current.roomStatus).toBe(RoomStatus.Failed); - expect(result.current.roomError).toBe(err); + await vi.waitFor(() => { + expect(result.current.roomStatus).toBe(RoomStatus.Failed); + }); + await vi.waitFor(() => { + expect(result.current.roomError).toBe(err); + }); await room.detach(); }); }); diff --git a/test/react/hooks/use-typing.integration.test.tsx b/test/react/hooks/use-typing.integration.test.tsx index 0d4e0677..ed984b39 100644 --- a/test/react/hooks/use-typing.integration.test.tsx +++ b/test/react/hooks/use-typing.integration.test.tsx @@ -1,4 +1,4 @@ -import { ChatClient, RoomOptionsDefaults, RoomStatus, TypingEvent, TypingListener } from '@ably/chat'; +import { RoomOptionsDefaults, RoomStatus, TypingEvent, TypingListener } from '@ably/chat'; import { cleanup, render } from '@testing-library/react'; import React, { useEffect } from 'react'; import { afterEach, describe, expect, it } from 'vitest'; @@ -31,12 +31,12 @@ describe('useTyping', () => { it('should send typing events', async () => { // create new clients - const chatClientOne = newChatClient() as unknown as ChatClient; - const chatClientTwo = newChatClient() as unknown as ChatClient; + const chatClientOne = newChatClient(); + const chatClientTwo = newChatClient(); // create a second room and attach it, so we can listen for typing events const roomId = randomRoomId(); - const roomTwo = chatClientTwo.rooms.get(roomId, RoomOptionsDefaults); + const roomTwo = await chatClientTwo.rooms.get(roomId, RoomOptionsDefaults); await roomTwo.attach(); // start listening for typing events on room two @@ -78,12 +78,12 @@ describe('useTyping', () => { }, 10000); it('should subscribe and correctly listen for typing events', async () => { // create new clients - const chatClientOne = newChatClient() as unknown as ChatClient; - const chatClientTwo = newChatClient() as unknown as ChatClient; + const chatClientOne = newChatClient(); + const chatClientTwo = newChatClient(); // create a second room and attach it, so we can send typing events const roomId = randomRoomId(); - const roomTwo = chatClientTwo.rooms.get(roomId, RoomOptionsDefaults); + const roomTwo = await chatClientTwo.rooms.get(roomId, RoomOptionsDefaults); await roomTwo.attach(); // store the received typing events for room one diff --git a/test/react/hooks/use-typing.test.tsx b/test/react/hooks/use-typing.test.tsx index 8a1a4eec..aeca6ea8 100644 --- a/test/react/hooks/use-typing.test.tsx +++ b/test/react/hooks/use-typing.test.tsx @@ -3,11 +3,14 @@ import { act, cleanup, renderHook, waitFor } from '@testing-library/react'; import * as Ably from 'ably'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { DefaultRoomLifecycle, InternalRoomLifecycle } from '../../../src/core/room-status.ts'; import { useTyping } from '../../../src/react/hooks/use-typing.ts'; import { makeTestLogger } from '../../helper/logger.ts'; import { makeRandomRoom } from '../../helper/room.ts'; +import { waitForEventualHookValue, waitForEventualHookValueToBeDefined } from '../../helper/wait-for-eventual-hook.ts'; let mockRoom: Room; +let mockRoomContext: { room: Promise }; let mockLogger: Logger; // apply mocks for the useChatConnection and useRoom hooks @@ -15,8 +18,12 @@ vi.mock('../../../src/react/hooks/use-chat-connection.js', () => ({ useChatConnection: () => ({ currentStatus: ConnectionStatus.Connected }), })); -vi.mock('../../../src/react/hooks/use-room.js', () => ({ - useRoom: () => ({ room: mockRoom, roomStatus: RoomStatus.Attached }), +vi.mock('../../../src/react/helper/use-room-context.js', () => ({ + useRoomContext: () => mockRoomContext, +})); + +vi.mock('../../../src/react/helper/use-room-status.js', () => ({ + useRoomStatus: () => ({ status: RoomStatus.Attached }), })); vi.mock('../../../src/react/hooks/use-logger.js', () => ({ @@ -25,11 +32,17 @@ vi.mock('../../../src/react/hooks/use-logger.js', () => ({ vi.mock('ably'); +const updateMockRoom = (newRoom: Room & { _lifecycle?: InternalRoomLifecycle }) => { + mockRoom = newRoom; + (mockRoom as unknown as { _lifecycle: InternalRoomLifecycle })._lifecycle.setStatus({ status: RoomStatus.Attached }); + mockRoomContext = { room: Promise.resolve(newRoom) }; +}; + describe('useTyping', () => { beforeEach(() => { // create a new mock room before each test, enabling typing vi.resetAllMocks(); - mockRoom = makeRandomRoom({ options: { typing: { timeoutMs: 500 } } }); + updateMockRoom(makeRandomRoom({ options: { typing: { timeoutMs: 500 } } })); mockLogger = makeTestLogger(); }); @@ -37,11 +50,11 @@ describe('useTyping', () => { cleanup(); }); - it('should provide the typing instance and chat status response metrics', () => { + it('should provide the typing instance and chat status response metrics', async () => { const { result } = renderHook(() => useTyping()); // check that the typing instance is correctly provided - expect(result.current.typingIndicators).toBe(mockRoom.typing); + await waitForEventualHookValue(result, mockRoom.typing, (value) => value.typingIndicators); // check connection and room metrics are correctly provided expect(result.current.roomStatus).toBe(RoomStatus.Attached); @@ -50,7 +63,7 @@ describe('useTyping', () => { expect(result.current.connectionError).toBeUndefined(); }); - it('should correctly subscribe and unsubscribe to typing events', () => { + it('should correctly subscribe and unsubscribe to typing events', async () => { // mock listener and associated unsubscribe function const mockListener = vi.fn(); const mockUnsubscribe = vi.fn(); @@ -67,9 +80,10 @@ describe('useTyping', () => { }; // update the mock room with the new typing object - mockRoom = { ...mockRoom, typing: mockTyping }; + updateMockRoom({ ...mockRoom, _lifecycle: new DefaultRoomLifecycle('roomId', mockLogger), typing: mockTyping }); + const { result, unmount } = renderHook(() => useTyping({ listener: mockListener })); - const { unmount } = renderHook(() => useTyping({ listener: mockListener })); + await waitForEventualHookValueToBeDefined(result, (value) => value.typingIndicators); // verify that subscribe was called with the mock listener on mount by triggering an event const typingEvent = { currentlyTyping: new Set() }; @@ -253,29 +267,31 @@ describe('useTyping', () => { ); }); - it('should handle rerender if the room instance changes', () => { + it('should handle rerender if the room instance changes', async () => { const { result, rerender } = renderHook(() => useTyping()); // check the initial state of the typing instance - expect(result.current.typingIndicators).toBe(mockRoom.typing); + await waitForEventualHookValue(result, mockRoom.typing, (value) => value.typingIndicators); // change the mock room instance - mockRoom = makeRandomRoom({ - options: { - typing: { - timeoutMs: 500, + updateMockRoom( + makeRandomRoom({ + options: { + typing: { + timeoutMs: 500, + }, }, - }, - }); + }), + ); // re-render to trigger the useEffect rerender(); // check that the typing instance is updated - expect(result.current.typingIndicators).toBe(mockRoom.typing); + await waitForEventualHookValue(result, mockRoom.typing, (value) => value.typingIndicators); }); - it('should subscribe and unsubscribe to discontinuity events', () => { + it('should subscribe and unsubscribe to discontinuity events', async () => { const mockOff = vi.fn(); const mockDiscontinuityListener = vi.fn(); @@ -291,7 +307,9 @@ describe('useTyping', () => { // check that the listener was subscribed to the discontinuity events by triggering one const discontinuityEvent = new Ably.ErrorInfo('test', 50000, 500); - expect(discontinuityListener).toBeDefined(); + await waitFor(() => { + expect(discontinuityListener).toBeDefined(); + }); discontinuityListener?.(discontinuityEvent); expect(mockDiscontinuityListener).toHaveBeenCalledWith(discontinuityEvent); diff --git a/test/react/providers/chat-client-provider.test.tsx b/test/react/providers/chat-client-provider.test.tsx index f345ec89..55951e57 100644 --- a/test/react/providers/chat-client-provider.test.tsx +++ b/test/react/providers/chat-client-provider.test.tsx @@ -1,4 +1,3 @@ -import { ChatClient } from '@ably/chat'; import { cleanup, render } from '@testing-library/react'; import React from 'react'; import { afterEach, describe, it, vi } from 'vitest'; @@ -14,7 +13,7 @@ describe('useChatClient', () => { }); it('should create a provider without error', () => { - const chatClient = newChatClient() as unknown as ChatClient; + const chatClient = newChatClient(); const TestComponent = () => { return
; }; diff --git a/test/react/providers/chat-room-provider.integration.test.tsx b/test/react/providers/chat-room-provider.integration.test.tsx new file mode 100644 index 00000000..c302e22f --- /dev/null +++ b/test/react/providers/chat-room-provider.integration.test.tsx @@ -0,0 +1,52 @@ +import { RoomOptionsDefaults, RoomStatus } from '@ably/chat'; +import { cleanup, configure, render } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { ChatClientProvider } from '../../../src/react/providers/chat-client-provider.tsx'; +import { ChatRoomProvider } from '../../../src/react/providers/chat-room-provider.tsx'; +import { newChatClient } from '../../helper/chat.ts'; +import { randomRoomId } from '../../helper/identifier.ts'; + +describe('ChatRoomProvider', () => { + beforeEach(() => { + configure({ reactStrictMode: true }); + }); + + afterEach(() => { + configure({ reactStrictMode: false }); + cleanup(); + }); + + // This check ensures that a chat room is valid when being used in strict mode + it('should attach the room in strict mode', async () => { + const chatClient = newChatClient(); + const TestComponent = () => { + return
; + }; + const roomId = randomRoomId(); + + const TestProvider = () => { + return ( + + + + + + ); + }; + render(); + + const room = await chatClient.rooms.get(roomId, { reactions: RoomOptionsDefaults.reactions }); + await vi.waitFor( + () => { + expect(room.status).toBe(RoomStatus.Attached); + }, + { timeout: 5000 }, + ); + }); +}); diff --git a/test/react/providers/room-provider.test.tsx b/test/react/providers/room-provider.test.tsx index 1f93bd2a..5e3be3fa 100644 --- a/test/react/providers/room-provider.test.tsx +++ b/test/react/providers/room-provider.test.tsx @@ -1,8 +1,9 @@ -import { ChatClient, RoomOptionsDefaults } from '@ably/chat'; +import { RoomOptionsDefaults } from '@ably/chat'; import { cleanup, render } from '@testing-library/react'; import React from 'react'; import { afterEach, describe, expect, it, vi } from 'vitest'; +import { useRoom } from '../../../src/react/index.ts'; import { ChatClientProvider } from '../../../src/react/providers/chat-client-provider.tsx'; import { ChatRoomProvider } from '../../../src/react/providers/chat-room-provider.tsx'; import { newChatClient } from '../../helper/chat.ts'; @@ -15,9 +16,14 @@ describe('ChatRoomProvider', () => { cleanup(); }); - it('should create a provider without error', () => { - const chatClient = newChatClient() as unknown as ChatClient; + it('should create a provider without error', async () => { + const chatClient = newChatClient(); + let roomResolved = false; const TestComponent = () => { + const { room } = useRoom(); + if (room) { + roomResolved = true; + } return
; }; const roomId = randomRoomId(); @@ -38,14 +44,17 @@ describe('ChatRoomProvider', () => { render(); // Try to get the client to get a room with different options, should fail - expect(() => chatClient.rooms.get(roomId, RoomOptionsDefaults)).toThrowErrorInfoWithCode(40000); + await vi.waitFor(() => { + expect(roomResolved).toBeTruthy(); + }); + await expect(() => chatClient.rooms.get(roomId, RoomOptionsDefaults)).rejects.toBeErrorInfoWithCode(40000); // Now try it with the right options, should be fine - expect(() => chatClient.rooms.get(roomId, { reactions: RoomOptionsDefaults.reactions })).toBeTruthy(); + await chatClient.rooms.get(roomId, { reactions: RoomOptionsDefaults.reactions }); }); - it('should correctly release rooms', () => { - const chatClient = newChatClient() as unknown as ChatClient; + it('should correctly release rooms', async () => { + const chatClient = newChatClient(); const TestComponent = () => { return
; }; @@ -67,7 +76,7 @@ describe('ChatRoomProvider', () => { const r = render(); // Try to get the client to get a room with different options, should fail - expect(() => chatClient.rooms.get(roomId, RoomOptionsDefaults)).toThrowErrorInfoWithCode(40000); + await expect(() => chatClient.rooms.get(roomId, RoomOptionsDefaults)).rejects.toBeErrorInfoWithCode(40000); // Now try it with the right options, should be fine expect(() => chatClient.rooms.get(roomId, { reactions: RoomOptionsDefaults.reactions })); @@ -80,14 +89,14 @@ describe('ChatRoomProvider', () => { expect(() => chatClient.rooms.get(roomId, RoomOptionsDefaults)).toBeTruthy(); }); - it('should attach and detach correctly', () => { - const chatClient = newChatClient() as unknown as ChatClient; + it('should attach and detach correctly', async () => { + const chatClient = newChatClient(); const TestComponent = () => { return
; }; const roomId = randomRoomId(); - const room = chatClient.rooms.get(roomId, { reactions: RoomOptionsDefaults.reactions }); + const room = await chatClient.rooms.get(roomId, { reactions: RoomOptionsDefaults.reactions }); expect(room).toBeTruthy(); vi.spyOn(room, 'attach'); @@ -110,24 +119,28 @@ describe('ChatRoomProvider', () => { const r = render(); // Make sure the room is attaching - expect(room.attach).toHaveBeenCalled(); + await vi.waitFor(() => { + expect(room.attach).toHaveBeenCalled(); + }); r.unmount(); // Make sure the room is detaching - expect(room.detach).toHaveBeenCalled(); + await vi.waitFor(() => { + expect(room.detach).toHaveBeenCalled(); + }); // Try to get the client to get a room with different options, should fail - expect(() => chatClient.rooms.get(roomId, RoomOptionsDefaults)).toThrowErrorInfoWithCode(40000); + await expect(() => chatClient.rooms.get(roomId, RoomOptionsDefaults)).rejects.toBeErrorInfoWithCode(40000); }); - it('should not attach, detach, or release when not configured to do so', () => { - const chatClient = newChatClient() as unknown as ChatClient; + it('should not attach, detach, or release when not configured to do so', async () => { + const chatClient = newChatClient(); const TestComponent = () => { return
; }; const roomId = randomRoomId(); - const room = chatClient.rooms.get(roomId, { reactions: RoomOptionsDefaults.reactions }); + const room = await chatClient.rooms.get(roomId, { reactions: RoomOptionsDefaults.reactions }); expect(room).toBeTruthy(); vi.spyOn(room, 'attach'); @@ -157,8 +170,8 @@ describe('ChatRoomProvider', () => { expect(room.detach).toHaveBeenCalledTimes(0); // Try to get the client to get a room with different options, should fail (since it should not be released) - expect(() => chatClient.rooms.get(roomId, RoomOptionsDefaults)).toThrowErrorInfoWithCode(40000); + await expect(() => chatClient.rooms.get(roomId, RoomOptionsDefaults)).rejects.toBeErrorInfoWithCode(40000); - void chatClient.rooms.release(roomId); + await chatClient.rooms.release(roomId); }); }); diff --git a/vitest.workspace.ts b/vitest.workspace.ts index 85ba0333..0517e37d 100644 --- a/vitest.workspace.ts +++ b/vitest.workspace.ts @@ -17,7 +17,7 @@ export default defineWorkspace([ test: { globalSetup: './test/helper/test-setup.ts', setupFiles: ['./test/helper/expectations.ts'], - include: ['test/react/**/*.test.{tsx,jsx}'], + include: ['test/react/**/*.test.{tsx,jsx,ts}'], name: 'react-hooks', environment: 'jsdom', },